From 0713672105165e850969f7e5b01da83b4337d3cd Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Mon, 20 Jul 2026 12:28:26 -0700 Subject: [PATCH 1/2] create release tags with dedicated app --- .../auto-tag-on-release-pr-merge.yml | 103 ++++++------------ .github/workflows/docker.yml | 14 +-- .github/workflows/helm-chart.yml | 15 +-- RELEASING.md | 15 ++- scripts/test-release-ref-contract.sh | 13 ++- 5 files changed, 67 insertions(+), 93 deletions(-) diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index b2e1e2f846..37833c06ad 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -4,22 +4,18 @@ name: Auto-tag on Release PR Merge # prefix; the main chart lane also auto-detects a Chart.yaml version bump so # a chart feature PR can publish its own new version when merged: # -# version-bump/ → tag v → dispatch release.yml (desktop app) -# relay-release/ → tag relay-v → dispatch docker.yml (relay image) -# chart-release/ → tag chart-v → dispatch helm-chart.yml (main helm chart) -# push-chart-release/ → tag push-chart-v → dispatch push-gateway-helm-chart.yml +# version-bump/ → tag v → release.yml (desktop app) +# relay-release/ → tag relay-v → docker.yml (relay image) +# chart-release/ → tag chart-v → helm-chart.yml (main helm chart) +# push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml # any internal PR that bumps deploy/charts/buzz/Chart.yaml `version` -# → tag chart-v → dispatch helm-chart.yml (helm chart) +# → tag chart-v → helm-chart.yml (helm chart) # mobile-release/ → tag mobile-v → (manual sprout_ref for buzz-releases build — see below) # -# The desktop, relay, and chart lanes dispatch their build workflow rather than -# relying on the consumer's `on.push.tags` trigger: auto-tag pushes the tag -# with the default GITHUB_TOKEN, which GitHub's recursion guard blocks from -# firing any `on: push` trigger. So release.yml, docker.yml, and helm-chart.yml -# all have a `push.tags` trigger that is dead for auto-pushed tags — the -# dispatch is the real path. Each dispatch passes the bare version and the tag -# ref so the consumer builds the tagged commit (github.ref on a dispatch is -# `main`). +# Release tags are created with a short-lived token from the dedicated +# buzz-release-tagger GitHub App. GitHub attributes the ref creation to that +# App, so the consumer's `on.push.tags` trigger runs normally. The workflow's +# default GITHUB_TOKEN remains read-only and is never used to create a tag. # # The mobile lane is push-only by infosec necessity: OSS `block/buzz` CI must # not trigger CI in the private `buzz-releases` repo, so auto-dispatch across @@ -33,8 +29,7 @@ on: branches: [main] permissions: - contents: write - actions: write + contents: read jobs: auto-tag: @@ -47,6 +42,7 @@ jobs: with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 + persist-credentials: false - name: Resolve release lane and version id: release @@ -59,24 +55,19 @@ jobs: case "$BRANCH" in version-bump/*) VERSION="${BRANCH#version-bump/}" - TAG_PREFIX="v" - DISPATCH="release" ;; + TAG_PREFIX="v" ;; relay-release/*) VERSION="${BRANCH#relay-release/}" - TAG_PREFIX="relay-v" - DISPATCH="docker" ;; + TAG_PREFIX="relay-v" ;; chart-release/*) VERSION="${BRANCH#chart-release/}" - TAG_PREFIX="chart-v" - DISPATCH="helm-chart" ;; + TAG_PREFIX="chart-v" ;; push-chart-release/*) VERSION="${BRANCH#push-chart-release/}" - TAG_PREFIX="push-chart-v" - DISPATCH="push-gateway-helm-chart" ;; + TAG_PREFIX="push-chart-v" ;; mobile-release/*) VERSION="${BRANCH#mobile-release/}" - TAG_PREFIX="mobile-v" - DISPATCH="" ;; + TAG_PREFIX="mobile-v" ;; *) parent_sha="$(git rev-parse HEAD^)" old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')" @@ -86,8 +77,7 @@ jobs: echo "enabled=false" >> "$GITHUB_OUTPUT" exit 0 fi - TAG_PREFIX="chart-v" - DISPATCH="helm-chart" ;; + TAG_PREFIX="chart-v" ;; esac if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then echo "::error::Invalid release version: '$VERSION'" @@ -95,19 +85,29 @@ jobs: fi { echo "enabled=true" - echo "version=$VERSION" echo "tag=${TAG_PREFIX}${VERSION}" - echo "dispatch=$DISPATCH" } >> "$GITHUB_OUTPUT" echo "Tagging ${TAG_PREFIX}${VERSION}" + - name: Create release tagger token + if: steps.release.outputs.enabled == 'true' + id: release-tagger + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }} + private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }} + permission-contents: write + - name: Create and push tag if: steps.release.outputs.enabled == 'true' env: + GH_TOKEN: ${{ steps.release-tagger.outputs.token }} TAG: ${{ steps.release.outputs.tag }} run: | - EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')" - if [ -n "$EXISTING_SHA" ]; then + set -euo pipefail + EXISTING_REF="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .ref 2>/dev/null || true)" + if [ -n "$EXISTING_REF" ]; then + EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" exit 0 @@ -116,40 +116,7 @@ jobs: exit 1 fi fi - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config user.name "github-actions[bot]" - git tag "$TAG" - git push origin "$TAG" - - - name: Trigger release build - # The desktop, relay, and chart lanes dispatch their build workflow - # because the consumer's on:push:tags trigger is dead for tags pushed - # by GITHUB_TOKEN (recursion guard — see header comment). Mobile has no - # consumer yet, so dispatch="" skips this step entirely. - if: steps.release.outputs.enabled == 'true' && steps.release.outputs.dispatch != '' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DISPATCH: ${{ steps.release.outputs.dispatch }} - VERSION: ${{ steps.release.outputs.version }} - TAG: ${{ steps.release.outputs.tag }} - run: | - # Run the workflow itself at the immutable tag so github.sha, OIDC, - # provenance, and the checked-out source all name the release commit. - extra_args=() - case "$DISPATCH" in - release) WORKFLOW="release.yml" ;; - docker) WORKFLOW="docker.yml" ;; - helm-chart) - WORKFLOW="helm-chart.yml" - extra_args=(-f "ref=$TAG") ;; - push-gateway-helm-chart) - WORKFLOW="push-gateway-helm-chart.yml" - extra_args=(-f "ref=$TAG") ;; - *) - echo "::error::Unhandled dispatch target: '$DISPATCH'" - exit 1 ;; - esac - gh workflow run "$WORKFLOW" \ - --ref "$TAG" \ - -f version="$VERSION" \ - "${extra_args[@]}" + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$TAG" \ + -f sha="$GITHUB_SHA" \ + --silent diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c1b4d5711e..31080652ea 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,15 +18,13 @@ name: Docker image # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} # (+ :latest for stable, NOT for prereleases) # - pull_request → build only (no push), cache stays warm -# - workflow_dispatch → relay-tag rescue, dispatched at the tag itself +# - workflow_dispatch → manual relay-tag rescue at the tag itself # # Why workflow_dispatch carries a version input: -# auto-tag-on-release-pr-merge.yml pushes relay-v* with the default -# GITHUB_TOKEN, which GitHub deliberately does NOT let fire on:push triggers -# (recursion guard). So the push:tags trigger above never runs for releases. -# auto-tag instead dispatches this workflow at the immutable tag and supplies -# the bare version for metadata-action. The workflow rejects a dispatch whose -# github.ref, checked-out HEAD, and relay-v tag do not resolve to one commit. +# Normal releases arrive through the push:tags trigger above. The input is +# retained only for an operator to rerun publication manually at an immutable +# relay tag. The workflow rejects a dispatch whose github.ref, checked-out +# HEAD, and relay-v tag do not resolve to one commit. # On the rescue path inputs.version is already bare (e.g. 0.3.0), so the # match=^relay-v(.*)$ regex simply no-ops (it warns, leaving the value # intact) and the bare version flows straight to the semver parser. On a @@ -146,7 +144,7 @@ jobs: # own — it only strips refs/tags/, then runs the raw ref through # semver.valid(), which rejects "relay-v0.3.0". The match capture # group feeds the bare version to the semver parser. value= supplies - # the version on the auto-tag rescue dispatch (github.ref is `main` + # the version on a manual rescue dispatch (github.ref is `main` # there, not the tag): it is already bare, so match no-ops (warns, # value intact) and the bare version validates as-is. On push value= # is empty, so the ref drives it and match strips relay-v — push diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index 0fa156f6c7..e3d443d9f3 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -9,14 +9,11 @@ name: helm chart # `chart-v*` tags publish — `main` pushes and PRs stay lint/render-only so we # never overwrite a released chart version from an in-progress `main`. # -# Why workflow_dispatch carries version/ref inputs (same reason as docker.yml): -# auto-tag-on-release-pr-merge.yml pushes `chart-v*` with the default -# GITHUB_TOKEN, which GitHub's recursion guard blocks from firing any -# `on: push` trigger. So the `push.tags` trigger below is dead for -# auto-pushed tags — auto-tag instead dispatches this workflow with the bare -# version + tag ref, and the publish job checks out inputs.ref and packages at -# inputs.version. On a real manual `git push` of a `chart-v*` tag the -# push.tags trigger fires and inputs are empty. +# Why workflow_dispatch carries version/ref inputs: +# Normal releases arrive through the `push.tags` trigger below. The inputs are +# retained only for an operator to rerun publication manually at an immutable +# chart tag; the publish job checks out inputs.ref and packages at +# inputs.version. on: workflow_dispatch: @@ -153,7 +150,7 @@ jobs: publish: # Packages the chart and pushes it to GHCR as an OCI artifact. Fires only on - # a `chart-v*` tag push (manual) or the auto-tag rescue dispatch (which + # a `chart-v*` tag push or a manual rescue dispatch (which # carries inputs.version) — never on `main` pushes or PRs, so an in-progress # `main` can never overwrite a released chart version. Mirrors docker.yml's # GHCR publish (login with GITHUB_TOKEN, packages: write, fork override var). diff --git a/RELEASING.md b/RELEASING.md index 98d5dd9c80..c3450254cf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -59,6 +59,11 @@ with any new commits, and updates the PR in place. All three lanes share one engine; they differ only in which version manifest they bump, which branch prefix they use, and what the merge triggers. +The merge workflow creates tags with a short-lived installation token from the +dedicated `buzz-release-tagger` GitHub App. Release-tag rules allow that App to +create matching tags and prevent other actors from creating, moving, or +deleting them. The workflow's default `GITHUB_TOKEN` is read-only. + ### Desktop 1. **`just release-desktop`** runs locally on `main` — computes the next @@ -79,15 +84,13 @@ they bump, which branch prefix they use, and what the merge triggers. opens (or updates) a PR. 2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the `relay-release/*` branch merge and pushes a `relay-v` tag. -3. **Auto-tag dispatches `docker.yml`** — the same workflow then triggers - `docker.yml` with the version and tag ref, which builds the multi-arch relay +3. **Tag triggers `docker.yml`** — the `relay-v` push triggers + `docker.yml`, which builds the multi-arch relay image and publishes `ghcr.io/block/buzz:` (plus `:.`, `:`, and `:latest` for stable releases). Prereleases (`relay-v-rc.1`) publish only the prerelease tag and do **not** - move `:latest`. (The dispatch — rather than relying on `docker.yml`'s - `push: tags` trigger — is required because GitHub suppresses `on: push` for - tags pushed by the workflow's own `GITHUB_TOKEN`; the desktop lane dispatches - `release.yml` for the same reason.) + move `:latest`. GitHub runs the tag trigger because the tag is created by + the dedicated GitHub App rather than the workflow's `GITHUB_TOKEN`. Every push to `main` continues to build and publish `:main` + `:sha-<7>` tags (the rolling development image). The `:latest` tag tracks the latest **stable** diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 4569e208c5..672767f589 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -53,7 +53,16 @@ fi grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/release.yml" grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/docker.yml" grep -q 'test-release-ref-contract\.sh' "$repo_root/.github/workflows/ci.yml" -grep -Fq -- "--ref \"\$TAG\"" \ - "$repo_root/.github/workflows/auto-tag-on-release-pr-merge.yml" +auto_tag="$repo_root/.github/workflows/auto-tag-on-release-pr-merge.yml" +grep -q 'actions/create-github-app-token@' "$auto_tag" +grep -q 'client-id:.*vars\.BUZZ_RELEASE_TAGGER_CLIENT_ID' "$auto_tag" +grep -q 'private-key:.*secrets\.BUZZ_RELEASE_TAGGER_PRIVATE_KEY' "$auto_tag" +grep -q 'permission-contents: write' "$auto_tag" +grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag" +grep -Fq 'git/refs' "$auto_tag" +if grep -q 'gh workflow run' "$auto_tag"; then + echo "auto-tag still dispatches a publisher instead of using the tag push" >&2 + exit 1 +fi echo "release ref contract passed" From 9580edc54da37ea07e9fd7f7cc3b3728eff97428 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Mon, 20 Jul 2026 14:46:13 -0700 Subject: [PATCH 2/2] use release App name --- .github/workflows/auto-tag-on-release-pr-merge.yml | 2 +- RELEASING.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 37833c06ad..9472b0a222 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -13,7 +13,7 @@ name: Auto-tag on Release PR Merge # mobile-release/ → tag mobile-v → (manual sprout_ref for buzz-releases build — see below) # # Release tags are created with a short-lived token from the dedicated -# buzz-release-tagger GitHub App. GitHub attributes the ref creation to that +# buzz-release-bot GitHub App. GitHub attributes the ref creation to that # App, so the consumer's `on.push.tags` trigger runs normally. The workflow's # default GITHUB_TOKEN remains read-only and is never used to create a tag. # diff --git a/RELEASING.md b/RELEASING.md index c3450254cf..890f0f5fff 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -60,7 +60,7 @@ All three lanes share one engine; they differ only in which version manifest they bump, which branch prefix they use, and what the merge triggers. The merge workflow creates tags with a short-lived installation token from the -dedicated `buzz-release-tagger` GitHub App. Release-tag rules allow that App to +dedicated `buzz-release-bot` GitHub App. Release-tag rules allow that App to create matching tags and prevent other actors from creating, moving, or deleting them. The workflow's default `GITHUB_TOKEN` is read-only.