From d494052a33ac1c2ecab93f87fa52f5dc5750eac9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 15 Oct 2026 16:17:37 -0500 Subject: [PATCH] feat(release-rc): tag and release each RC after Central confirms it A new tag-and-release job waits for the orb's bump commit and for the version on Maven Central, checks that the Central artifact was published after this merge, then creates the signed annotated tag (SSH key from the QRUN_TAG_SIGNING_KEY secret) on the bump commit and a GitHub prerelease listing the merged PRs. It never creates an unsigned tag and skips a tag that already exists. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/qbit-release-rc.yml | 124 +++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/.github/workflows/qbit-release-rc.yml b/.github/workflows/qbit-release-rc.yml index 11b2c57..6b944cc 100644 --- a/.github/workflows/qbit-release-rc.yml +++ b/.github/workflows/qbit-release-rc.yml @@ -3,8 +3,14 @@ # Called from each qbit's .github/workflows/release-rc.yml on a manual dispatch or when a # develop PR labelled `release:rc` is merged. It merges develop into the release line through # the GitHub API (GitHub signs the merge commit), and the push of release/* runs the existing -# CircleCI publish_release_candidate workflow (qqq-orb: RC bump, Maven Central, tag). Nothing -# here publishes, tags or touches hotfix/* branches. +# CircleCI publish_release_candidate workflow (qqq-orb: RC bump, Maven Central). The second job, +# tag-and-release, then waits for the orb's "Bump version to X [skip ci]" commit and for X on Maven +# Central, checks that the Central artifact was published after this merge (provenance), and only +# then creates the signed annotated tag vX on the bump commit and a GitHub prerelease whose notes +# list the merged PRs. Nothing here publishes artifacts or touches hotfix/* branches. +# +# Signing: the tag is signed with the SSH key in the QRUN_TAG_SIGNING_KEY secret (callers pass +# `secrets: inherit`). Without it the job fails before tagging; it never creates an unsigned tag. # # Release line: develop's X.Y.Z-SNAPSHOT picks release/X.Y.Z if that branch # exists, else release/X.Y (created from develop when missing). @@ -17,6 +23,10 @@ on: description: Print the plan without merging or creating branches type: boolean default: false + secrets: + QRUN_TAG_SIGNING_KEY: + description: SSH private key that signs release tags + required: false permissions: contents: write @@ -28,8 +38,13 @@ jobs: concurrency: group: qbit-release-rc-${{ github.repository }} cancel-in-progress: false + outputs: + branch: ${{ steps.merge.outputs.branch }} + merge_sha: ${{ steps.merge.outputs.merge_sha }} + merged_at: ${{ steps.merge.outputs.merged_at }} steps: - name: Merge develop into the release line + id: merge env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -55,6 +70,7 @@ jobs: echo "Plan: create $branch from develop $develop_sha (first RC of $full)" [[ "$DRY_RUN" == true ]] && exit 0 gh api "repos/$REPO/git/refs" -f ref="refs/heads/$branch" -f sha="$develop_sha" --silent + merge_sha="$develop_sha" else echo "Plan: merge develop $develop_sha into $branch" [[ "$DRY_RUN" == true ]] && exit 0 @@ -63,11 +79,113 @@ jobs: -f commit_message="Merge develop into $branch for the next $full RC" 2>&1) || true status=$(printf '%s\n' "$response" | sed -n 's:^HTTP/[0-9.]* \([0-9][0-9]*\).*:\1:p' | head -n 1) case "$status" in - 201) ;; + 201) merge_sha=$(gh api "repos/$REPO/branches/$branch" -q .commit.sha) ;; 204) echo "::warning::$branch already contains develop; no new RC"; exit 0 ;; 409) echo "::error::Merge conflict between develop and $branch; resolve it in a PR into $branch"; exit 1 ;; *) echo "::error::Merge of develop into $branch failed (HTTP ${status:-unknown})"; exit 1 ;; esac fi + { + echo "branch=$branch" + echo "merge_sha=$merge_sha" + echo "merged_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } >> "$GITHUB_OUTPUT" echo "Pushed $branch; CircleCI publish_release_candidate cuts the RC." >> "$GITHUB_STEP_SUMMARY" echo "Release line: $branch" >> "$GITHUB_STEP_SUMMARY" + + tag-and-release: + needs: cut-rc + if: needs.cut-rc.outputs.merge_sha != '' + runs-on: ubuntu-24.04 + timeout-minutes: 150 + concurrency: + group: qbit-release-rc-tag-${{ github.repository }} + cancel-in-progress: false + steps: + - name: Wait for the orb bump and the Maven Central artifact, then tag and release + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BRANCH: ${{ needs.cut-rc.outputs.branch }} + MERGE_SHA: ${{ needs.cut-rc.outputs.merge_sha }} + MERGED_AT: ${{ needs.cut-rc.outputs.merged_at }} + SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }} + run: | + set -euo pipefail + if [[ -z "$SIGNING_KEY" ]]; then + echo "::error::QRUN_TAG_SIGNING_KEY is not available (the caller needs 'secrets: inherit'); not creating an unsigned tag. The release captain tags this RC." + exit 1 + fi + artifact=$(gh api "repos/$REPO/contents/pom.xml?ref=$MERGE_SHA" -q .content | base64 -d \ + | sed -n 's:.*\(qbit-[^<]*\).*:\1:p' | head -n 1) + group_path=com/kingsrook/qbits + + # 1. The orb pushes "Bump version to X [skip ci]" on top of the merge after it deploys. + bump_sha=""; version="" + for _ in $(seq 1 100); do + line=$(gh api "repos/$REPO/commits?sha=$BRANCH&per_page=1" -q '.[0] | .sha + " " + (.commit.message | split("\n")[0])') + sha=${line%% *}; msg=${line#* } + if [[ "$msg" =~ ^Bump\ version\ to\ ([0-9]+\.[0-9]+\.[0-9]+(-RC\.[0-9]+)?)\ \[skip\ ci\]$ ]] \ + && [[ "$(gh api "repos/$REPO/compare/$MERGE_SHA...$sha" -q .status)" == ahead ]]; then + bump_sha=$sha; version=${BASH_REMATCH[1]}; break + fi + sleep 60 + done + if [[ -z "$bump_sha" ]]; then + echo "::error::No orb bump commit on $BRANCH after the merge; the release captain tags this RC." + exit 1 + fi + if gh api "repos/$REPO/git/refs/tags/v$version" --silent 2>/dev/null; then + echo "::warning::Tag v$version already exists; nothing to do." + exit 0 + fi + + # 2. Provenance: the Central artifact must exist and be newer than this merge. + pom="https://repo1.maven.org/maven2/$group_path/$artifact/$version/$artifact-$version.pom" + for _ in $(seq 1 60); do + [[ "$(curl -s -o /dev/null -w '%{http_code}' "$pom")" == 200 ]] && break + sleep 60 + done + last_modified=$(curl -sI "$pom" | tr -d '\r' | sed -n 's/^[Ll]ast-[Mm]odified: //p') + if [[ "$(curl -s -o /dev/null -w '%{http_code}' "$pom")" != 200 || -z "$last_modified" ]]; then + echo "::error::$artifact $version is not on Maven Central" + exit 1 + fi + if (( $(date -d "$last_modified" +%s) <= $(date -d "$MERGED_AT" +%s) )); then + echo "::error::$artifact $version on Central ($last_modified) predates this merge ($MERGED_AT); not tagging." + exit 1 + fi + + # 3. Signed annotated tag on the bump commit. + workdir=$(mktemp -d) + key="$workdir/signing_key" + install -m 600 /dev/null "$key" + printf '%s\n' "$SIGNING_KEY" > "$key" + git init -q "$workdir/repo" + git -C "$workdir/repo" fetch -q --depth=1 "https://x-access-token:$GH_TOKEN@github.com/$REPO.git" "$bump_sha" + git -C "$workdir/repo" -c user.name="James Maes" -c user.email=james@kof22.com -c gpg.format=ssh \ + -c user.signingkey="$key" tag -s "v$version" "$bump_sha" -m "$artifact $version" + rm -f "$key" + git -C "$workdir/repo" push -q "https://x-access-token:$GH_TOKEN@github.com/$REPO.git" "v$version" + + # 4. GitHub prerelease whose notes list the merged PRs since the previous tag. + previous=$(gh api "repos/$REPO/tags?per_page=100" -q '.[].name' | grep -E '^v[0-9]' | grep -vx "v$version" | sort -V | tail -n 1 || true) + prs=$(gh api "repos/$REPO/compare/${previous:-$MERGE_SHA}...$bump_sha" \ + -q '.commits[].commit.message | select(startswith("Merge pull request #")) | split("\n") | (.[0] | capture("#(?[0-9]+)").n) + "\t" + (.[2] // "")') + notes="$workdir/notes.md" + { + echo "$artifact **$version** release candidate (Maven Central, $last_modified)." + echo + echo "## Changes" + if [[ -n "$prs" ]]; then + while IFS=$'\t' read -r n title; do echo "- $title ([#$n](https://github.com/$REPO/pull/$n))"; done <<< "$prs" + else + echo "- See the commits since ${previous:-the previous release}." + fi + echo + echo "The signed annotated tag \`v$version\` fixes source \`${bump_sha:0:7}\` on \`$BRANCH\`." + echo + echo "No GA approval is implied." + } > "$notes" + gh release create "v$version" -R "$REPO" --verify-tag --prerelease --title "$artifact $version" --notes-file "$notes" + echo "Tagged and released v$version ($artifact) on ${bump_sha:0:7}." >> "$GITHUB_STEP_SUMMARY" -- 2.54.0