From 5c011fc7546cb27d376e01c9047e86b84a111f86 Mon Sep 17 00:00:00 2001 From: Marko Petzold Date: Sat, 24 Oct 2026 23:58:08 +0200 Subject: [PATCH] release-tag: promote-branch fast-forwards a branch to the release For a hosting service that deploys from a branch: after the tag, the tag job moves that branch to the released commit with the release App's token, so only release merges reach it. A missing branch is created, a re-run passes, and a commit that does not descend from the branch fails; never a force push. --- .github/workflows/release-tag.yml | 15 +++++++++++++++ README.md | 4 ++++ scripts/release.sh | 28 ++++++++++++++++++++++++++++ scripts/test_release.sh | 30 ++++++++++++++++++++++++++++-- 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 1369612..27221e3 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -28,6 +28,13 @@ on: release-branch: type: string default: main + promote-branch: + description: >- + Branch to fast-forward to the released commit after the tag (e.g. the + branch a hosting service deploys from); empty = none. Created if + missing; never force-pushed. + type: string + default: "" harness-ref: description: Ref of IronFlock/workflows the harness scripts are taken from. type: string @@ -102,3 +109,11 @@ jobs: run: | MESSAGE=$(head -n1 <<<"$MESSAGE") MESSAGE=${MESSAGE% (#*)} "$HARNESS/scripts/release.sh" tag + - name: fast-forward ${{ inputs.promote-branch }} + if: steps.version.outputs.mode == 'release' && inputs.promote-branch != '' + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + REPO: ${{ github.repository }} + BRANCH: ${{ inputs.promote-branch }} + SHA: ${{ github.sha }} + run: '"$HARNESS/scripts/release.sh" promote' diff --git a/README.md b/README.md index 4f545ba..307c608 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,10 @@ deployments from `main` only, variable `RELEASE_APP_CLIENT_ID`, secret `RELEASE_APP_PRIVATE_KEY`; the token covers the contents of that one repository. Callers grant `contents: read` only and pass `secrets: inherit`: environment secrets reach a reusable workflow only that way. +With `promote-branch` set, the tag job then fast-forwards that branch to the +released commit (never a force push), for a hosting service that deploys from +a branch: only release merges reach it. Protect that branch with a ruleset +whose only bypass actor is the release App. ## Pull requests from a laptop diff --git a/scripts/release.sh b/scripts/release.sh index 8904379..6685a36 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -32,6 +32,13 @@ # set the annotated tag of a release through the GitHub API. Env: TAG, # SHA, MESSAGE, REPO, GH_TOKEN. A tag already on SHA passes (re-run); a # tag on another commit fails. +# +# release.sh promote +# fast-forward branch BRANCH to the released commit through the GitHub +# API (e.g. the branch a hosting service deploys from). Env: BRANCH, SHA, +# REPO, GH_TOKEN. A missing branch is created; a branch already on SHA +# passes (re-run); a branch that SHA does not descend from fails (never a +# force push). set -euo pipefail out() { @@ -157,10 +164,31 @@ tag() { echo "tagged $TAG on $SHA" } +promote() { + local current status + if ! current=$(gh api "repos/$REPO/git/ref/heads/$BRANCH" --jq .object.sha 2>/dev/null); then + gh api "repos/$REPO/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$SHA" >/dev/null + echo "created $BRANCH on $SHA" + return 0 + fi + if [ "$current" = "$SHA" ]; then + echo "$BRANCH is already on $SHA" + return 0 + fi + status=$(gh api "repos/$REPO/compare/$current...$SHA" --jq .status) + if [ "$status" != ahead ]; then + echo "::error::$BRANCH is on $current, which $SHA does not descend from ($status): not a fast-forward" + return 1 + fi + gh api "repos/$REPO/git/refs/heads/$BRANCH" -X PATCH -f sha="$SHA" -F force=false >/dev/null + echo "moved $BRANCH from $current to $SHA" +} + case "${1:-}" in version) version "$2" "${3:-}" ;; intent) intent ;; mode) mode ;; tag) tag ;; + promote) promote ;; *) echo "usage: release.sh version [] | intent | mode | tag" >&2; exit 2 ;; esac diff --git a/scripts/test_release.sh b/scripts/test_release.sh index 2347e5b..427a0c6 100755 --- a/scripts/test_release.sh +++ b/scripts/test_release.sh @@ -154,19 +154,28 @@ while [ $# -gt 0 ]; do ref=*) ref=${2#*=} ;; esac shift 2 ;; - --jq) shift 2 ;; + --jq | -X | -F) shift 2 ;; *) shift ;; esac done case "$path" in */git/ref/tags/*) cat "$state/ref-${path##*/}" 2>/dev/null || exit 1 ;; + */git/ref/heads/*) cat "$state/head-${path##*/}" 2>/dev/null || exit 1 ;; + */git/refs/heads/*) echo "$sha" > "$state/head-${path##*/}" ;; + */compare/*) + range=${path##*/} + if grep -qx "${range%...*} ${range#*...}" "$state/ancestors" 2>/dev/null; then echo ahead; else echo diverged; fi ;; */git/tags/*) cat "$state/obj-${path##*/}" ;; */git/tags) obj=t$RANDOM echo "$object" > "$state/obj-$obj" echo "$message" > "$state/msg-$obj" echo "$obj" ;; - */git/refs) echo "tag $sha" > "$state/ref-${ref##*/}" ;; + */git/refs) + case "$ref" in + refs/heads/*) echo "$sha" > "$state/head-${ref##*/}" ;; + *) echo "tag $sha" > "$state/ref-${ref##*/}" ;; + esac ;; *) echo "fake gh: unexpected $path" >&2; exit 2 ;; esac FAKE @@ -184,5 +193,22 @@ check "tag: the same tag on another commit fails" 1 tag v3.0.0 bbb echo "commit ccc" > "$work/gh/ref-v3.0.1" check "tag: a lightweight tag on the same commit passes" 0 tag v3.0.1 ccc +promote() { # promote + PATH="$work/bin:$PATH" FAKE_GH_STATE="$work/gh" REPO=o/r BRANCH=production SHA=$1 \ + GH_TOKEN=unused "$script" promote +} +head_is() { # head_is + if [ "$(cat "$work/gh/head-production" 2>/dev/null)" = "$2" ]; then echo "ok $1"; else + echo "FAIL $1: production is on '$(cat "$work/gh/head-production" 2>/dev/null)', expected $2"; failures=$((failures + 1)); fi +} +check "promote: a missing branch is created" 0 promote aaa +head_is "promote: created on the release commit" aaa +check "promote: a re-run on the same commit passes" 0 promote aaa +echo "aaa bbb" > "$work/gh/ancestors" +check "promote: a fast-forward moves the branch" 0 promote bbb +head_is "promote: moved to the new release" bbb +check "promote: a commit that does not descend fails" 1 promote zzz +head_is "promote: not moved after a refusal" bbb + [ "$failures" = 0 ] || { echo "$failures failure(s)"; exit 1; } echo "all release.sh tests passed"