From 3f193ae5e1365e0187e11c97cf74141cd321ff6f Mon Sep 17 00:00:00 2001 From: bytebeast Date: Mon, 10 Aug 2026 13:29:51 -0700 Subject: [PATCH] fix(release): ensure image have semantic ver tag (AH-2026081080303) Refs: AH-2026081080303 --- .github/workflows/image.yml | 30 ++++- .github/workflows/release.yml | 207 ++++++++++++++++++++++++++++------ 2 files changed, 199 insertions(+), 38 deletions(-) diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 0df1f86..b71932e 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -55,7 +55,31 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Tags are derived from the git ref, so the version handoff from + # Single source of truth for the version is APP_VERSION in the script - + # the same value .github/scripts/release.py bumps and release.yml turns + # into the git tag. Reading it here lets main builds carry a + # version-bearing tag, and lets a release build fail loudly if the tag + # and the file disagree. + - name: Read APP_VERSION + id: ver + run: | + version=$(sed -n 's/^APP_VERSION *= *"\(.*\)"/\1/p' check-endpoint.py) + if [ -z "$version" ]; then + echo "::error::could not read APP_VERSION from check-endpoint.py" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "APP_VERSION is $version" >> "$GITHUB_STEP_SUMMARY" + + # On a tag build the two must agree. If they don't, something tagged + # a commit whose script says otherwise, and the image would be + # labelled with a version it doesn't contain. + if [ "$GITHUB_REF_TYPE" = "tag" ] && [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match APP_VERSION $version" + exit 1 + fi + + # Release tags are derived from the git ref, so the version handoff from # release.yml is the tag itself - nothing needs to be passed between # workflows. # @@ -78,6 +102,10 @@ jobs: type=edge,branch=main type=ref,event=pr type=sha,format=short + # Main builds between releases: the version currently in the + # script, suffixed so it can never be mistaken for the released + # image of that version and never overwrites it. + type=raw,value=${{ steps.ver.outputs.version }}-dev.{{sha}},enable={{is_default_branch}} - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9affcd..22f8459 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,35 +13,95 @@ # releasable commits. Without that ordering the two race, and `prepare` would # compute the same version a second time and open a duplicate PR. # -# Needs .github/scripts/release.py. No third-party actions; `gh` is -# preinstalled. - +# ── Why this uses a GitHub App token ────────────────────────────────────── +# +# GITHUB_TOKEN cannot do either half of this job: +# +# 1. It is barred from createPullRequest by policy, regardless of +# `permissions:`. That surfaces as "GitHub Actions is not permitted to +# create or approve pull requests". There is a repo setting that lifts +# this, but see (2) before reaching for it. +# +# 2. Anything GITHUB_TOKEN does - opening a PR, pushing a tag - +# deliberately does NOT trigger further workflows, to prevent recursion. +# Two consequences here: +# - main requires code scanning results, so a GITHUB_TOKEN-authored PR +# would open, never run CodeQL, and sit unmergeable forever. +# - the tag pushed below would be inert, so image.yml would never build +# a version-tagged container. That is why only `edge` and `sha-` +# images appear. +# +# An App installation token is subject to neither restriction. The App needs +# Contents: read & write and Pull requests: read & write on this repository. +# Set vars.RELEASE_APP_ID and secrets.RELEASE_APP_PRIVATE_KEY. +# +# actions/create-github-app-token is published by GitHub under the `actions` +# org. Needs .github/scripts/release.py; `gh` is preinstalled. + name: release - + on: push: branches: [main] workflow_dispatch: - + permissions: {} - + concurrency: group: release cancel-in-progress: false - + jobs: # ── tag whatever version is currently committed ─────────────────────────── tag: name: Tag the released version runs-on: ubuntu-latest permissions: - contents: write + contents: read # the App token does the writing steps: - # persist-credentials stays on: this job pushes a tag. + # vars.* resolve to "" when undefined rather than failing, so a missing + # App ID surfaces as an opaque Octokit error ("appId option is + # required") instead of a workflow error. Check it where the message is + # useful. + - name: Check App credentials are present + env: + APP_ID: ${{ vars.RELEASE_APP_ID }} + PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + run: | + # App IDs are not secret, so printing this is safe and saves a round trip. + echo "app id seen by the workflow: '${APP_ID}'" + echo "private key length: ${#PRIVATE_KEY}" + + if [ -z "$APP_ID" ]; then + echo "::error::vars.RELEASE_APP_ID is empty. Check Settings → Secrets and variables → Actions → Variables (not the Secrets tab)." + exit 1 + fi + if [ -z "$PRIVATE_KEY" ]; then + echo "::error::secrets.RELEASE_APP_PRIVATE_KEY is empty." + exit 1 + fi + if [ "${#PRIVATE_KEY}" -lt 1000 ]; then + echo "::error::RELEASE_APP_PRIVATE_KEY looks truncated (${#PRIVATE_KEY} chars). Paste the whole .pem, including the BEGIN/END lines." + exit 1 + fi + + - uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + # Scope the minted token below whatever the App installation grants. + # This job only pushes a tag; it has no business opening PRs. + permission-contents: write + + # persist-credentials stays on: this job pushes a tag. The App token is + # what gets persisted, so the tag push is attributed to the App and DOES + # trigger image.yml's `push: tags: [v*]`. - uses: actions/checkout@v4 # zizmor: ignore[artipacked] with: fetch-depth: 0 - + token: ${{ steps.app-token.outputs.token }} + # Detection is by tag existence, not by commit message. The subject of a # merged release PR depends on whether you squash, rebase or merge, so # matching on "chore(release):" would work for two of the three and fail @@ -54,36 +114,70 @@ jobs: exit 1 fi tag="v$version" - + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then echo "$tag already exists, nothing to do" exit 0 fi - + git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git tag -a "$tag" -m "$tag" git push origin "$tag" echo "created $tag" >> "$GITHUB_STEP_SUMMARY" - + # ── open the next release PR ────────────────────────────────────────────── prepare: name: Open release PR needs: tag runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write + contents: read # the App token does the writing steps: + # Same guard as the tag job - see the comment there. + - name: Check App credentials are present + env: + APP_ID: ${{ vars.RELEASE_APP_ID }} + PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + run: | + echo "app id seen by the workflow: '${APP_ID}'" + echo "private key length: ${#PRIVATE_KEY}" + + if [ -z "$APP_ID" ]; then + echo "::error::vars.RELEASE_APP_ID is empty. Check Settings → Secrets and variables → Actions → Variables (not the Secrets tab)." + exit 1 + fi + if [ -z "$PRIVATE_KEY" ]; then + echo "::error::secrets.RELEASE_APP_PRIVATE_KEY is empty." + exit 1 + fi + if [ "${#PRIVATE_KEY}" -lt 1000 ]; then + echo "::error::RELEASE_APP_PRIVATE_KEY looks truncated (${#PRIVATE_KEY} chars). Paste the whole .pem, including the BEGIN/END lines." + exit 1 + fi + + - uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + # contents for the branch push, pull-requests for `gh pr create`. + # Nothing else the App may be installed with is exposed here. + permission-contents: write + permission-pull-requests: write + # persist-credentials stays on: this job pushes the release branch. - uses: actions/checkout@v4 # zizmor: ignore[artipacked] with: fetch-depth: 0 - + token: ${{ steps.app-token.outputs.token }} + - uses: actions/setup-python@v5 with: python-version: "3.12" - + + # release.py writes version/tag/files to $GITHUB_OUTPUT itself and exits + # 2 when there is nothing to release. - name: Work out the next version id: bump run: | @@ -97,47 +191,86 @@ jobs: fi [ "$rc" -eq 0 ] || exit "$rc" echo "release=true" >> "$GITHUB_OUTPUT" - + + # Fail loudly if release.py exited 0 without populating its outputs. + # Otherwise TAG is empty, the branch becomes a bare "release/", and the + # resulting mess looks like a permissions problem rather than a bug here. + - name: Check release.py produced its outputs + if: steps.bump.outputs.release == 'true' + env: + TAG: ${{ steps.bump.outputs.tag }} + VERSION: ${{ steps.bump.outputs.version }} + FILES: ${{ steps.bump.outputs.files }} + run: | + for v in TAG VERSION FILES; do + if [ -z "${!v}" ]; then + echo "::error::release.py exited 0 but did not set $v" + exit 1 + fi + done + - name: Push the branch and open the PR if: steps.bump.outputs.release == 'true' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} TAG: ${{ steps.bump.outputs.tag }} VERSION: ${{ steps.bump.outputs.version }} FILES: ${{ steps.bump.outputs.files }} run: | branch="release/$TAG" - + + # Check for an open PR BEFORE touching the remote. A re-run that is + # going to exit early should not force-push over a branch someone is + # mid-review on - that would dismiss their approval for nothing. + open=$(gh pr list --head "$branch" --state open --json number --jq 'length') + git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - + git checkout -b "$branch" + # FILES is a space-separated list; the split is intentional. + # shellcheck disable=SC2086 git add -- $FILES git commit -m "chore(release): $TAG" - + # Force-push so a re-run updates the existing branch rather than # failing. The branch only ever holds generated content. git push --force origin "$branch" - - if gh pr list --head "$branch" --state open --json number \ - --jq 'length' | grep -qv '^0$'; then - echo "a PR for $branch is already open, updated it in place" + + if [ "$open" -gt 0 ]; then + echo "a PR for $branch is already open, updated it in place" \ + >> "$GITHUB_STEP_SUMMARY" exit 0 fi - + + # Built before the gh call so the heredoc's column-zero terminator + # doesn't wreck the indentation of the command below. + # shellcheck disable=SC2086 + file_list=$(printf -- '- `%s`\n' $FILES) + + body=$(cat <> "$GITHUB_STEP_SUMMARY" + # To merge without a human, enable auto-merge on the repository and - # uncomment the next line. Checks still have to pass first. + # uncomment the next line. Checks still have to pass first - and if + # the ruleset requires an approving review, this will sit pending + # indefinitely, since an App cannot approve its own PR. # gh pr merge --auto --squash "$branch"