Skip to content

Commit d619196

Browse files
authored
fix: pin release publishing to merge commit (#2959)
1 parent 2339757 commit d619196

7 files changed

Lines changed: 292 additions & 95 deletions

File tree

.github/workflows/publish.yml

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,12 @@ permissions: {}
55
on:
66
push:
77
tags:
8-
- "v*"
8+
# Stable tags are created only by a merged, reviewed release PR. Direct
9+
# tag pushes are reserved for prerelease channels.
10+
- "v*-*"
911
pull_request:
1012
types: [closed]
1113
branches: [main]
12-
workflow_dispatch:
13-
inputs:
14-
version:
15-
description: "Version to publish (e.g. 0.4.11). Tag v<version> must already exist."
16-
required: true
17-
type: string
1814

1915
jobs:
2016
publish:
@@ -25,35 +21,43 @@ jobs:
2521
permissions:
2622
contents: write
2723
id-token: write
28-
# Run on tag push, manual dispatch, OR when a release/* PR is merged
24+
env:
25+
EXPECTED_RELEASE_SHA: >-
26+
${{ github.event_name == 'pull_request'
27+
&& github.event.pull_request.merge_commit_sha
28+
|| github.sha }}
29+
# Stable releases come only from reviewed release PRs. Tag pushes are
30+
# prerelease-only because the trigger pattern requires a hyphenated version.
2931
if: >-
3032
github.event_name == 'push' ||
31-
github.event_name == 'workflow_dispatch' ||
3233
(github.event.pull_request.merged == true &&
3334
startsWith(github.event.pull_request.head.ref, 'release/v'))
3435
steps:
3536
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
3637
with:
3738
fetch-depth: 0
38-
# On manual dispatch, check out the existing tag so we publish the
39-
# exact commit that was tagged — not whatever is currently on main.
40-
ref: >-
41-
${{ github.event_name == 'workflow_dispatch'
42-
&& format('refs/tags/v{0}', inputs.version)
43-
|| github.ref }}
39+
# Pin every release to an immutable ref. In particular, a merged
40+
# release PR event exposes main as github.ref, which may advance
41+
# before this job starts; use the event's exact merge commit instead.
42+
ref: ${{ env.EXPECTED_RELEASE_SHA }}
43+
44+
- name: Verify immutable release checkout
45+
run: |
46+
ACTUAL_SHA="$(git rev-parse HEAD)"
47+
EXPECTED_COMMIT_SHA="$(git rev-parse "${EXPECTED_RELEASE_SHA}^{commit}")"
48+
if [ "$ACTUAL_SHA" != "$EXPECTED_COMMIT_SHA" ]; then
49+
echo "::error::Expected release commit $EXPECTED_COMMIT_SHA, checked out $ACTUAL_SHA"
50+
exit 1
51+
fi
4452
4553
- name: Resolve version
4654
id: version
4755
env:
4856
EVENT_NAME: ${{ github.event_name }}
49-
INPUT_VERSION: ${{ inputs.version }}
5057
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
5158
run: |
5259
if [ "$EVENT_NAME" = "push" ]; then
5360
VERSION="${GITHUB_REF_NAME#v}"
54-
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
55-
VERSION="${INPUT_VERSION}"
56-
VERSION="${VERSION#v}"
5761
else
5862
BRANCH="${PR_HEAD_REF}"
5963
VERSION="${BRANCH#release/v}"
@@ -84,8 +88,29 @@ jobs:
8488
env:
8589
VERSION: ${{ steps.version.outputs.version }}
8690
run: |
87-
git tag "v$VERSION"
88-
git push origin "v$VERSION"
91+
TAG="v$VERSION"
92+
EXPECTED_TAG_SHA="$(git rev-parse HEAD)"
93+
94+
verify_remote_tag() {
95+
git fetch --force --no-tags origin "+refs/tags/$TAG:refs/tags/$TAG"
96+
ACTUAL_TAG_SHA="$(git rev-parse "refs/tags/$TAG^{commit}")"
97+
if [ "$ACTUAL_TAG_SHA" != "$EXPECTED_TAG_SHA" ]; then
98+
echo "::error::Release tag $TAG points to $ACTUAL_TAG_SHA, expected $EXPECTED_TAG_SHA"
99+
exit 1
100+
fi
101+
echo "Release tag $TAG already exists at the expected commit — skipping"
102+
}
103+
104+
if [ -n "$(git ls-remote --refs origin "refs/tags/$TAG")" ]; then
105+
verify_remote_tag
106+
else
107+
git tag --no-sign "$TAG" "$EXPECTED_TAG_SHA"
108+
if ! git push origin "refs/tags/$TAG"; then
109+
# A concurrent retry may have created the tag after ls-remote.
110+
git tag -d "$TAG"
111+
verify_remote_tag
112+
fi
113+
fi
89114
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
90115
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
91116
with:

docs/contributing/changelog-process.mdx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,11 @@ The docs changelog lives in `docs/changelog.mdx` and uses Mintlify `<Update>` en
5757
The release commit can include the version bump, `releases/v0.6.53.md`, and the docs changelog update.
5858
</Step>
5959
<Step title="Publish">
60-
Push the release tag:
61-
```bash
62-
git push origin main --tags
63-
```
64-
The publish workflow uses `releases/v0.6.53.md` as the GitHub Release body when the file exists. If no reviewed release file is present, it falls back to GitHub-generated notes.
60+
Push the `release/v0.6.53` branch without its local tag, open a PR to `main`, and merge it after approval and CI. The publish workflow pins its checkout to the exact merge SHA, verifies that SHA, creates `v0.6.53`, and uses `releases/v0.6.53.md` as the GitHub Release body. If no reviewed release file is present, it falls back to GitHub-generated notes.
61+
62+
To recover a failed publish, rerun the original merged-PR workflow. Do not push the stable tag or use a manual dispatch; those paths are intentionally disabled so recovery cannot publish a different commit.
6563

66-
The generated compare link points to the future `v0.6.53` tag. It may not resolve between the PR merge and the final tag push.
64+
The generated compare link points to the future `v0.6.53` tag. It may not resolve until the release PR merges and the publish workflow creates the tag.
6765
</Step>
6866
</Steps>
6967

docs/contributing/release-channels.mdx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ If a feature should ship in alpha only, merge or retarget that PR to a prereleas
2121

2222
## Stable release
2323

24-
Stable releases must be reachable from `origin/main` or `origin/release/v*`.
24+
Stable releases publish only when a reviewed `release/vX.Y.Z` PR merges into protected `main`.
2525
Prepare and review release notes before creating the release commit:
2626

2727
```bash
@@ -32,10 +32,9 @@ On the first run, `release:prepare` drafts missing changelog artifacts and exits
3232

3333
See [Changelog process](/contributing/changelog-process) for the full workflow. For stable releases, `bun run set-version <version>` still enforces this checkpoint when maintainers run the lower-level release command directly.
3434

35-
```bash
36-
bun run release:prepare <version>
37-
git push origin main --tags
38-
```
35+
Push the release branch without its local tag, open a PR to `main`, and merge it after approval and CI. The publish workflow checks out the exact PR merge SHA, verifies it before publishing, creates the stable tag at that commit, publishes npm packages, and creates the GitHub release.
36+
37+
If that workflow needs recovery, rerun its original merged-PR event. Stable tag pushes and manual dispatches do not publish, so recovery cannot select a different commit.
3938

4039
For hotfixes, branch from the last stable tag, cherry-pick only the fix, publish the patch release, then merge or cherry-pick the same fix back into the prerelease branch.
4140

@@ -64,8 +63,9 @@ The publish workflow validates release channel boundaries before publishing:
6463

6564
- Stable versions must publish with `latest`.
6665
- Prerelease versions must publish with the prerelease dist-tag, such as `alpha`.
67-
- Stable tags must be reachable from `main` or `release/v*`.
66+
- Stable releases must come from a merged, reviewed `release/vX.Y.Z` PR.
6867
- Prerelease tags must be reachable from a prerelease branch.
6968
- Merged `release/vX.Y.Z` PRs publish stable releases only.
69+
- Every publish job verifies that its checkout matches the immutable event SHA.
7070

7171
This prevents an alpha-only feature from being included in a stable hotfix by accident.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"player:perf": "bun run --filter @hyperframes/player perf",
4848
"format:check": "oxfmt --check .",
4949
"knip": "knip",
50-
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
50+
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
5151
"test:skills": "node --test 'skills/**/*.test.mjs'",
5252
"generate:previews": "tsx scripts/generate-template-previews.ts",
5353
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",

scripts/publish-workflow.test.mjs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync, spawnSync } from "node:child_process";
3+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import test from "node:test";
7+
import { parse } from "yaml";
8+
9+
const workflow = readFileSync(new URL("../.github/workflows/publish.yml", import.meta.url), "utf8");
10+
const config = parse(workflow);
11+
const publish = config.jobs.publish;
12+
const checkout = publish.steps.find((step) => step.uses?.startsWith("actions/checkout@"));
13+
const checkoutGuard = publish.steps.find(
14+
(step) => step.name === "Verify immutable release checkout",
15+
);
16+
const createReleaseTag = publish.steps.find((step) => step.name === "Create release tag");
17+
18+
const normalizeExpression = (expression) => expression.replace(/\s+/g, " ").trim();
19+
20+
function git(cwd, ...args) {
21+
return execFileSync("git", args, {
22+
cwd,
23+
encoding: "utf8",
24+
stdio: "pipe",
25+
timeout: 5_000,
26+
}).trim();
27+
}
28+
29+
function runCreateReleaseTag(cwd, version) {
30+
return spawnSync("bash", ["-euo", "pipefail", "-c", createReleaseTag.run], {
31+
cwd,
32+
encoding: "utf8",
33+
env: { ...process.env, VERSION: version },
34+
timeout: 5_000,
35+
});
36+
}
37+
38+
test("stable publishing has one reviewed immutable event path", () => {
39+
assert.deepEqual(config.on.push.tags, ["v*-*"]);
40+
assert.equal(config.on.workflow_dispatch, undefined);
41+
assert.equal(
42+
normalizeExpression(publish.if),
43+
"github.event_name == 'push' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v'))",
44+
);
45+
assert.equal(
46+
normalizeExpression(publish.env.EXPECTED_RELEASE_SHA),
47+
"${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.sha }}",
48+
);
49+
assert.equal(checkout.with.ref, "${{ env.EXPECTED_RELEASE_SHA }}");
50+
});
51+
52+
test("the executable checkout guard cannot be conditionally disabled", () => {
53+
assert.ok(checkoutGuard);
54+
assert.equal(checkoutGuard.if, undefined);
55+
assert.equal(checkoutGuard["continue-on-error"], undefined);
56+
assert.equal(
57+
checkoutGuard.run.trim(),
58+
[
59+
'ACTUAL_SHA="$(git rev-parse HEAD)"',
60+
'EXPECTED_COMMIT_SHA="$(git rev-parse "${EXPECTED_RELEASE_SHA}^{commit}")"',
61+
'if [ "$ACTUAL_SHA" != "$EXPECTED_COMMIT_SHA" ]; then',
62+
' echo "::error::Expected release commit $EXPECTED_COMMIT_SHA, checked out $ACTUAL_SHA"',
63+
" exit 1",
64+
"fi",
65+
].join("\n"),
66+
);
67+
});
68+
69+
test("stable release tag recovery is idempotent and immutable", () => {
70+
assert.ok(createReleaseTag);
71+
assert.equal(createReleaseTag.if, "github.event_name == 'pull_request'");
72+
assert.equal(
73+
createReleaseTag.run.trim(),
74+
[
75+
'TAG="v$VERSION"',
76+
'EXPECTED_TAG_SHA="$(git rev-parse HEAD)"',
77+
"",
78+
"verify_remote_tag() {",
79+
' git fetch --force --no-tags origin "+refs/tags/$TAG:refs/tags/$TAG"',
80+
' ACTUAL_TAG_SHA="$(git rev-parse "refs/tags/$TAG^{commit}")"',
81+
' if [ "$ACTUAL_TAG_SHA" != "$EXPECTED_TAG_SHA" ]; then',
82+
' echo "::error::Release tag $TAG points to $ACTUAL_TAG_SHA, expected $EXPECTED_TAG_SHA"',
83+
" exit 1",
84+
" fi",
85+
' echo "Release tag $TAG already exists at the expected commit — skipping"',
86+
"}",
87+
"",
88+
'if [ -n "$(git ls-remote --refs origin "refs/tags/$TAG")" ]; then',
89+
" verify_remote_tag",
90+
"else",
91+
' git tag --no-sign "$TAG" "$EXPECTED_TAG_SHA"',
92+
' if ! git push origin "refs/tags/$TAG"; then',
93+
" # A concurrent retry may have created the tag after ls-remote.",
94+
' git tag -d "$TAG"',
95+
" verify_remote_tag",
96+
" fi",
97+
"fi",
98+
].join("\n"),
99+
);
100+
});
101+
102+
test("stable release tag creation survives retries and rejects a mismatched commit", () => {
103+
const root = mkdtempSync(join(tmpdir(), "hyperframes-release-tag-test-"));
104+
const origin = join(root, "origin.git");
105+
const checkout = join(root, "checkout");
106+
107+
try {
108+
execFileSync("git", ["init", "--bare", origin], { stdio: "pipe", timeout: 5_000 });
109+
execFileSync("git", ["init", checkout], { stdio: "pipe", timeout: 5_000 });
110+
git(checkout, "config", "user.name", "HyperFrames Test");
111+
git(checkout, "config", "user.email", "test@hyperframes.invalid");
112+
git(checkout, "commit", "--allow-empty", "-m", "release commit");
113+
git(checkout, "branch", "-M", "main");
114+
git(checkout, "remote", "add", "origin", origin);
115+
git(checkout, "push", "-u", "origin", "main");
116+
117+
const releaseSha = git(checkout, "rev-parse", "HEAD");
118+
const firstRun = runCreateReleaseTag(checkout, "9.8.7");
119+
assert.equal(firstRun.status, 0, `${firstRun.stdout}\n${firstRun.stderr}`);
120+
assert.equal(git(checkout, "rev-parse", "refs/tags/v9.8.7^{commit}"), releaseSha);
121+
122+
const retry = runCreateReleaseTag(checkout, "9.8.7");
123+
assert.equal(retry.status, 0, `${retry.stdout}\n${retry.stderr}`);
124+
assert.match(retry.stdout, /already exists at the expected commit/);
125+
126+
git(checkout, "commit", "--allow-empty", "-m", "different commit");
127+
const mismatch = runCreateReleaseTag(checkout, "9.8.7");
128+
assert.equal(mismatch.status, 1, `${mismatch.stdout}\n${mismatch.stderr}`);
129+
assert.match(mismatch.stdout, /points to .* expected/);
130+
} finally {
131+
rmSync(root, { recursive: true, force: true });
132+
}
133+
});

0 commit comments

Comments
 (0)