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"