From b244b4eb9d7ba55753b198449cea8cdd7ce4cef2 Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Tue, 27 Oct 2026 21:04:28 +0530 Subject: [PATCH 1/7] NAPP-177: find the release PR even when the run starts right after the merge Same change as Guardian.swift#189, with the step kept identical on both SDKs. The 1.10.0 Release run on Guardian.swift started three seconds after the merge, before GitHub linked the merge commit to its PR, and skipped; the Android 0.11.3 run did not hit it, but the timing risk is the same. Detect release now reads the PR number from the squash title first (exact, and it settles ordinary merges at once), then falls back to the commit-to-PR lookup with 6 attempts 5 seconds apart. It also outputs the release PR number for the job summary. Co-Authored-By: Claude --- .github/workflows/release.yml | 44 +++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f4bd0f..7685d96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,7 @@ jobs: release: ${{ steps.detect.outputs.release }} version: ${{ steps.detect.outputs.version }} sha: ${{ steps.detect.outputs.sha }} + pr: ${{ steps.detect.outputs.pr }} env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -93,17 +94,45 @@ jobs: # 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 + IFS=$'\t' read -r sha pr_number < <(gh pr list --repo "$REPO" --head "release/$version" --base master --state merged \ + --json mergeCommit,mergedAt,number --jq 'sort_by(.mergedAt) | last // empty | "\(.mergeCommit.oid)\t\(.number)"') || true + 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 \ - '[.[] | 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 + # GitHub can take a while to link a fresh merge commit to its PR, and a run + # that starts seconds after the merge can find no PR yet. So first try the + # PR number in the squash title ("Release X.Y.Z (#N)"), which resolves at + # once and also settles ordinary merges quickly, then fall back to the + # commit-to-PR lookup with retries before deciding this is not a release. + # Prints the PR's head ref and number, or nothing if it is not this merge. + pr_by_number() { + gh api "repos/$REPO/pulls/$1" --jq \ + "select(.merged_at != null and .base.ref == \"master\" and .merge_commit_sha == \"$sha\") | \"\(.head.ref)\t\(.number)\"" + } + pr_by_commit() { + gh api "repos/$REPO/commits/$sha/pulls" --jq \ + '[.[] | select(.merged_at != null and .base.ref == "master")] | sort_by(.head.ref | startswith("release/") | not) | .[0] // empty | "\(.head.ref)\t\(.number)"' + } + title="$(jq -r '.head_commit.message // ""' "$GITHUB_EVENT_PATH" | head -1)" + number="$(sed -nE 's/.*\(#([0-9]+)\)$/\1/p' <<<"$title")" + found="" + if [[ -n "$number" ]]; then + found="$(pr_by_number "$number" || true)" + fi + attempts=6 + for attempt in $(seq 1 "$attempts"); do + [[ -n "$found" ]] && break + found="$(pr_by_commit || true)" + if [[ -z "$found" && "$attempt" -lt "$attempts" ]]; then + echo "No merged PR linked to $sha yet (attempt $attempt of $attempts); retrying in 5 seconds." + sleep 5 + fi + done + IFS=$'\t' read -r head_ref pr_number <<<"$found" || true + if [[ ! "${head_ref:-}" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Not a release merge; nothing to do." echo "release=false" >> "$GITHUB_OUTPUT" exit 0 @@ -118,8 +147,9 @@ jobs: echo "release=true" echo "version=$version" echo "sha=$sha" + echo "pr=$pr_number" } >> "$GITHUB_OUTPUT" - echo "Release $version from $sha" + echo "Release $version from $sha (PR #$pr_number)" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 if: steps.detect.outputs.release == 'true' From b4c038ca11c179374c0046c5d5e93efe1a28e87a Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Tue, 27 Oct 2026 21:04:48 +0530 Subject: [PATCH 2/7] NAPP-186: name the SDK and version in the approval request and summarise the release Same change as Guardian.swift#189. The deployment review email only said "Publish to Maven Central", so an approver could not tell what they were publishing. The publish job is now "Publish Guardian.Android X.Y.Z to Maven Central staging", which shows in the email, the run page and the approval dialog, and the supersede check matches it by prefix so runs named either way are still found. Validate version writes a job summary with the SDK, version, commit, release PR, a reminder that approval only uploads to staging, and the full CHANGELOG entry. Co-Authored-By: Claude --- .github/workflows/release.yml | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7685d96..e2af229 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,6 +177,36 @@ jobs: exit 1 fi + # What the approver reads on the run page, right above the approval button. + - name: Summarise the release for the approver + if: steps.detect.outputs.release == 'true' + env: + VERSION: ${{ steps.detect.outputs.version }} + SHA: ${{ steps.detect.outputs.sha }} + PR: ${{ steps.detect.outputs.pr }} + run: | + set -euo pipefail + { + echo "### Guardian.Android $VERSION is waiting for approval" + echo + echo "| | |" + echo "|---|---|" + echo "| SDK | Guardian.Android (com.auth0.android:guardian on Maven Central) |" + echo "| Version | $VERSION |" + echo "| Commit | [\`${SHA:0:7}\`]($GITHUB_SERVER_URL/$REPO/commit/$SHA) |" + echo "| Release PR | [#$PR]($GITHUB_SERVER_URL/$REPO/pull/$PR) |" + echo + echo "Approving uploads $VERSION to Maven Central staging. It goes public only after someone clicks Publish in the Maven Central Portal." + echo + echo "#### CHANGELOG entry" + echo + awk -v h="## [${VERSION}](" ' + index($0, h) == 1 { on = 1; next } + on && /^## \[/ { exit } + on { print } + ' CHANGELOG.md + } >> "$GITHUB_STEP_SUMMARY" + # 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. @@ -200,7 +230,7 @@ jobs: 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')" + --jq '[.jobs[] | select((.name | startswith("Publish")) and .status == "in_progress")] | length')" if [[ "$publishing" != "0" ]]; then echo "Run $id is already publishing $VERSION; leaving it." continue @@ -272,7 +302,9 @@ jobs: # (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 + # The SDK and version show in the review email, the run page and the approval + # dialog, so the approver knows what they are publishing. + name: Publish Guardian.Android ${{ needs.validate-version.outputs.version }} to Maven Central staging needs: [validate-version, test] if: needs.validate-version.outputs.release == 'true' runs-on: ubuntu-latest From 37386a1e820377b8e34f52e6c0c7a5af7c93d1d2 Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Tue, 27 Oct 2026 21:05:02 +0530 Subject: [PATCH 3/7] NAPP-183: clearer wording for the staging_dropped checkbox and its error The checkbox confused the team during the first runs. Its label in the Run workflow form is now "Replace an unpublished upload: tick only after you clicked Drop on this version's earlier upload in the Maven Central Portal", and the preflight error says exactly what to do: click Drop on that upload in Deployments, then run Release by hand with that box ticked, or release the next version if this one was already Published. A comment next to the input lists when to tick it and when not to. The input keeps its name, since the form shows the description, so behaviour and existing runbooks are unchanged. Co-Authored-By: Claude --- .github/workflows/release.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2af229..bee0aae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,8 +45,16 @@ on: description: "Version to release (X.Y.Z). Run on master." required: true type: string + # A confirmation, not an action: the workflow cannot tell which staging upload + # is which, so it trusts this. Leave it unticked for every normal release, for + # re-running a failed run and for any other manual run. Tick it only when an + # earlier run uploaded this version, nobody clicked Publish, a fix for the same + # version was merged, the new run stopped with "already uploaded to staging + # from another commit", and you clicked Drop on the old upload in the Portal. + # Once a version is Published it can never change: release the fix as the + # next version. (The input keeps its name; the form shows the description.) staging_dropped: - description: "Correction only: I dropped the earlier staging deployment for this version in the Maven Central Portal" + description: "Replace an unpublished upload: tick only after you clicked Drop on this version's earlier upload in the Maven Central Portal" required: false type: boolean default: false @@ -391,10 +399,10 @@ jobs: 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." + echo "::error::$VERSION was already uploaded to staging from another commit, $tag_sha (this run is for $SHA). In the Maven Central Portal, Deployments, click Drop on that upload, then run Release by hand for $VERSION with 'Replace an unpublished upload' ticked. If $VERSION was already Published there, it can no longer change: release the fix as the next version." exit 1 fi - echo "The earlier staging deployment was dropped; moving the Release and tag to $SHA." + echo "'Replace an unpublished upload' is ticked: the earlier upload was dropped, so moving the Release and tag to $SHA and uploading again." state="moved" fi echo "state=$state" >> "$GITHUB_OUTPUT" From 31bb9f54ca5a6834729a56123b2589ec22c95f39 Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Tue, 27 Oct 2026 21:05:30 +0530 Subject: [PATCH 4/7] NAPP-182: skip the Release Test job when the release PR already tested the same tree Same change as Guardian.swift#189, with the check step identical on both SDKs. For 0.11.3 the merge commit's tree was identical to the release PR head, whose Test check (lint, unit tests and the coverage gate) had just passed, so the Release Test job re-ran the same suite on the same files. Validate version now compares the trees and reads the PR head's latest Test check run; when the trees match and the check passed, the Test job is skipped, otherwise it runs as before. Publish accepts a skipped Test only in that case, and uses !cancelled() so a superseded run still stops. The approver summary says which way the tests were covered. Co-Authored-By: Claude --- .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bee0aae..7cf8a7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,9 @@ name: Release # 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). +# Test is skipped when the release PR's Test check already passed on a commit with +# exactly the same tree as the merge commit (the usual case: the release branch was +# up to date with master), since it would run the same suite on the same files. # 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; @@ -78,6 +81,7 @@ jobs: version: ${{ steps.detect.outputs.version }} sha: ${{ steps.detect.outputs.sha }} pr: ${{ steps.detect.outputs.pr }} + tested: ${{ steps.tested.outputs.tested }} env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -185,6 +189,33 @@ jobs: exit 1 fi + # Same tree as the release PR's head, and the PR's Test check passed there: + # the PR checks already ran the suite on exactly these files. Any difference + # (master moved after the PR's last check) and the Test job runs as before. + - name: Check whether the release PR already tested this tree + id: tested + if: steps.detect.outputs.release == 'true' + env: + SHA: ${{ steps.detect.outputs.sha }} + PR: ${{ steps.detect.outputs.pr }} + run: | + set -euo pipefail + head="$(gh api "repos/$REPO/pulls/$PR" --jq .head.sha)" + merge_tree="$(gh api "repos/$REPO/git/commits/$SHA" --jq .tree.sha)" + head_tree="$(gh api "repos/$REPO/git/commits/$head" --jq .tree.sha)" + # The latest Test check run on the PR head, from GitHub Actions (pr.yml). + tests="$(gh api "repos/$REPO/commits/$head/check-runs?check_name=Test&filter=latest" \ + --jq '[.check_runs[] | select(.app.slug == "github-actions")] | if length == 0 then "none" elif all(.conclusion == "success") then "success" else "failed" end')" + if [[ "$merge_tree" == "$head_tree" && "$tests" == "success" ]]; then + echo "Release PR #$PR passed Test on ${head:0:7}, whose tree is identical to ${SHA:0:7}; skipping the Test job." + echo "tested=true" >> "$GITHUB_OUTPUT" + echo "note=Passed on the release PR head ${head:0:7}, same tree as this commit, so not run again" >> "$GITHUB_OUTPUT" + else + echo "Running Test: tree ${merge_tree:0:7} vs PR head ${head_tree:0:7}, PR Test check: $tests." + echo "tested=false" >> "$GITHUB_OUTPUT" + echo "note=Run in this workflow (the merged tree differs from the PR head, or the PR's Test check did not pass)" >> "$GITHUB_OUTPUT" + fi + # What the approver reads on the run page, right above the approval button. - name: Summarise the release for the approver if: steps.detect.outputs.release == 'true' @@ -192,6 +223,7 @@ jobs: VERSION: ${{ steps.detect.outputs.version }} SHA: ${{ steps.detect.outputs.sha }} PR: ${{ steps.detect.outputs.pr }} + TESTS: ${{ steps.tested.outputs.note }} run: | set -euo pipefail { @@ -203,6 +235,7 @@ jobs: echo "| Version | $VERSION |" echo "| Commit | [\`${SHA:0:7}\`]($GITHUB_SERVER_URL/$REPO/commit/$SHA) |" echo "| Release PR | [#$PR]($GITHUB_SERVER_URL/$REPO/pull/$PR) |" + echo "| Tests | $TESTS |" echo echo "Approving uploads $VERSION to Maven Central staging. It goes public only after someone clicks Publish in the Maven Central Portal." echo @@ -251,7 +284,7 @@ jobs: test: name: Test needs: validate-version - if: needs.validate-version.outputs.release == 'true' + if: needs.validate-version.outputs.release == 'true' && needs.validate-version.outputs.tested != 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -314,7 +347,10 @@ jobs: # dialog, so the approver knows what they are publishing. name: Publish Guardian.Android ${{ needs.validate-version.outputs.version }} to Maven Central staging needs: [validate-version, test] - if: needs.validate-version.outputs.release == 'true' + # Test is either run and green, or skipped because the release PR already ran it. + if: >- + !cancelled() && needs.validate-version.result == 'success' && needs.validate-version.outputs.release == 'true' && + (needs.test.result == 'success' || (needs.test.result == 'skipped' && needs.validate-version.outputs.tested == 'true')) runs-on: ubuntu-latest # 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 From 8461a7167299bb9009ba682f8baf436f11584db9 Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Tue, 27 Oct 2026 21:07:04 +0530 Subject: [PATCH 5/7] NAPP-179: move the build tooling to Ruby 3.3, in step with Guardian.swift fastlane warns that Ruby 3.2 support is going away and it will require 3.3, and Guardian.swift moved to 3.3 in #189, so .ruby-version moves from 3.2 to 3.3 (setup-ruby resolves the latest 3.3 patch, as before). Co-Authored-By: Claude --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index 4fe5631..f30101c 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2 \ No newline at end of file +3.3 \ No newline at end of file From cd97fcffa7cbd3767d6edb3677abf950ca4bae50 Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Tue, 27 Oct 2026 21:07:05 +0530 Subject: [PATCH 6/7] NAPP-179: update fastlane to 2.241.1 to clear the rubyzip alert The one open Dependabot alert (high) is rubyzip 2.4.1, fixed in 3.4.0, and fastlane 2.237.0 caps rubyzip below 3. fastlane 2.241.1, the version Guardian.swift already uses, allows rubyzip 3, so the pin moves there and the lock updates conservatively to fastlane's own dependencies (rubyzip 3.7.0, faraday 2). Build tooling only: the SDK artifact is built and published by Gradle. fastlane loads and lists all five lanes with the new lock. Co-Authored-By: Claude --- fastlane/Gemfile | 2 +- fastlane/Gemfile.lock | 105 +++++++++++++++++++++++++----------------- 2 files changed, 64 insertions(+), 43 deletions(-) diff --git a/fastlane/Gemfile b/fastlane/Gemfile index b74da93..211854f 100644 --- a/fastlane/Gemfile +++ b/fastlane/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -gem "fastlane", "2.237.0" +gem "fastlane", "2.241.1" # abbrev/ostruct stopped shipping as default gems starting Ruby 3.4/4.0; fastlane and its # dependencies (highline, actions_helper) still require them at load-time diff --git a/fastlane/Gemfile.lock b/fastlane/Gemfile.lock index 49bebf2..0289634 100644 --- a/fastlane/Gemfile.lock +++ b/fastlane/Gemfile.lock @@ -31,6 +31,7 @@ GEM base64 (0.3.0) benchmark (0.5.0) bigdecimal (4.1.3) + cgi (0.5.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -43,38 +44,26 @@ GEM domain_name (0.6.20260921) dotenv (2.8.1) emoji_regex (3.2.3) + erb (6.0.7) excon (1.6.0) logger - faraday (1.10.6) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) + faraday (2.15.1) + faraday-net_http (>= 2.0, < 3.6) + json + logger faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) + faraday-net_http (3.5.0) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.237.0) + fastlane (2.241.1) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) addressable (>= 2.9.0, < 3.0.0) @@ -84,24 +73,28 @@ GEM base64 (~> 0.2) benchmark (>= 0.1.0) bundler (>= 2.4.0, < 5.0.0) + cgi (~> 0.4) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 2.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + erb (>= 4.0, < 7.0) + excon (>= 1.0.0, < 2.0.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) + google-apis-androidpublisher_v3 (~> 0.99) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-env (>= 1.6.0, < 2.5.0) google-cloud-storage (~> 1.31) highline (~> 2.0) - http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) @@ -114,11 +107,11 @@ GEM optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) + rubyzip (>= 3.4.0, < 4.0.0) + security (~> 0.3) simctl (~> 1.6.3) - terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-notifier (>= 3.1.0, < 4.0.0) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -173,6 +166,12 @@ GEM domain_name (~> 0.5) httpclient (2.9.0) mutex_m + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) json (2.21.2) jwt (3.3.0) @@ -185,14 +184,34 @@ GEM mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) + net-http (0.10.0) + net-protocol (>= 0.3.0) + uri (>= 0.11.1) + net-protocol (0.4.0) + timeout nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.1) + prism (2.0.0) pstore (0.2.1) public_suffix (7.0.5) rake (13.4.2) + rbs (4.1.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.1.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) representable (3.3.1) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -200,9 +219,8 @@ GEM retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) - ruby2_keywords (0.0.5) - rubyzip (2.4.1) - security (0.1.5) + rubyzip (3.7.0) + security (0.4.0) signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -210,16 +228,19 @@ GEM simctl (1.6.10) CFPropertyList naturally - terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-notifier (3.1.0) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + timeout (0.6.1) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) unicode-display_width (2.6.0) + uri (1.1.2) word_wrap (1.0.0) xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) @@ -241,7 +262,7 @@ PLATFORMS DEPENDENCIES abbrev - fastlane (= 2.237.0) + fastlane (= 2.241.1) ostruct BUNDLED WITH From f3860b46915684edf8d292f3e7e5e4457bf9624c Mon Sep 17 00:00:00 2001 From: Manigandan Parthasarathi Date: Tue, 27 Oct 2026 21:10:05 +0530 Subject: [PATCH 7/7] NAPP-185: add a Validate release workflow that checks every part of a release Same design as Guardian.swift#189, adapted to the manual Portal promotion. Validate release (validate-release.yml, logic in .github/scripts/validate-release.sh) checks one version and writes a pass or fail table to the job summary, and fails if any check fails. It is read-only. Stage staged runs as the last job of Release: the GitHub Release is published, marked Latest when it is the highest version, and its notes match the CHANGELOG entry at the tag; no draft is left behind; the tag points at the merge commit of the newest merged release PR, which is on master and Verified; master's .version and README install line are at this version or later; and the newest Release run for the version succeeded. Stage published is started by hand after clicking Publish in the Portal and adds that repo1.maven.org serves the POM, the AAR and their .asc signatures (polling up to wait-minutes), that the POM has the right version, and that Maven resolves the exact artifact. The staging deployment in the Portal is not checked, because reading it needs the Maven Central credentials, which only the approved publish job may use. Run locally, the Maven Central rows pass for the published 0.11.2, and for 0.11.3, which was only a pipeline test run, it correctly reports no Release, no tag and nothing on Maven Central. Co-Authored-By: Claude --- .github/scripts/validate-release.sh | 248 +++++++++++++++++++++++++ .github/workflows/release.yml | 26 ++- .github/workflows/validate-release.yml | 77 ++++++++ 3 files changed, 349 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/validate-release.sh create mode 100644 .github/workflows/validate-release.yml diff --git a/.github/scripts/validate-release.sh b/.github/scripts/validate-release.sh new file mode 100755 index 0000000..b494dc0 --- /dev/null +++ b/.github/scripts/validate-release.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# validate-release.sh: check that every part of a Guardian.Android release happened, +# and report each check as a row of a pass or fail table. Read-only: it never +# changes a release, it only reports. Exits 1 if any check fails. +# +# Usage: validate-release.sh X.Y.Z +# +# Environment: +# REPO owner/name (default auth0/Guardian.Android) +# GH_TOKEN token for gh (read access is enough) +# STAGE "staged": right after the Release run, before anyone clicks +# Publish in the Maven Central Portal (the default); +# "published": after the Portal click, also checks that Maven +# Central serves the version and that it resolves +# WAIT_MINUTES how long to wait for Maven Central to serve the version +# (default 0: check once) +# CURRENT_RUN_ID id of the Release run calling this, if any; that run counts +# as the newest Release run for the version +# GITHUB_STEP_SUMMARY where the table goes (default stdout) +# +# Needs gh, jq, curl, git and mvn (all on the GitHub ubuntu runners). +# +# The staging deployment in the Portal is not checked: reading it needs the Maven +# Central credentials, which only the approved publish job may use. +set -uo pipefail + +VERSION="${1:?usage: validate-release.sh X.Y.Z}" +REPO="${REPO:-auth0/Guardian.Android}" +STAGE="${STAGE:-staged}" +WAIT_MINUTES="${WAIT_MINUTES:-0}" +CURRENT_RUN_ID="${CURRENT_RUN_ID:-}" +SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}" + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::'$VERSION' is not X.Y.Z." + exit 1 +fi +if [[ "$STAGE" != "staged" && "$STAGE" != "published" ]]; then + echo "::error::STAGE must be staged or published, not '$STAGE'." + exit 1 +fi + +rows=() +failed=0 +# check RESULT NAME EXPECTED FOUND +check() { + local mark="PASS" + if [[ "$1" != "pass" ]]; then + mark="FAIL" + failed=$((failed + 1)) + echo "::error::$2: expected $3, found $4" + fi + rows+=("| $mark | $2 | $3 | $4 |") +} +# Runs a condition until it passes or WAIT_MINUTES is up. Returns its last status. +wait_for() { + local deadline=$((SECONDS + WAIT_MINUTES * 60)) + until "$@"; do + if (( SECONDS >= deadline )); then + return 1 + fi + sleep 15 + done +} +# file_at REF PATH: a file's content at a ref, or nothing. +file_at() { + local content + content="$(gh api "repos/$REPO/contents/$2?ref=$1" --jq .content 2>/dev/null)" || return 0 + base64 --decode <<<"$content" 2>/dev/null +} +# The X.Y.Z entry of a CHANGELOG on stdin, as release.yml extracts it. +changelog_entry() { + tr -d '\r' | awk -v h="## [${VERSION}](" ' + index($0, h) == 1 { on = 1; next } + on && /^## \[/ { exit } + on { print } + ' +} +# Text without CRs, trailing spaces or leading and trailing blank lines, to compare. +normalise() { + python3 -c 'import sys; print("\n".join(l.rstrip() for l in sys.stdin.read().replace("\r", "").strip().split("\n")))' +} +at_least() { # at_least FOUND WANTED: FOUND is WANTED or a later version + [[ -n "$1" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$2" ]] +} + +# ── GitHub Release ────────────────────────────────────────────────────────────── +release="$(gh api "repos/$REPO/releases?per_page=100" --jq \ + "[.[] | select(.tag_name == \"$VERSION\" or .name == \"$VERSION\")]" 2>/dev/null)" || release='[]' +published="$(jq -c '[.[] | select(.draft == false and .tag_name == $v)][0] // empty' --arg v "$VERSION" <<<"$release")" +drafts="$(jq '[.[] | select(.draft)] | length' <<<"$release")" +if [[ -n "$published" ]]; then + check pass "GitHub Release" "published Release $VERSION" "published" +else + check fail "GitHub Release" "published Release $VERSION" "$( [[ "$drafts" != "0" ]] && echo "only a draft" || echo "none")" +fi +if [[ "$drafts" == "0" ]]; then + check pass "No leftover draft" "no draft Release for $VERSION" "none" +else + check fail "No leftover draft" "no draft Release for $VERSION" "$drafts draft(s)" +fi + +highest="$(git ls-remote --tags --refs "https://github.com/$REPO.git" 2>/dev/null \ + | sed -nE 's|.*refs/tags/([0-9]+\.[0-9]+\.[0-9]+)$|\1|p' | sort -V | tail -1)" +latest="$(gh api "repos/$REPO/releases/latest" --jq .tag_name 2>/dev/null)" || latest="" +if [[ "$highest" != "$VERSION" ]]; then + check pass "Marked Latest" "Latest only if $VERSION is the highest version" "not the highest ($highest), Latest is $latest" +elif [[ "$latest" == "$VERSION" ]]; then + check pass "Marked Latest" "Latest is $VERSION" "$latest" +else + check fail "Marked Latest" "Latest is $VERSION" "${latest:-none}" +fi + +if [[ -n "$published" ]]; then + body="$(jq -r .body <<<"$published" | normalise)" + entry="$(file_at "$VERSION" CHANGELOG.md | changelog_entry | normalise)" + if [[ -z "$entry" ]]; then + check fail "Release notes" "the CHANGELOG.md entry for $VERSION at the tag" "no entry at the tag" + elif [[ "$body" == "$entry" ]]; then + check pass "Release notes" "the CHANGELOG.md entry for $VERSION at the tag" "identical" + else + check fail "Release notes" "the CHANGELOG.md entry for $VERSION at the tag" "a different body" + fi +fi + +# ── Tag and commit ────────────────────────────────────────────────────────────── +# The commit the tag points at: the peeled entry for an annotated tag, else the tag. +tag_sha="$(git ls-remote --tags "https://github.com/$REPO.git" "refs/tags/$VERSION" "refs/tags/$VERSION^{}" 2>/dev/null \ + | sort -k2 | tail -1 | cut -f1)" +merge_sha="$(gh pr list --repo "$REPO" --head "release/$VERSION" --base master --state merged \ + --json mergeCommit,mergedAt --jq 'sort_by(.mergedAt) | last | .mergeCommit.oid // ""' 2>/dev/null)" || merge_sha="" +if [[ -z "$tag_sha" ]]; then + check fail "Tag" "tag $VERSION on the release PR's merge commit" "no tag" +elif [[ -z "$merge_sha" ]]; then + check fail "Tag" "tag $VERSION on the release PR's merge commit" "no merged release/$VERSION PR" +elif [[ "$tag_sha" == "$merge_sha" ]]; then + check pass "Tag" "tag $VERSION on the release PR's merge commit" "${tag_sha:0:7}" +else + check fail "Tag" "tag $VERSION on the release PR's merge commit ${merge_sha:0:7}" "${tag_sha:0:7}" +fi +if [[ -n "$tag_sha" ]]; then + status="$(gh api "repos/$REPO/compare/$tag_sha...master" --jq .status 2>/dev/null)" || status="" + if [[ "$status" == "ahead" || "$status" == "identical" ]]; then + check pass "On master" "the tagged commit is in master's history" "yes ($status)" + else + check fail "On master" "the tagged commit is in master's history" "${status:-unknown}" + fi + verified="$(gh api "repos/$REPO/commits/$tag_sha" --jq .commit.verification.verified 2>/dev/null)" || verified="" + if [[ "$verified" == "true" ]]; then + check pass "Verified" "the tagged commit is Verified" "Verified" + else + check fail "Verified" "the tagged commit is Verified" "${verified:-unknown}" + fi +fi + +# ── master's version files and README ───────────────────────────────────────── +master_version="$(file_at master .version | tr -d '[:space:]')" +bad="" +at_least "$master_version" "$VERSION" || bad=".version is ${master_version:-missing}" +readme_v="$(file_at master README.md | sed -nE 's/.*com\.auth0\.android:guardian:([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' | head -1)" +at_least "$readme_v" "$VERSION" || bad="${bad:+$bad, }README install line is ${readme_v:-missing}" +if [[ -z "$bad" ]]; then + check pass "master versions" "$VERSION or later in .version and the README" "$master_version" +else + check fail "master versions" "$VERSION or later in .version and the README" "$bad" +fi + +# ── Release workflow run ────────────────────────────────────────────────────── +# The version a Release run released: from a manual run's title, or the release PR +# its pushed commit belongs to. +run_version() { # event head_sha title + 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/")' 2>/dev/null + fi +} +newest="" +while IFS=$'\t' read -r id event head_sha title conclusion; do + [[ -z "$id" ]] && continue + if [[ "$(run_version "$event" "$head_sha" "$title")" == "$VERSION" ]]; then + newest="$id"$'\t'"$conclusion" + break + fi +done < <(gh api "repos/$REPO/actions/workflows/release.yml/runs?per_page=50" \ + --jq '.workflow_runs[] | [.id, .event, .head_sha, .display_title, (.conclusion // "running")] | @tsv' 2>/dev/null) +IFS=$'\t' read -r run_id run_conclusion <<<"$newest" +if [[ -z "${run_id:-}" ]]; then + check fail "Release run" "the newest Release run for $VERSION succeeded" "no Release run found" +elif [[ "$run_id" == "$CURRENT_RUN_ID" ]]; then + check pass "Release run" "the newest Release run for $VERSION succeeded" "this run ($run_id), publish succeeded" +elif [[ "$run_conclusion" == "success" ]]; then + check pass "Release run" "the newest Release run for $VERSION succeeded" "run $run_id: success" +else + check fail "Release run" "the newest Release run for $VERSION succeeded" "run $run_id: $run_conclusion" +fi + +# ── Maven Central, once promoted ──────────────────────────────────────────────── +base="https://repo1.maven.org/maven2/com/auth0/android/guardian/$VERSION/guardian-$VERSION" +if [[ "$STAGE" == "published" ]]; then + served() { + local f + for f in pom pom.asc aar aar.asc; do + [[ "$(curl -s -o /dev/null -w '%{http_code}' "$base.$f")" == "200" ]] || return 1 + done + } + if wait_for served; then + check pass "Maven Central" "repo1 serves the POM, the AAR and their .asc signatures" "all served" + pom_v="$(curl -sf "$base.pom" | grep -m1 -oE '[^<]+' | sed 's///')" + if [[ "$pom_v" == "$VERSION" ]]; then + check pass "POM version" "the published POM is version $VERSION" "$pom_v" + else + check fail "POM version" "the published POM is version $VERSION" "${pom_v:-unreadable}" + fi + # As a consumer would: resolve the exact artifact from Maven Central. Not its + # dependencies, which come from Google's Maven repository. + if mvn -B -q dependency:get -Dartifact="com.auth0.android:guardian:$VERSION:aar" -Dtransitive=false \ + >/tmp/mvn-resolve.log 2>&1; then + check pass "Resolves" "Maven resolves com.auth0.android:guardian:$VERSION" "resolved" + else + check fail "Resolves" "Maven resolves com.auth0.android:guardian:$VERSION" "failed: $(tail -1 /tmp/mvn-resolve.log)" + fi + else + check fail "Maven Central" "repo1 serves the POM, the AAR and their .asc signatures" "not served after $WAIT_MINUTES min" + fi +fi + +# ── Report ────────────────────────────────────────────────────────────────────── +{ + if (( failed == 0 )); then + echo "### Guardian.Android $VERSION ($STAGE): all release checks passed" + else + echo "### Guardian.Android $VERSION ($STAGE): $failed release check(s) failed" + fi + echo + echo "| | Check | Expected | Found |" + echo "|---|---|---|---|" + printf '%s\n' "${rows[@]}" + echo + if [[ "$STAGE" == "staged" ]]; then + echo "Before promotion: check the com.auth0.android:guardian $VERSION deployment in the Maven Central Portal (Deployments) and click Publish, then run Validate release by hand for $VERSION with stage published." + else + echo "The staging deployment in the Portal is not checked here (it needs the release credentials)." + fi +} >> "$SUMMARY" + +(( failed == 0 )) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cf8a7c..624025a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,8 @@ name: Release # 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). +# Jobs: validate-version -> test -> publish (maven-central-release approval gate) +# -> validate-release. # Test is skipped when the release PR's Test check already passed on a commit with # exactly the same tree as the merge commit (the usual case: the release branch was # up to date with master), since it would run the same suite on the same files. @@ -20,7 +21,11 @@ name: Release # (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. +# Then validate-release (validate-release.yml, stage staged) checks the GitHub side +# of the release: the Release and its notes, the tag, master's versions and this +# run. It reports a pass or fail table and never changes the release. +# Then promote the staging deployment by hand at https://central.sonatype.com, and +# run Validate release by hand with stage published to check Maven Central. # # 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 @@ -562,6 +567,7 @@ jobs: 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/" + echo "Then run Validate release by hand for $VERSION with stage published, to check Maven Central." } >> "$GITHUB_STEP_SUMMARY" - name: Clean up credentials @@ -570,3 +576,19 @@ jobs: rm -f /tmp/secring.gpg sed -i '/mavenCentralUsername\|mavenCentralPassword\|signing\./d' \ ~/.gradle/gradle.properties 2>/dev/null || true + + # ── 4. Check the GitHub side of the release ──────────────────────────────── + # Also after a run that had nothing left to redo, so the report covers it too. + # Maven Central is checked after the Portal promotion, by hand (stage published). + validate-release: + name: Validate release + needs: [validate-version, publish] + if: "!cancelled() && needs.publish.result == 'success'" + permissions: + contents: read + actions: read + pull-requests: read + uses: ./.github/workflows/validate-release.yml + with: + version: ${{ needs.validate-version.outputs.version }} + stage: staged diff --git a/.github/workflows/validate-release.yml b/.github/workflows/validate-release.yml new file mode 100644 index 0000000..997d9ac --- /dev/null +++ b/.github/workflows/validate-release.yml @@ -0,0 +1,77 @@ +name: Validate release + +# Checks that every part of a release happened and reports each check as a pass or +# fail row in the job summary (see .github/scripts/validate-release.sh for the +# list). Read-only: it never changes a release. The job fails if any check fails. +# +# Two stages, because promotion in the Maven Central Portal is a manual click: +# staged runs automatically as the last job of the Release workflow, before +# promotion: the GitHub Release, its notes, the tag, master's versions +# and the Release run. +# published start it by hand after clicking Publish in the Portal (Actions tab, +# Validate release, Run workflow, with the version): the same checks, +# plus that Maven Central serves the POM, the AAR and their signatures +# and that the version resolves. Use wait-minutes (for example 30) to +# let it poll while the version becomes public. + +on: + workflow_call: + inputs: + version: + description: "Version to validate (X.Y.Z)" + required: true + type: string + stage: + description: "staged (before promotion in the Portal) or published (after it)" + required: false + type: string + default: staged + wait-minutes: + description: "How long to wait for Maven Central to serve the version" + required: false + type: number + default: 0 + workflow_dispatch: + inputs: + version: + description: "Version to validate (X.Y.Z)" + required: true + type: string + stage: + description: "staged (before promotion in the Portal) or published (after it)" + required: true + type: choice + options: [published, staged] + default: published + wait-minutes: + description: "How long to wait for Maven Central to serve the version" + required: false + type: number + default: 30 + +permissions: + contents: read + +jobs: + validate: + name: Validate release ${{ inputs.version }} (${{ inputs.stage }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + actions: read # find the newest Release run for the version + pull-requests: read # find the release PR and map runs to it + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 + + - name: Validate + run: .github/scripts/validate-release.sh "$VERSION" + env: + VERSION: ${{ inputs.version }} + STAGE: ${{ inputs.stage }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + WAIT_MINUTES: ${{ inputs.wait-minutes }} + # Called from Release, this is that run's id, which counts as the newest + # Release run for the version. + CURRENT_RUN_ID: ${{ github.run_id }}