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 9f4bd0f..624025a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,13 +11,21 @@ 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. # 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. +# 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 @@ -45,8 +53,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 @@ -69,6 +85,8 @@ jobs: release: ${{ steps.detect.outputs.release }} 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 }} @@ -93,17 +111,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 +164,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' @@ -147,6 +194,65 @@ 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' + env: + VERSION: ${{ steps.detect.outputs.version }} + SHA: ${{ steps.detect.outputs.sha }} + PR: ${{ steps.detect.outputs.pr }} + TESTS: ${{ steps.tested.outputs.note }} + 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 "| 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 + 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. @@ -170,7 +276,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 @@ -183,7 +289,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: @@ -242,9 +348,14 @@ 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' + # 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 @@ -329,10 +440,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" @@ -456,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 @@ -464,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 }} 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 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