From 64413915b947b26c79028505b77d5d6effb9dff1 Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Fri, 16 Oct 2026 12:48:19 +0530 Subject: [PATCH 1/5] NAPP-173: merge the release PR before tagging and publishing Adds prepare-release.yml, which opens the release/X.Y.Z PR with the version files and a drafted CHANGELOG entry as one GitHub-signed commit. Reworks release.yml to start on the squash merge into master: validate, test, then behind the approval gate create a draft GitHub Release on the merge commit, upload to Maven Central staging and publish the draft (which creates the tag). Newer runs for a version cancel older ones that have not started publishing, and a stale run refuses to publish. This matches the old Android release guides and the shared SDK release guide. Co-Authored-By: Claude --- .github/workflows/prepare-release.yml | 250 +++++++++++++++ .github/workflows/release.yml | 427 ++++++++++++++++---------- 2 files changed, 519 insertions(+), 158 deletions(-) create mode 100644 .github/workflows/prepare-release.yml diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..e052ff4 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,250 @@ +name: Prepare release + +# Step 1 of a release (see the Guardian SDK Release Guide on Confluence, AA1 1156121307). +# Run manually on master with the version to ship: +# +# gh workflow run prepare-release.yml --ref master -f version=X.Y.Z +# +# It creates release/X.Y.Z from master, sets .version and the README install line, +# drafts the CHANGELOG.md entry, commits all of it as ONE verified commit (created +# through the GitHub API so GitHub signs it; a `git commit` here would be unsigned) +# and opens the "Release X.Y.Z" PR. Review and edit the CHANGELOG entry on the PR, +# then squash-merge it: the Release workflow (release.yml) starts on that merge. +# +# Re-running for the same version is safe: +# - open release PR for X.Y.Z -> nothing to do, prints the PR +# - branch exists, no PR yet -> finishes the commit and opens the PR +# - release PR already merged -> correction mode: recreates release/X.Y.Z from +# master (the old branch was squash-merged, so a PR from it would show the old +# commits again) so a fix can go through a new PR before the version ships. + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (X.Y.Z, no v prefix)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: prepare-release-${{ inputs.version }} + cancel-in-progress: false + +jobs: + prepare: + name: Prepare release + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # create the branch and the release commit + pull-requests: write # open the release PR + actions: write # start pr.yml on the branch (see the last step) + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + VERSION: ${{ inputs.version }} + BRANCH: release/${{ inputs.version }} + steps: + - name: Require master + if: github.ref != 'refs/heads/master' + run: | + echo "::error::Run Prepare release on master (it ran on ${GITHUB_REF_NAME})." + exit 1 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 + with: + fetch-depth: 0 # all tags, for the latest-version check and the changelog range + + - name: Validate version and work out what to do + id: plan + run: | + set -euo pipefail + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::'$VERSION' is not X.Y.Z." + exit 1 + fi + + pom="https://repo1.maven.org/maven2/com/auth0/android/guardian/${VERSION}/guardian-${VERSION}.pom" + if [[ "$(curl -s -o /dev/null -w '%{http_code}' "$pom")" == "200" ]]; then + echo "::error::$VERSION is already on Maven Central. Versions are immutable there; release the next version instead." + exit 1 + fi + + latest="$(git tag -l | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)" + echo "latest=$latest" >> "$GITHUB_OUTPUT" + + open_pr="$(gh pr list --repo "$REPO" --head "$BRANCH" --base master --state open --json url --jq '.[0].url // ""')" + if [[ -n "$open_pr" ]]; then + echo "Release PR for $VERSION is already open: $open_pr" + echo "mode=done" >> "$GITHUB_OUTPUT" + exit 0 + fi + + merged_pr="$(gh pr list --repo "$REPO" --head "$BRANCH" --base master --state merged --json url --jq '.[0].url // ""')" + if [[ -n "$merged_pr" ]]; then + echo "The release PR for $VERSION was already merged ($merged_pr) and $VERSION is not on Maven Central yet: correction mode." + echo "mode=correction" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # A fresh release: the version must be new everywhere and higher than the last one. + if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then + echo "::error::Tag $VERSION already exists." + exit 1 + fi + if gh release view "$VERSION" --repo "$REPO" >/dev/null 2>&1; then + echo "::error::A GitHub Release $VERSION already exists." + exit 1 + fi + if [[ -n "$latest" ]]; then + highest="$(printf '%s\n%s\n' "$latest" "$VERSION" | sort -V | tail -1)" + if [[ "$VERSION" == "$latest" || "$highest" != "$VERSION" ]]; then + echo "::error::$VERSION is not higher than the latest release, $latest." + exit 1 + fi + fi + echo "mode=release" >> "$GITHUB_OUTPUT" + + # Correction mode: point release/X.Y.Z at master's head (create it if it was + # deleted after the merge). Nothing to commit: master already carries the + # version files and the CHANGELOG entry, so the fix goes on the branch next. + - name: Recreate the release branch from master (correction mode) + if: steps.plan.outputs.mode == 'correction' + run: | + set -euo pipefail + head="$(git rev-parse origin/master)" + if gh api "repos/$REPO/git/ref/heads/$BRANCH" >/dev/null 2>&1; then + gh api -X PATCH "repos/$REPO/git/refs/heads/$BRANCH" -f sha="$head" -F force=true >/dev/null + else + gh api "repos/$REPO/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$head" >/dev/null + fi + { + echo "### $BRANCH recreated from master" + echo + echo "Push the fix (and any CHANGELOG correction) to \`$BRANCH\`, open a PR into master and merge it." + echo "The merge starts a new Release run for $VERSION, which cancels the older run if it has not started publishing." + echo "Do not approve the older run in the meantime." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Create the release branch + if: steps.plan.outputs.mode == 'release' + run: | + set -euo pipefail + if gh api "repos/$REPO/git/ref/heads/$BRANCH" >/dev/null 2>&1; then + echo "$BRANCH already exists; reusing it." + else + gh api "repos/$REPO/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$(git rev-parse origin/master)" >/dev/null + echo "Created $BRANCH from master." + fi + git fetch origin "$BRANCH" + git checkout -B "$BRANCH" "origin/$BRANCH" + + - name: Set the version files + if: steps.plan.outputs.mode == 'release' + run: | + set -euo pipefail + echo "$VERSION" > .version + sed -E -i "s/(com\.auth0\.android:guardian:)[0-9]+\.[0-9]+\.[0-9]+/\1${VERSION}/g" README.md + if ! grep -q "com.auth0.android:guardian:${VERSION}" README.md; then + echo "::error::Could not find the com.auth0.android:guardian install line in README.md." + exit 1 + fi + + # Drafts the entry in the existing CHANGELOG format from the PRs merged since + # the previous tag. PR titles are untrusted text, so they only ever pass through + # files and Python, never through the shell. + - name: Draft the CHANGELOG entry + if: steps.plan.outputs.mode == 'release' + env: + LATEST: ${{ steps.plan.outputs.latest }} + run: | + set -euo pipefail + if grep -qF "## [${VERSION}](" CHANGELOG.md; then + echo "CHANGELOG.md already has an entry for $VERSION; keeping it." + exit 0 + fi + args=(-f tag_name="$VERSION" -f target_commitish=master) + [[ -n "$LATEST" ]] && args+=(-f previous_tag_name="$LATEST") + gh api "repos/$REPO/releases/generate-notes" "${args[@]}" --jq '.body' > /tmp/notes.md + python3 - <<'PY' + import datetime, os, re + + version, latest, repo = os.environ["VERSION"], os.environ["LATEST"], os.environ["REPO"] + bullets = [] + for line in open("/tmp/notes.md", encoding="utf-8"): + line = line.rstrip("\n") + if line.startswith("## New Contributors") or line.startswith("**Full Changelog**"): + break + m = re.match(r"^\* (.+) by @\S+ in (https://\S+/pull/\d+)$", line) + if m: + bullets.append(f"- {m.group(1)} (see [PR]({m.group(2)}))") + if not bullets: + bullets = ["- TODO: describe the changes in this release"] + + date = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d") + entry = [f"## [{version}](https://github.com/{repo}/tree/{version}) ({date})"] + if latest: + entry.append(f"[Full Changelog](https://github.com/{repo}/compare/{latest}...{version})") + entry += ["", "**Changed**", *bullets, "", ""] + + with open("CHANGELOG.md", encoding="utf-8") as f: + lines = f.read().split("\n") + # Keep the "# Change Log" title and insert the new entry above the newest one. + at = next((i for i, l in enumerate(lines) if l.startswith("## ")), len(lines)) + with open("CHANGELOG.md", "w", encoding="utf-8") as f: + f.write("\n".join(lines[:at] + entry + lines[at:])) + PY + + # One commit through the Git Data API (blobs, tree, commit, ref update). No + # author or committer is passed, so GitHub signs it and it shows as Verified. + - name: Commit as one verified commit + if: steps.plan.outputs.mode == 'release' + run: | + set -euo pipefail + files=(.version README.md CHANGELOG.md) + if git diff --quiet -- "${files[@]}"; then + echo "Version files and CHANGELOG are already up to date on $BRANCH." + exit 0 + fi + parent="$(git rev-parse HEAD)" + base_tree="$(git rev-parse 'HEAD^{tree}')" + entries='[]' + for f in "${files[@]}"; do + git diff --quiet -- "$f" && continue + blob="$(base64 -w0 "$f" | jq -Rs '{content: ., encoding: "base64"}' \ + | gh api "repos/$REPO/git/blobs" --input - --jq .sha)" + entries="$(jq -c --arg p "$f" --arg s "$blob" '. + [{path: $p, mode: "100644", type: "blob", sha: $s}]' <<<"$entries")" + done + tree="$(jq -n --arg b "$base_tree" --argjson t "$entries" '{base_tree: $b, tree: $t}' \ + | gh api "repos/$REPO/git/trees" --input - --jq .sha)" + commit="$(jq -n --arg m "Release $VERSION" --arg t "$tree" --arg p "$parent" '{message: $m, tree: $t, parents: [$p]}' \ + | gh api "repos/$REPO/git/commits" --input - --jq .sha)" + # Not forced: if someone pushed to the branch meanwhile, fail rather than drop their work. + gh api -X PATCH "repos/$REPO/git/refs/heads/$BRANCH" -f sha="$commit" >/dev/null + echo "Committed $commit to $BRANCH." + + - name: Open the release PR + if: steps.plan.outputs.mode == 'release' + run: | + set -euo pipefail + body="$(cat <> "$GITHUB_STEP_SUMMARY" + + # A PR opened with GITHUB_TOKEN does not trigger pull_request workflows, so + # start the PR checks on the branch explicitly. Their check runs land on the + # PR's head commit. Later pushes to the branch trigger pr.yml as usual. + - name: Run the PR checks on the release branch + if: steps.plan.outputs.mode == 'release' + run: gh workflow run pr.yml --repo "$REPO" --ref "$BRANCH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7de666e..8849cbd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,93 +1,177 @@ name: Release -# Triggered automatically when any commit is pushed to a release/X.Y.Z branch, -# or manually via workflow_dispatch (select the release branch in the UI). +# Step 2 of a release (see the Guardian SDK Release Guide on Confluence, AA1 1156121307). +# Runs on every push to master and carries on only when the pushed commit is the +# squash merge of a release/X.Y.Z PR (opened by prepare-release.yml). It runs on +# the push rather than on the PR's "closed" event so that github.ref is master, +# which is what an environment restricted to master expects. # -# The validate-version job syncs .version to the branch name if they don't match, -# so the developer only needs to create the branch — .version is handled automatically. -# It then outputs the SHA it ended on; test and publish check out THAT exact SHA so -# every job runs against the same commit (including the sync) and a mid-flight push -# to the branch can't make publish ship an untested commit. +# Manual run (for example after fixing an environment problem): +# gh workflow run release.yml --ref master -f version=X.Y.Z # -# Required GitHub secrets — configure these as ENVIRONMENT secrets on the -# `maven-central-release` environment (NOT repo-wide), so they are only readable -# by the publish job after the approval gate: -# MAVEN_CENTRAL_USERNAME — Maven Central Portal username -# MAVEN_CENTRAL_PASSWORD — Maven Central Portal password -# SIGNING_KEY_ID — GPG public key ID -# SIGNING_KEY_PASSWORD — GPG secret key passphrase -# SIGNING_SECRET_KEY_RING_B64 — base64-encoded secring.gpg -# Generate with: base64 -i secring.gpg | pbcopy +# Jobs: validate-version -> test -> publish (maven-central-release approval gate). +# After approval, publish: +# 1. creates the GitHub Release X.Y.Z as a DRAFT targeting the merge commit +# (a draft does not create the tag), with the CHANGELOG entry as its body; +# 2. uploads to Maven Central staging; +# 3. publishes the draft, which creates the X.Y.Z tag on the merge commit. +# Then promote the staging deployment by hand at https://central.sonatype.com. # -# Required GitHub environment (Settings → Environments): -# maven-central-release — add required reviewers (human approval gate) and -# the secrets above. The gate attaches to the -# publish job, which is the only job that uses them. +# Re-runs never need a manual cleanup: a failed upload leaves only the draft, +# which the next run reuses. A newer run for the same version cancels older runs +# that have not started publishing, and publish refuses to run if a newer run for +# its version exists, so approving a stale run does nothing. # -# Release steps: -# 1. Create branch release/X.Y.Z — workflow triggers automatically. -# .version is auto-synced to the branch version if needed. -# 2. Approve the publish job when it pauses on the maven-central-release gate. -# 3. After publish completes: log in to central.sonatype.com and manually promote -# the staging deployment (publishToMavenCentral(false) — no auto-release). +# Required GitHub environment: maven-central-release, with required reviewers +# and these ENVIRONMENT secrets (not repo-wide, so only the publish job can read +# them, and only after approval): +# MAVEN_CENTRAL_USERNAME Maven Central Portal username +# MAVEN_CENTRAL_PASSWORD Maven Central Portal password +# SIGNING_KEY_ID GPG public key ID +# SIGNING_KEY_PASSWORD GPG secret key passphrase +# SIGNING_SECRET_KEY_RING_B64 base64-encoded secring.gpg (base64 -i secring.gpg | pbcopy) + +# Manual runs carry the version in their title; the superseding logic below reads it back. +run-name: ${{ github.event_name == 'workflow_dispatch' && format('Release {0} (manual)', inputs.version) || github.event.head_commit.message }} on: + push: + branches: [master] workflow_dispatch: - + inputs: + version: + description: "Version to release (X.Y.Z). Run on master." + required: true + type: string + staging_dropped: + description: "Correction only: I dropped the earlier staging deployment for this version in the Maven Central Portal" + required: false + type: boolean + default: false permissions: contents: read jobs: - # ── 1. Validate and sync version ────────────────────────────────────────── + # ── 1. Detect and validate the release, supersede older runs ────────────── validate-version: name: Validate version runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 permissions: - contents: write # required to commit the .version sync if needed + contents: read + pull-requests: read # map the pushed commit to its release PR + actions: write # cancel superseded runs outputs: - sha: ${{ steps.sync.outputs.sha }} + release: ${{ steps.detect.outputs.release }} + version: ${{ steps.detect.outputs.version }} + sha: ${{ steps.detect.outputs.sha }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} steps: + - name: Detect release + id: detect + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + sha="$GITHUB_SHA" + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$GITHUB_REF" != "refs/heads/master" ]]; then + echo "::error::Run Release on master (it ran on ${GITHUB_REF_NAME})." + exit 1 + fi + version="$INPUT_VERSION" + else + # The squash merge of a release/X.Y.Z PR into master, or nothing to do. + head_ref="$(gh api "repos/$REPO/commits/$sha/pulls" --jq \ + '[.[] | select(.merged_at != null and .base.ref == "master" and (.head.ref | test("^release/[0-9]+\\.[0-9]+\\.[0-9]+$")))][0].head.ref // ""')" + if [[ -z "$head_ref" ]]; then + echo "Not a release merge; nothing to do." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + version="${head_ref#release/}" + fi + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::'$version' is not X.Y.Z." + exit 1 + fi + { + echo "release=true" + echo "version=$version" + echo "sha=$sha" + } >> "$GITHUB_OUTPUT" + echo "Release $version from $sha" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 + if: steps.detect.outputs.release == 'true' with: - ref: ${{ github.ref }} + ref: ${{ steps.detect.outputs.sha }} - - name: Sync .version to branch name - id: sync + - name: Check the version files and the registry + if: steps.detect.outputs.release == 'true' + env: + VERSION: ${{ steps.detect.outputs.version }} run: | set -euo pipefail - branch="${GITHUB_REF_NAME}" - if [[ ! "$branch" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "❌ This workflow only runs on release/X.Y.Z branches." - echo " Current branch: $branch" + current="$(tr -d '[:space:]' < .version)" + if [[ "$current" != "$VERSION" ]]; then + echo "::error::.version on this commit is '$current', expected $VERSION. Run Prepare release for $VERSION." exit 1 fi - version="${branch#release/}" - current="$(tr -d '[:space:]' < .version)" - if [[ "$current" == "$version" ]]; then - echo "✅ .version is already $version" - else - echo "$version" > .version - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add .version - git commit -m "chore: sync .version to $version" - git push origin "$branch" - echo "✅ Updated .version from $current to $version" + if ! grep -qF "## [${VERSION}](" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no entry for $VERSION." + exit 1 + fi + pom="https://repo1.maven.org/maven2/com/auth0/android/guardian/${VERSION}/guardian-${VERSION}.pom" + if [[ "$(curl -s -o /dev/null -w '%{http_code}' "$pom")" == "200" ]]; then + echo "::error::$VERSION is already on Maven Central. Release the next version instead." + exit 1 fi - # Emit the commit every later job must check out (post-sync if we committed). - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - # ── 2. Run tests ─────────────────────────────────────────────────────────── + # Cancel older runs for the same version that have not started publishing: + # still validating or testing, or waiting at the approval gate. A run whose + # publish job is already executing is left alone. + - name: Supersede older runs for this version + if: steps.detect.outputs.release == 'true' + env: + VERSION: ${{ steps.detect.outputs.version }} + run: | + set -euo pipefail + run_version() { # event head_sha title -> the version that run releases + if [[ "$1" == "workflow_dispatch" ]]; then + sed -nE 's/^Release ([0-9]+\.[0-9]+\.[0-9]+) \(manual\)$/\1/p' <<<"$3" + else + gh api "repos/$REPO/commits/$2/pulls" \ + --jq '[.[] | select(.merged_at != null and (.head.ref | startswith("release/")))][0].head.ref // "" | ltrimstr("release/")' + fi + } + for status in queued in_progress waiting; do + gh api "repos/$REPO/actions/workflows/release.yml/runs?status=$status&per_page=100" \ + --jq ".workflow_runs[] | select(.id < $GITHUB_RUN_ID) | [.id, .event, .head_sha, .display_title] | @tsv" + done | while IFS=$'\t' read -r id event head_sha title; do + [[ "$(run_version "$event" "$head_sha" "$title")" == "$VERSION" ]] || continue + publishing="$(gh api "repos/$REPO/actions/runs/$id/jobs" \ + --jq '[.jobs[] | select(.name == "Publish to Maven Central" and .status == "in_progress")] | length')" + if [[ "$publishing" != "0" ]]; then + echo "Run $id is already publishing $VERSION; leaving it." + continue + fi + echo "Cancelling superseded run $id for $VERSION." + gh api -X POST "repos/$REPO/actions/runs/$id/cancel" >/dev/null || true + done + + # ── 2. Run tests on the exact commit being released ─────────────────────── test: name: Test needs: validate-version + if: needs.validate-version.outputs.release == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: - # Pin to the exact commit validate-version ended on (includes the .version sync). - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 with: ref: ${{ needs.validate-version.outputs.sha }} @@ -139,79 +223,166 @@ jobs: path: guardian/build/reports/reports.html # ── 3. Publish ───────────────────────────────────────────────────────────── - # The maven-central-release environment supplies BOTH the human approval gate - # (required reviewers) AND the Maven/signing secrets — so approval and secret - # scoping attach directly to the job that uses the credentials. + # The maven-central-release environment supplies BOTH the approval gate + # (required reviewers) AND the Maven/signing secrets, so approval and secret + # scoping attach to the one job that uses the credentials. publish: name: Publish to Maven Central - needs: [test, validate-version] + needs: [validate-version, test] + if: needs.validate-version.outputs.release == 'true' runs-on: ubuntu-latest - # No job-level timeout — the environment approval gate counts against it, so a - # job timeout would cancel the job if a reviewer takes longer than the limit - # (e.g. cross-timezone, weekend). The environment itself has a configurable - # deployment timeout (Settings → Environments → maven-central-release → Timeout). - # Individual execution steps have their own timeout-minutes below. + # No job-level timeout: the approval wait counts against it, so a job timeout + # would cancel the job if a reviewer takes longer than the limit. The + # environment has its own deployment timeout; the long steps have their own. environment: maven-central-release + # Never two publishes for one version at once, and never cancel one mid-flight. + concurrency: + group: maven-central-publish-${{ needs.validate-version.outputs.version }} + cancel-in-progress: false permissions: - contents: write # required for the CHANGELOG commit, git tag, and GitHub Release - pull-requests: write # required to open the post-release PR to master + contents: write # GitHub Release and its tag + actions: read # newest-run check + pull-requests: read # newest-run check maps runs to their release PR + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + VERSION: ${{ needs.validate-version.outputs.version }} + SHA: ${{ needs.validate-version.outputs.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 with: ref: ${{ needs.validate-version.outputs.sha }} - # Fail BEFORE any irreversible work if this version was already released. - # Re-running after a partial failure must not re-upload a second staging - # deployment or prepend a duplicate CHANGELOG block. - - name: Preflight — version + idempotency guard + # Approving a stale run must do nothing: if a newer Release run exists for + # this version (a correction was merged), stop before anything public. + - name: Check this is the newest run for the version + run: | + set -euo pipefail + run_version() { # event head_sha title -> the version that run releases + if [[ "$1" == "workflow_dispatch" ]]; then + sed -nE 's/^Release ([0-9]+\.[0-9]+\.[0-9]+) \(manual\)$/\1/p' <<<"$3" + else + gh api "repos/$REPO/commits/$2/pulls" \ + --jq '[.[] | select(.merged_at != null and (.head.ref | startswith("release/")))][0].head.ref // "" | ltrimstr("release/")' + fi + } + gh api "repos/$REPO/actions/workflows/release.yml/runs?per_page=100" \ + --jq ".workflow_runs[] | select(.id > $GITHUB_RUN_ID) | [.id, .event, .head_sha, .display_title] | @tsv" \ + | while IFS=$'\t' read -r id event head_sha title; do + if [[ "$(run_version "$event" "$head_sha" "$title")" == "$VERSION" ]]; then + echo "::error::A newer Release run exists for $VERSION (run $id). This run is superseded; approve the newer one." + exit 1 + fi + done + + # Decide what this run has to do, from what earlier runs left behind: + # nothing yet -> create the draft, upload, publish it + # a draft Release -> reuse it (pointed at this commit), upload, publish it + # a published Release on this SHA -> the upload already happened: promote in the Portal + # a published Release elsewhere -> correction after an earlier upload: only once the + # earlier staging deployment was dropped by hand + - name: Preflight + id: preflight env: - GH_TOKEN: ${{ github.token }} + STAGING_DROPPED: ${{ inputs.staging_dropped }} run: | set -euo pipefail - tag="$(tr -d '[:space:]' < .version)" - if [[ ! "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "❌ .version is not a valid X.Y.Z version: '$tag'" + pom="https://repo1.maven.org/maven2/com/auth0/android/guardian/${VERSION}/guardian-${VERSION}.pom" + if [[ "$(curl -s -o /dev/null -w '%{http_code}' "$pom")" == "200" ]]; then + echo "::error::$VERSION is already on Maven Central. Release the next version instead." exit 1 fi - if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then - echo "❌ Tag '$tag' already exists on the remote — this version was already released." - exit 1 + if ! state="$(gh release view "$VERSION" --repo "$REPO" --json isDraft --jq 'if .isDraft then "draft" else "published" end' 2>/dev/null)"; then + state="none" fi - if gh release view "$tag" >/dev/null 2>&1; then - echo "❌ GitHub Release '$tag' already exists — this version was already released." + if [[ "$state" == "published" ]]; then + tag_sha="$(gh api "repos/$REPO/commits/$VERSION" --jq .sha)" + if [[ "$tag_sha" == "$SHA" ]]; then + echo "::notice::$VERSION was already uploaded to staging from this commit. Promote it at https://central.sonatype.com; nothing to redo." + echo "action=skip" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "$STAGING_DROPPED" != "true" ]]; then + echo "::error::$VERSION was already uploaded to staging from $tag_sha, and this run is for $SHA. Drop that deployment in the Maven Central Portal (Deployments, Drop), then run Release manually for $VERSION with 'staging_dropped' ticked." + exit 1 + fi + echo "The earlier staging deployment was dropped; moving the Release and tag to $SHA." + state="moved" + fi + echo "state=$state" >> "$GITHUB_OUTPUT" + echo "action=publish" >> "$GITHUB_OUTPUT" + + - name: Extract the CHANGELOG entry + if: steps.preflight.outputs.action == 'publish' + run: | + set -euo pipefail + awk -v h="## [${VERSION}](" ' + index($0, h) == 1 { on = 1; next } + on && /^## \[/ { exit } + on { print } + ' CHANGELOG.md > /tmp/release-notes.md + if ! grep -q '[^[:space:]]' /tmp/release-notes.md; then + echo "::error::Empty CHANGELOG entry for $VERSION." exit 1 fi - echo "✅ Version $tag is clean to release." + + # Android consumers never resolve the tag, so moving it is safe until the + # Portal promotion, and it keeps the tag on the commit that actually ships. + - name: Create or update the draft GitHub Release + if: steps.preflight.outputs.action == 'publish' + env: + STATE: ${{ steps.preflight.outputs.state }} + run: | + set -euo pipefail + if [[ "$STATE" == "moved" ]]; then + gh release delete "$VERSION" --repo "$REPO" --cleanup-tag --yes + STATE="none" + fi + if [[ "$STATE" == "draft" ]]; then + id="$(gh release view "$VERSION" --repo "$REPO" --json databaseId --jq .databaseId)" + gh api -X PATCH "repos/$REPO/releases/$id" \ + -f target_commitish="$SHA" -F body=@/tmp/release-notes.md >/dev/null + echo "Reused the draft Release $VERSION, now targeting $SHA." + else + gh release create "$VERSION" --repo "$REPO" --draft --target "$SHA" \ + --title "$VERSION" --notes-file /tmp/release-notes.md + echo "Created the draft Release $VERSION targeting $SHA." + fi - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # pin@v6.0.1 + if: steps.preflight.outputs.action == 'publish' with: distribution: temurin java-version-file: '.java-version' - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # pin@v6.4.0 + if: steps.preflight.outputs.action == 'publish' - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # pin@v1.327.0 + if: steps.preflight.outputs.action == 'publish' with: bundler: 'Gemfile.lock' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # pin@v6.1.0 + if: steps.preflight.outputs.action == 'publish' with: path: ~/vendor/bundle key: bundle-v1-${{ hashFiles('fastlane/Gemfile.lock') }} restore-keys: bundle-v1- - name: Install Ruby dependencies + if: steps.preflight.outputs.action == 'publish' run: bundle check || bundle install --jobs 4 --retry 3 env: BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile BUNDLE_PATH: ~/vendor/bundle - # signing.* properties contain dots → can't be expressed as env vars → - # must be written to ~/.gradle/gradle.properties. The delimiter is QUOTED - # (<<'EOF') so a secret value containing $, backtick, or $(...) is written - # literally and never shell-expanded. Runner is ephemeral; cleanup step - # removes the file defensively anyway. + # signing.* properties contain dots, so they can't be env vars and must be + # written to ~/.gradle/gradle.properties. The delimiter is QUOTED (<<'EOF') + # so a secret containing $, a backtick or $(...) is written literally and + # never shell-expanded. The cleanup step removes the file afterwards. - name: Configure signing and Maven Central credentials + if: steps.preflight.outputs.action == 'publish' run: | set -euo pipefail echo "${{ secrets.SIGNING_SECRET_KEY_RING_B64 }}" | base64 --decode > /tmp/secring.gpg @@ -224,7 +395,10 @@ jobs: signing.secretKeyRingFile=/tmp/secring.gpg EOF + # publishToMavenCentral(false) in guardian/build.gradle uploads to staging + # and does NOT release; promotion in the Portal is a deliberate manual step. - name: Publish to Maven Central (staging) + if: steps.preflight.outputs.action == 'publish' timeout-minutes: 30 run: ./build.sh --buildType release --notest --nolint --nocoverage --publish --nobuild --skip-deps env: @@ -232,86 +406,23 @@ jobs: BUNDLE_PATH: ~/vendor/bundle RELEASE_CONTEXT: "true" - # After this step: log in to https://central.sonatype.com and manually promote - # the staging deployment. publishToMavenCentral(false) in build.gradle uploads - # to staging but does NOT auto-release — promotion is a deliberate manual gate. - - # Generate release notes via the GitHub API, prepend them to CHANGELOG.md, - # and push the result to the release branch. Notes are stored in GITHUB_ENV - # (random delimiter — PR-title text is untrusted) for the Release step to reuse. - - name: Update CHANGELOG.md - timeout-minutes: 10 - env: - GH_TOKEN: ${{ github.token }} + # Publishing the draft creates the X.Y.Z tag on the merge commit. Only + # reached once the upload succeeded, so a failed upload leaves just the draft. + - name: Publish the GitHub Release (creates the tag) + if: steps.preflight.outputs.action == 'publish' run: | set -euo pipefail - tag="$(tr -d '[:space:]' < .version)" - notes="$(gh api "repos/${{ github.repository }}/releases/generate-notes" \ - --method POST \ - --field tag_name="$tag" \ - --field target_commitish="${GITHUB_REF_NAME}" \ - --jq '.body')" - if [[ -z "$notes" ]]; then - echo "❌ generate-notes returned empty — aborting rather than shipping an empty changelog/release." - exit 1 - fi - date="$(date -u +%Y-%m-%d)" - touch CHANGELOG.md # first-release bootstrap: don't hard-fail if absent + gh release edit "$VERSION" --repo "$REPO" --draft=false { - printf '## %s (%s)\n\n%s\n\n' "$tag" "$date" "$notes" - cat CHANGELOG.md - } > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md - git commit -m "chore: update CHANGELOG for $tag" - # Push the detached HEAD to the branch. With set -e, a non-fast-forward - # rejection (someone pushed to the branch mid-run) fails the job here — - # BEFORE we tag, so we never tag a commit that isn't on the branch. - git push origin "HEAD:${GITHUB_REF_NAME}" - # Hand the notes to the Release step via a random, unguessable delimiter so - # a PR title equal to the delimiter can't truncate the value or inject env vars. - delim="EOF_$(openssl rand -hex 16)" - { - echo "RELEASE_NOTES<<${delim}" - echo "$notes" - echo "${delim}" - } >> "$GITHUB_ENV" - - # Tag the commit that includes the updated CHANGELOG (only reached if the - # branch push above succeeded). - - name: Tag release - run: | - set -euo pipefail - tag="$(tr -d '[:space:]' < .version)" - git tag -a "$tag" -m "Release $tag" - git push origin "$tag" - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag="$(tr -d '[:space:]' < .version)" - gh release create "$tag" \ - --title "$tag" \ - --notes "$RELEASE_NOTES" - - - name: Open PR to master - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag="$(tr -d '[:space:]' < .version)" - gh pr create \ - --base master \ - --head "${GITHUB_REF_NAME}" \ - --title "Release $tag" \ - --body "$(printf 'Merges the release branch for **%s** into master.\n\nIncludes:\n- `.version` bump to %s\n- CHANGELOG.md update\n\n> After merging, the release/%s branch can be deleted.' "$tag" "$tag" "$tag")" + echo "### $VERSION uploaded to Maven Central staging" + echo + echo "Next: open https://central.sonatype.com, Deployments, check the com.auth0.android:guardian $VERSION deployment (files and POM) and click Publish." + echo "It is public about 10 to 15 minutes later at https://repo.maven.apache.org/maven2/com/auth0/android/guardian/$VERSION/" + } >> "$GITHUB_STEP_SUMMARY" - name: Clean up credentials if: always() run: | rm -f /tmp/secring.gpg sed -i '/mavenCentralUsername\|mavenCentralPassword\|signing\./d' \ - ~/.gradle/gradle.properties 2>/dev/null || true \ No newline at end of file + ~/.gradle/gradle.properties 2>/dev/null || true From 7b2207120d291f696df60b71dfb2519c0bde3568 Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Fri, 16 Oct 2026 12:48:19 +0530 Subject: [PATCH 2/5] NAPP-173: release the reviewed merge commit on a manual run A manual Release run used to ship master's head, which can include changes merged after the release PR that were never reviewed as part of the release and are not in its CHANGELOG entry. It now releases the merge commit of the newest merged release/X.Y.Z PR (a correction PR, if there was one) and fails when there is no such PR, so a release can no longer skip Prepare release. Co-Authored-By: Claude --- .github/workflows/release.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8849cbd..e34a02b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ name: Release # # Manual run (for example after fixing an environment problem): # gh workflow run release.yml --ref master -f version=X.Y.Z +# It releases the merge commit of the newest merged release/X.Y.Z PR, not master's +# head, and fails if there is no such PR (Prepare release was never run or merged). # # Jobs: validate-version -> test -> publish (maven-central-release approval gate). # After approval, publish: @@ -84,6 +86,19 @@ jobs: exit 1 fi version="$INPUT_VERSION" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::'$version' is not X.Y.Z." + exit 1 + fi + # Release exactly what was reviewed: the merge commit of the newest merged + # release/X.Y.Z PR (a correction PR, if there was one), never master's head, + # which may carry changes merged after the release PR. + sha="$(gh pr list --repo "$REPO" --head "release/$version" --base master --state merged \ + --json mergeCommit,mergedAt --jq 'sort_by(.mergedAt) | last | .mergeCommit.oid // ""')" + if [[ -z "$sha" ]]; then + echo "::error::No merged release/$version PR. Run Prepare release for $version and merge its PR first." + exit 1 + fi else # The squash merge of a release/X.Y.Z PR into master, or nothing to do. head_ref="$(gh api "repos/$REPO/commits/$sha/pulls" --jq \ From 02ce42f33f7a240e8c886566eb6b9e04c476ee25 Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Fri, 16 Oct 2026 12:48:20 +0530 Subject: [PATCH 3/5] NAPP-173: document starting releases from the Actions tab only Review feedback: the header comments showed gh workflow run examples, which read as an invitation to start a release from a terminal. Both workflows now point to the Actions tab instead. The workflows themselves are unchanged. Co-Authored-By: Claude --- .github/workflows/prepare-release.yml | 5 ++--- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index e052ff4..53de517 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,9 +1,8 @@ name: Prepare release # Step 1 of a release (see the Guardian SDK Release Guide on Confluence, AA1 1156121307). -# Run manually on master with the version to ship: -# -# gh workflow run prepare-release.yml --ref master -f version=X.Y.Z +# Start it from the Actions tab (Prepare release, Run workflow, branch master) with +# the version to ship. # # It creates release/X.Y.Z from master, sets .version and the README install line, # drafts the CHANGELOG.md entry, commits all of it as ONE verified commit (created diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e34a02b..e0af5ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,8 @@ name: Release # the push rather than on the PR's "closed" event so that github.ref is master, # which is what an environment restricted to master expects. # -# Manual run (for example after fixing an environment problem): -# gh workflow run release.yml --ref master -f version=X.Y.Z +# Manual run, only to redo a release (for example after fixing an environment +# problem): Actions tab, Release, Run workflow, branch master, with the version. # It releases the merge commit of the newest merged release/X.Y.Z PR, not master's # head, and fails if there is no such PR (Prepare release was never run or merged). # From 7f4b60efe49715c8d962963d8441e7a73371f05a Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Fri, 16 Oct 2026 12:48:20 +0530 Subject: [PATCH 4/5] NAPP-173: approve the held PR checks instead of dispatching pr.yml Testing on a fork showed that GitHub holds the pull_request run of a PR opened by github-actions[bot] at "Approval required", and that a pr.yml run started with workflow_dispatch passes but is not shown on the PR. Prepare release now approves the held run, so the checks show on the release PR. Also drops the extra blank line after the drafted CHANGELOG entry, and stops using the whole commit message as the title of push-triggered Release runs. Co-Authored-By: Claude --- .github/workflows/prepare-release.yml | 33 +++++++++++++++++++++------ .github/workflows/release.yml | 2 +- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 53de517..cde376a 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -40,7 +40,7 @@ jobs: permissions: contents: write # create the branch and the release commit pull-requests: write # open the release PR - actions: write # start pr.yml on the branch (see the last step) + actions: write # approve the PR checks on the release PR (see the last step) env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -187,7 +187,7 @@ jobs: entry = [f"## [{version}](https://github.com/{repo}/tree/{version}) ({date})"] if latest: entry.append(f"[Full Changelog](https://github.com/{repo}/compare/{latest}...{version})") - entry += ["", "**Changed**", *bullets, "", ""] + entry += ["", "**Changed**", *bullets, ""] with open("CHANGELOG.md", encoding="utf-8") as f: lines = f.read().split("\n") @@ -241,9 +241,28 @@ jobs: echo "Opened $url" echo "### Release PR: $url" >> "$GITHUB_STEP_SUMMARY" - # A PR opened with GITHUB_TOKEN does not trigger pull_request workflows, so - # start the PR checks on the branch explicitly. Their check runs land on the - # PR's head commit. Later pushes to the branch trigger pr.yml as usual. - - name: Run the PR checks on the release branch + # GitHub holds the PR checks of a PR opened by github-actions[bot] at "Approval + # required". Approve that run so the checks show on the PR. (Starting pr.yml with + # workflow_dispatch instead runs the tests, but GitHub does not show the result + # on the PR.) Later pushes to the branch trigger pr.yml as usual. + - name: Start the PR checks if: steps.plan.outputs.mode == 'release' - run: gh workflow run pr.yml --repo "$REPO" --ref "$BRANCH" + run: | + set -euo pipefail + sha="$(gh api "repos/$REPO/git/ref/heads/$BRANCH" --jq .object.sha)" + for _ in $(seq 1 20); do + runs="$(gh api "repos/$REPO/actions/workflows/pr.yml/runs?event=pull_request&head_sha=$sha" \ + --jq '[.workflow_runs[] | {id, conclusion}]')" + held="$(jq -r '[.[] | select(.conclusion == "action_required")][0].id // ""' <<<"$runs")" + if [[ -n "$held" ]]; then + gh api -X POST "repos/$REPO/actions/runs/$held/approve" >/dev/null + echo "Approved the PR checks (run $held)." + exit 0 + fi + if [[ "$(jq length <<<"$runs")" != "0" ]]; then + echo "The PR checks are already running." + exit 0 + fi + sleep 3 + done + echo "::warning::No PR check run appeared for $BRANCH. Close and reopen the release PR to start the checks." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0af5ad..eb0eee3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ name: Release # SIGNING_SECRET_KEY_RING_B64 base64-encoded secring.gpg (base64 -i secring.gpg | pbcopy) # Manual runs carry the version in their title; the superseding logic below reads it back. -run-name: ${{ github.event_name == 'workflow_dispatch' && format('Release {0} (manual)', inputs.version) || github.event.head_commit.message }} +run-name: ${{ github.event_name == 'workflow_dispatch' && format('Release {0} (manual)', inputs.version) || '' }} on: push: From 8989da151b622a667c17467ce66f54fe975de948 Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Fri, 16 Oct 2026 12:48:20 +0530 Subject: [PATCH 5/5] NAPP-173: keep the draft Release tag name when updating it Updating a draft Release without tag_name resets it to untagged-, after which the version lookup no longer finds the draft. Send tag_name on every update, find a draft by its title as well, and publish by release id. The superseded-run message now also says to start Release again if the newer run failed. Co-Authored-By: Claude --- .github/workflows/release.yml | 41 +++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb0eee3..9f4bd0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -285,7 +285,7 @@ jobs: --jq ".workflow_runs[] | select(.id > $GITHUB_RUN_ID) | [.id, .event, .head_sha, .display_title] | @tsv" \ | while IFS=$'\t' read -r id event head_sha title; do if [[ "$(run_version "$event" "$head_sha" "$title")" == "$VERSION" ]]; then - echo "::error::A newer Release run exists for $VERSION (run $id). This run is superseded; approve the newer one." + echo "::error::A newer Release run exists for $VERSION (run $id). This run is superseded: approve the newer run, or if that one failed, start Release again for $VERSION from the Actions tab." exit 1 fi done @@ -307,9 +307,20 @@ jobs: echo "::error::$VERSION is already on Maven Central. Release the next version instead." exit 1 fi - if ! state="$(gh release view "$VERSION" --repo "$REPO" --json isDraft --jq 'if .isDraft then "draft" else "published" end' 2>/dev/null)"; then + # Look the Release up by id rather than through `gh release view`: GitHub can + # give a draft an "untagged-..." tag name, so match a draft by its title too. + # VERSION is validated as X.Y.Z, so it is safe inside the jq filter. + release="$(gh api "repos/$REPO/releases?per_page=100" --jq \ + "[.[] | select(.tag_name == \"$VERSION\" or (.draft and .name == \"$VERSION\"))][0] // {}")" + release_id="$(jq -r '.id // ""' <<<"$release")" + if [[ -z "$release_id" ]]; then state="none" + elif [[ "$(jq -r .draft <<<"$release")" == "true" ]]; then + state="draft" + else + state="published" fi + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" if [[ "$state" == "published" ]]; then tag_sha="$(gh api "repos/$REPO/commits/$VERSION" --jq .sha)" if [[ "$tag_sha" == "$SHA" ]]; then @@ -344,25 +355,29 @@ jobs: # Android consumers never resolve the tag, so moving it is safe until the # Portal promotion, and it keeps the tag on the commit that actually ships. - name: Create or update the draft GitHub Release + id: draft if: steps.preflight.outputs.action == 'publish' env: STATE: ${{ steps.preflight.outputs.state }} + RELEASE_ID: ${{ steps.preflight.outputs.release_id }} run: | set -euo pipefail if [[ "$STATE" == "moved" ]]; then - gh release delete "$VERSION" --repo "$REPO" --cleanup-tag --yes + gh api -X DELETE "repos/$REPO/releases/$RELEASE_ID" + gh api -X DELETE "repos/$REPO/git/refs/tags/$VERSION" STATE="none" fi + # Always send tag_name: updating a draft without it resets the tag name. if [[ "$STATE" == "draft" ]]; then - id="$(gh release view "$VERSION" --repo "$REPO" --json databaseId --jq .databaseId)" - gh api -X PATCH "repos/$REPO/releases/$id" \ - -f target_commitish="$SHA" -F body=@/tmp/release-notes.md >/dev/null + id="$(gh api -X PATCH "repos/$REPO/releases/$RELEASE_ID" -f tag_name="$VERSION" \ + -f target_commitish="$SHA" -f name="$VERSION" -F body=@/tmp/release-notes.md --jq .id)" echo "Reused the draft Release $VERSION, now targeting $SHA." else - gh release create "$VERSION" --repo "$REPO" --draft --target "$SHA" \ - --title "$VERSION" --notes-file /tmp/release-notes.md + id="$(gh api "repos/$REPO/releases" -f tag_name="$VERSION" -f target_commitish="$SHA" \ + -f name="$VERSION" -F body=@/tmp/release-notes.md -F draft=true --jq .id)" echo "Created the draft Release $VERSION targeting $SHA." fi + echo "id=$id" >> "$GITHUB_OUTPUT" - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # pin@v6.0.1 if: steps.preflight.outputs.action == 'publish' @@ -425,9 +440,17 @@ jobs: # reached once the upload succeeded, so a failed upload leaves just the draft. - name: Publish the GitHub Release (creates the tag) if: steps.preflight.outputs.action == 'publish' + env: + RELEASE_ID: ${{ steps.draft.outputs.id }} run: | set -euo pipefail - gh release edit "$VERSION" --repo "$REPO" --draft=false + gh api -X PATCH "repos/$REPO/releases/$RELEASE_ID" -f tag_name="$VERSION" \ + -f target_commitish="$SHA" -F draft=false >/dev/null + tag_sha="$(gh api "repos/$REPO/commits/$VERSION" --jq .sha)" + if [[ "$tag_sha" != "$SHA" ]]; then + echo "::error::Tag $VERSION points at $tag_sha, expected $SHA." + exit 1 + fi { echo "### $VERSION uploaded to Maven Central staging" echo