Skip to content

Commit 2372661

Browse files
committed
fix: single-tag releases - version tags live only on packaged commits
release-please now runs with skip-github-release and owns only the version math, changelog, manifest, and release PR. The repo-owned pipeline detects the release-PR merge (manifest version step + merge subject + PR provenance, refusing ambiguity), builds the bundle, commits it as a child of the merge commit, creates vX.Y.Z once on that child, attests, creates the draft release with the CHANGELOG section as notes, uploads bundle + schema + sigstore assets, force-moves the major with a compare-and-swap, and confirms origin's refs carry the bundle. The release PR itself carries the next last-release-sha boundary (anchored to its merge parent on every refresh), so merging a release also advances release-please's walk boundary - version tags are not on main, so its commit walk cannot find them by tag - with no push to main and no admin credential; a boundary-check job parks release-please loudly if the recorded boundary ever goes stale. The git topology lives in .github/scripts/release-pipeline.ts, unit-tested against fixture repositories (31 tests: fresh placement, byte-verified idempotent reruns, tamper stops, the PR-carried anchor, boundary freshness, no backward major moves), so tag placement is proven on every push instead of on release day. The release-tags ruleset drops the retired build/ namespace and gains a repository-admin bypass for deliberate repair; a new major-release-tags ruleset blocks major-tag deletion while leaving moves to the pipeline. Docs, tests, and comments sweep every build/ reference; @vX.Y.Z is the runnable exact pin now. publish-release is temporarily commented out so the first release under the scheme runs end to end but stays a draft for inspection; publish by hand and uncomment after verifying. No secret is required: without REPO_PLATFORM_TOKEN the release PR's checks need the usual close/reopen to run, nothing else. The next release is forced to 2.0.1 (release-as): the first 2.0.1 attempt was cut under the old two-tag scheme and withdrawn before it shipped, so the number is reused for its replacement. Remove release-as after 2.0.1 ships. The managed release-please workflow gains a guard failing loudly when a merged release PR is stuck at autorelease: pending, the state release-please otherwise swallows as a green abort.
1 parent 5af1429 commit 2372661

14 files changed

Lines changed: 1576 additions & 259 deletions

.github/scripts/release-pipeline.ts

Lines changed: 597 additions & 0 deletions
Large diffs are not rendered by default.

.github/settings.yml

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,23 +129,48 @@ rulesets:
129129
actor_type: RepositoryRole
130130
bypass_mode: always
131131

132-
# vX.Y.Z release tags are immutable: template-sync workflow pins and
133-
# release artifacts reference them. So are their build/vX.Y.Z packaging
134-
# tags, the runnable refs exact pins name; the prefix puts them outside
135-
# the v*.*.* pattern, and fnmatch's * does not cross "/", so no single
136-
# pattern covers both. The bare moving major tag (v1) is deliberately
137-
# NOT matched; the release workflow force-moves it to the latest build
138-
# commit.
132+
# vX.Y.Z release tags are immutable: each points at a packaged commit (a
133+
# child of its release commit carrying the built bundle) that template-
134+
# sync workflow pins and release artifacts reference. The release
135+
# workflow creates each tag once and never moves it (reruns verify the
136+
# existing tag instead); repository admins bypass for deliberate,
137+
# auditable repair. The bare moving major tag (v2) is deliberately NOT
138+
# matched here - the release workflow force-moves it to the newest
139+
# packaged commit in its line - but major-release-tags below still
140+
# blocks its deletion.
139141
- name: release-tags
140142
target: tag
141143
enforcement: active
142144
conditions:
143145
ref_name:
144146
include:
145147
- v*.*.*
146-
- build/v*.*.*
147148
exclude: []
148149
rules:
149150
- type: deletion
150151
- type: non_fast_forward
151152
- type: update
153+
bypass_actors:
154+
- actor_id: 5
155+
actor_type: RepositoryRole
156+
bypass_mode: always
157+
158+
# The moving major tags (v1, v2) move on purpose - every release in the
159+
# line force-pushes them to its packaged commit - so no update rule, but
160+
# they must never vanish: a deleted major bricks every @v2 consumer at
161+
# once. fnmatch's * crosses dots, so v* also covers the vX.Y.Z tags;
162+
# that overlap is a harmless union with release-tags above.
163+
- name: major-release-tags
164+
target: tag
165+
enforcement: active
166+
conditions:
167+
ref_name:
168+
include:
169+
- v*
170+
exclude: []
171+
rules:
172+
- type: deletion
173+
bypass_actors:
174+
- actor_id: 5
175+
actor_type: RepositoryRole
176+
bypass_mode: always
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# The release-please chain of the repo-owned pipeline (called by
2+
# release.yml on every push; see release.yml's header for the single-tag
3+
# scheme). Order: prove the recorded boundary is fresh, let release-please
4+
# build or refresh the release PR, then anchor the NEXT boundary into that
5+
# PR's branch - merging the release PR advances last-release-sha on main
6+
# as part of the release itself, so nothing ever pushes to main and no
7+
# admin credential is involved.
8+
9+
name: Release bookkeeping
10+
11+
on:
12+
workflow_call:
13+
14+
jobs:
15+
# last-release-sha must be the newest release merge on main or its
16+
# parent; anything else means a boundary was lost (or a release merge
17+
# slipped past detection) and every release PR refresh would be computed
18+
# from a stale one - park release-please loudly instead.
19+
boundary-check:
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 5
22+
permissions:
23+
contents: read
24+
steps:
25+
- uses: actions/checkout@v7
26+
with:
27+
# The newest release merge can be arbitrarily far behind HEAD.
28+
fetch-depth: 0
29+
- uses: oven-sh/setup-bun@v2
30+
with:
31+
bun-version-file: .bun-version
32+
- name: Confirm last-release-sha matches the newest release merge
33+
run: bun .github/scripts/release-pipeline.ts boundary-check
34+
35+
# Version math, changelog, manifest, and the release PR - never tags or
36+
# releases (skip-github-release); the managed workflow itself also gates
37+
# on no merged release PR being stuck at "autorelease: pending".
38+
release-please:
39+
needs: [boundary-check]
40+
uses: ./.github/workflows/release-please.yml
41+
secrets: inherit
42+
43+
# Write the next boundary INTO the release PR: last-release-sha becomes
44+
# main's current head - the future merge commit's parent, which is just
45+
# as good a boundary (the walk then also collects the merge itself, a
46+
# chore the changelog hides). The release-freshness gate keeps the value
47+
# exact: a release PR must contain main's tip to merge, and every
48+
# refresh re-anchors. The branch is unprotected, so the default token
49+
# pushes it; without REPO_PLATFORM_TOKEN the PR's checks need the usual
50+
# close/reopen to run either way.
51+
anchor-release-pr:
52+
needs: [release-please]
53+
runs-on: ubuntu-latest
54+
timeout-minutes: 5
55+
permissions:
56+
contents: write
57+
steps:
58+
- uses: actions/checkout@v7
59+
- uses: oven-sh/setup-bun@v2
60+
with:
61+
bun-version-file: .bun-version
62+
- name: Anchor the boundary inside the release PR branch
63+
run: bun .github/scripts/release-pipeline.ts anchor

.github/workflows/release-please.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,27 @@ jobs:
7777
tracking-labels: "fuzz-nightly,nightly-failure"
7878
env:
7979
GH_TOKEN: ${{ github.token }}
80+
# release-please silently ABORTS - green - when any merged release PR
81+
# still wears "autorelease: pending" ("untagged, merged release PRs
82+
# outstanding"): release PRs stop being proposed with no red anywhere.
83+
# Gate the action on that state being clean. A fresh merge is exempt
84+
# (its label is legitimately pending until the pipeline tags or
85+
# publishes it, which can happen in a parallel job), so only a
86+
# pending label older than 30 minutes fails.
87+
- name: Fail when a stale pending release PR parks release-please
88+
if: steps.head.outputs.current == 'true'
89+
env:
90+
GH_TOKEN: ${{ github.token }}
91+
GH_REPO: ${{ github.repository }}
92+
run: |
93+
cutoff="$(date -u -d '30 minutes ago' +%Y-%m-%dT%H:%M:%SZ)"
94+
stale="$(gh pr list --state merged --label 'autorelease: pending' \
95+
--json number,mergedAt \
96+
--jq "[.[] | select(.mergedAt < \"$cutoff\") | .number] | join(\", \")")"
97+
if [ -n "$stale" ]; then
98+
echo "::error::merged release PR(s) #$stale have worn 'autorelease: pending' for over 30 minutes, so release-please refuses to propose any new release ('untagged, merged release PRs outstanding'). Finish or abandon that release, then move the label to 'autorelease: tagged'."
99+
exit 1
100+
fi
80101
- uses: googleapis/release-please-action@v5
81102
id: release
82103
if: steps.head.outputs.current == 'true'
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# The release chain of the repo-owned pipeline (called by release.yml with
2+
# the tag/version its detection proved; see release.yml's header for the
3+
# single-tag scheme). Packages the merge commit, creates the version tag on
4+
# the packaged child, attests, releases draft-first, moves the major, and
5+
# publishes last.
6+
7+
name: Release publish
8+
9+
on:
10+
workflow_call:
11+
inputs:
12+
tag:
13+
description: The vX.Y.Z tag to create for this release
14+
required: true
15+
type: string
16+
version:
17+
description: The X.Y.Z version (names the CHANGELOG.md section)
18+
required: true
19+
type: string
20+
21+
jobs:
22+
package-release:
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 15
25+
permissions:
26+
contents: write # tag pushes; also what makes the draft visible to gh
27+
id-token: write # OIDC identity for the provenance attestation
28+
attestations: write
29+
# The release-health gate reads open issues, the release PR's
30+
# override label, and Dependabot alerts; a permissions block zeroes
31+
# everything it omits, so the gate's read scopes must be spelled out.
32+
issues: read
33+
pull-requests: read
34+
vulnerability-alerts: read
35+
steps:
36+
- uses: actions/checkout@v7
37+
# Authoritative release gating, the same action the managed
38+
# release-please.yml runs: the release PR's own release-health check
39+
# can be stale by merge time (a tracking or blocker issue opened
40+
# after it went green re-runs no check), so re-check here before
41+
# anything is tagged.
42+
- uses: Vivswan/repo-platform/actions/release-health@main
43+
with:
44+
mode: release
45+
tracking-labels: "fuzz-nightly,nightly-failure"
46+
env:
47+
GH_TOKEN: ${{ github.token }}
48+
- uses: oven-sh/setup-bun@v2
49+
with:
50+
bun-version-file: .bun-version
51+
- name: Build the bundle and the schema
52+
run: |
53+
bun install --frozen-lockfile --ignore-scripts
54+
bun run build
55+
# The uploaded schema asset and the raw URLs at the version and
56+
# major tags must serve the bytes committed at the merge commit;
57+
# a divergent regeneration (generator drift, or a compromised
58+
# generator - SECURITY.md leans on this) stops the release here.
59+
git diff --exit-code lib/settings.schema.json
60+
# Create the packaged child and its version tag exactly once; a rerun
61+
# byte-verifies the existing tag instead (parent, whole tree, bundle
62+
# bytes) and never moves it. Both paths end with the worktree bundle
63+
# equal to the tagged bundle, which is what the attestation signs.
64+
- name: Create or verify the packaged commit and its version tag
65+
env:
66+
TAG: ${{ inputs.tag }}
67+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
68+
run: bun .github/scripts/release-pipeline.ts package
69+
- name: Attest build provenance for the bundle
70+
id: attest
71+
uses: actions/attest-build-provenance@v4
72+
with:
73+
subject-path: lib/index.js
74+
# gh names assets after the file, so the attestation bundle is copied
75+
# to a stable, self-describing name for offline verification:
76+
# gh attestation verify lib/index.js --bundle <asset> -R <owner>/<repo>
77+
- name: Create the draft release and upload the packaged artifacts
78+
env:
79+
GH_TOKEN: ${{ github.token }}
80+
TAG: ${{ inputs.tag }}
81+
VERSION: ${{ inputs.version }}
82+
ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }}
83+
run: |
84+
# A missing release and a transport error both leave state empty;
85+
# on a transport error the create below then fails loudly with
86+
# "already exists" instead of silently skipping the upload.
87+
state="$(gh release view "$TAG" --json isDraft --jq .isDraft 2>/dev/null || true)"
88+
if [ -z "$state" ]; then
89+
bun .github/scripts/release-pipeline.ts notes "$VERSION" > "$RUNNER_TEMP/notes.md"
90+
gh release create "$TAG" --draft --verify-tag --title "$TAG" \
91+
--notes-file "$RUNNER_TEMP/notes.md"
92+
elif [ "$state" = "false" ]; then
93+
echo "::notice::release $TAG is already published; skipping the asset upload (published releases freeze their assets)"
94+
exit 0
95+
fi
96+
cp "$ATTESTATION_BUNDLE" "$RUNNER_TEMP/lib-index-js.sigstore.json"
97+
gh release upload "$TAG" lib/settings.schema.json lib/index.js \
98+
"$RUNNER_TEMP/lib-index-js.sigstore.json" --clobber
99+
# Move the moving major tag (v2 for a v2.x.y release) to the packaged
100+
# commit, re-verified against origin from scratch: major-pinned
101+
# consumers must never receive an unpackaged commit, a package of the
102+
# wrong source, or (on a rerun of an old release's job) a step
103+
# backward to an older release.
104+
- name: Move the major tag to the packaged commit
105+
env:
106+
TAG: ${{ inputs.tag }}
107+
run: bun .github/scripts/release-pipeline.ts retag-major
108+
109+
# The gate between packaging and publishing, against origin's ACTUAL
110+
# state rather than anything the packaging run held locally: the version
111+
# tag and the major must both point at the packaged child of this merge
112+
# commit, and that commit's tree must carry a non-empty lib/index.js.
113+
verify-release:
114+
needs: [package-release]
115+
runs-on: ubuntu-latest
116+
timeout-minutes: 5
117+
permissions:
118+
contents: read
119+
steps:
120+
- uses: actions/checkout@v7
121+
- uses: oven-sh/setup-bun@v2
122+
with:
123+
bun-version-file: .bun-version
124+
- name: Confirm the published refs carry the bundle
125+
env:
126+
TAG: ${{ inputs.tag }}
127+
run: bun .github/scripts/release-pipeline.ts verify
128+
129+
# Publish last: published releases freeze their assets, so every job that
130+
# mutates the release must sit in this needs list. Also flips the release
131+
# PR's autorelease label, which release-please no longer flips itself
132+
# under skip-github-release.
133+
#
134+
# COMMENTED OUT for the first single-tag release: the pipeline runs end
135+
# to end (tag, attest, draft, assets, major) but leaves the release a
136+
# DRAFT for inspection. After verifying, publish by hand
137+
# (gh release edit <tag> --draft=false) and uncomment this job.
138+
# publish-release:
139+
# needs: [package-release, verify-release]
140+
# runs-on: ubuntu-latest
141+
# timeout-minutes: 5
142+
# permissions:
143+
# contents: write
144+
# pull-requests: write
145+
# steps:
146+
# - name: Publish the GitHub release
147+
# env:
148+
# GH_TOKEN: ${{ secrets.REPO_PLATFORM_TOKEN || github.token }}
149+
# GH_REPO: ${{ github.repository }}
150+
# TAG: ${{ inputs.tag }}
151+
# run: gh release edit "$TAG" --draft=false
152+
# - name: Mark the release PR tagged
153+
# env:
154+
# GH_TOKEN: ${{ github.token }}
155+
# GH_REPO: ${{ github.repository }}
156+
# run: |
157+
# number="$(gh api "repos/$GH_REPO/commits/$GITHUB_SHA/pulls" --jq '.[0].number // empty')"
158+
# if [ -z "$number" ]; then
159+
# echo "::notice::no pull request found for $GITHUB_SHA; leaving the autorelease labels alone"
160+
# exit 0
161+
# fi
162+
# gh api -X DELETE "repos/$GH_REPO/issues/$number/labels/autorelease%3A%20pending" || true
163+
# gh api "repos/$GH_REPO/issues/$number/labels" -f "labels[]=autorelease: tagged"

0 commit comments

Comments
 (0)