fix: pin release publishing to merge commit (#2959)

This commit is contained in:
James Russo
2026-08-02 21:32:08 -07:00
committed by GitHub
parent 2339757377
commit d6191965cf
7 changed files with 294 additions and 97 deletions
+46 -21
View File
@@ -5,16 +5,12 @@ permissions: {}
on:
push:
tags:
- "v*"
# Stable tags are created only by a merged, reviewed release PR. Direct
# tag pushes are reserved for prerelease channels.
- "v*-*"
pull_request:
types: [closed]
branches: [main]
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.4.11). Tag v<version> must already exist."
required: true
type: string
jobs:
publish:
@@ -25,35 +21,43 @@ jobs:
permissions:
contents: write
id-token: write
# Run on tag push, manual dispatch, OR when a release/* PR is merged
env:
EXPECTED_RELEASE_SHA: >-
${{ github.event_name == 'pull_request'
&& github.event.pull_request.merge_commit_sha
|| github.sha }}
# Stable releases come only from reviewed release PRs. Tag pushes are
# prerelease-only because the trigger pattern requires a hyphenated version.
if: >-
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
startsWith(github.event.pull_request.head.ref, 'release/v'))
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
# On manual dispatch, check out the existing tag so we publish the
# exact commit that was tagged — not whatever is currently on main.
ref: >-
${{ github.event_name == 'workflow_dispatch'
&& format('refs/tags/v{0}', inputs.version)
|| github.ref }}
# Pin every release to an immutable ref. In particular, a merged
# release PR event exposes main as github.ref, which may advance
# before this job starts; use the event's exact merge commit instead.
ref: ${{ env.EXPECTED_RELEASE_SHA }}
- name: Verify immutable release checkout
run: |
ACTUAL_SHA="$(git rev-parse HEAD)"
EXPECTED_COMMIT_SHA="$(git rev-parse "${EXPECTED_RELEASE_SHA}^{commit}")"
if [ "$ACTUAL_SHA" != "$EXPECTED_COMMIT_SHA" ]; then
echo "::error::Expected release commit $EXPECTED_COMMIT_SHA, checked out $ACTUAL_SHA"
exit 1
fi
- name: Resolve version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
if [ "$EVENT_NAME" = "push" ]; then
VERSION="${GITHUB_REF_NAME#v}"
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
VERSION="${INPUT_VERSION}"
VERSION="${VERSION#v}"
else
BRANCH="${PR_HEAD_REF}"
VERSION="${BRANCH#release/v}"
@@ -84,8 +88,29 @@ jobs:
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
git tag "v$VERSION"
git push origin "v$VERSION"
TAG="v$VERSION"
EXPECTED_TAG_SHA="$(git rev-parse HEAD)"
verify_remote_tag() {
git fetch --force --no-tags origin "+refs/tags/$TAG:refs/tags/$TAG"
ACTUAL_TAG_SHA="$(git rev-parse "refs/tags/$TAG^{commit}")"
if [ "$ACTUAL_TAG_SHA" != "$EXPECTED_TAG_SHA" ]; then
echo "::error::Release tag $TAG points to $ACTUAL_TAG_SHA, expected $EXPECTED_TAG_SHA"
exit 1
fi
echo "Release tag $TAG already exists at the expected commit — skipping"
}
if [ -n "$(git ls-remote --refs origin "refs/tags/$TAG")" ]; then
verify_remote_tag
else
git tag --no-sign "$TAG" "$EXPECTED_TAG_SHA"
if ! git push origin "refs/tags/$TAG"; then
# A concurrent retry may have created the tag after ls-remote.
git tag -d "$TAG"
verify_remote_tag
fi
fi
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
+4 -6
View File
@@ -57,13 +57,11 @@ The docs changelog lives in `docs/changelog.mdx` and uses Mintlify `<Update>` en
The release commit can include the version bump, `releases/v0.6.53.md`, and the docs changelog update.
</Step>
<Step title="Publish">
Push the release tag:
```bash
git push origin main --tags
```
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.
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.
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.
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.
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.
</Step>
</Steps>
+6 -6
View File
@@ -21,7 +21,7 @@ If a feature should ship in alpha only, merge or retarget that PR to a prereleas
## Stable release
Stable releases must be reachable from `origin/main` or `origin/release/v*`.
Stable releases publish only when a reviewed `release/vX.Y.Z` PR merges into protected `main`.
Prepare and review release notes before creating the release commit:
```bash
@@ -32,10 +32,9 @@ On the first run, `release:prepare` drafts missing changelog artifacts and exits
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.
```bash
bun run release:prepare <version>
git push origin main --tags
```
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.
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.
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.
@@ -64,8 +63,9 @@ The publish workflow validates release channel boundaries before publishing:
- Stable versions must publish with `latest`.
- Prerelease versions must publish with the prerelease dist-tag, such as `alpha`.
- Stable tags must be reachable from `main` or `release/v*`.
- Stable releases must come from a merged, reviewed `release/vX.Y.Z` PR.
- Prerelease tags must be reachable from a prerelease branch.
- Merged `release/vX.Y.Z` PRs publish stable releases only.
- Every publish job verifies that its checkout matches the immutable event SHA.
This prevents an alpha-only feature from being included in a stable hotfix by accident.
+1 -1
View File
@@ -47,7 +47,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"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",
"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",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
+133
View File
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { parse } from "yaml";
const workflow = readFileSync(new URL("../.github/workflows/publish.yml", import.meta.url), "utf8");
const config = parse(workflow);
const publish = config.jobs.publish;
const checkout = publish.steps.find((step) => step.uses?.startsWith("actions/checkout@"));
const checkoutGuard = publish.steps.find(
(step) => step.name === "Verify immutable release checkout",
);
const createReleaseTag = publish.steps.find((step) => step.name === "Create release tag");
const normalizeExpression = (expression) => expression.replace(/\s+/g, " ").trim();
function git(cwd, ...args) {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: "pipe",
timeout: 5_000,
}).trim();
}
function runCreateReleaseTag(cwd, version) {
return spawnSync("bash", ["-euo", "pipefail", "-c", createReleaseTag.run], {
cwd,
encoding: "utf8",
env: { ...process.env, VERSION: version },
timeout: 5_000,
});
}
test("stable publishing has one reviewed immutable event path", () => {
assert.deepEqual(config.on.push.tags, ["v*-*"]);
assert.equal(config.on.workflow_dispatch, undefined);
assert.equal(
normalizeExpression(publish.if),
"github.event_name == 'push' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v'))",
);
assert.equal(
normalizeExpression(publish.env.EXPECTED_RELEASE_SHA),
"${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.sha }}",
);
assert.equal(checkout.with.ref, "${{ env.EXPECTED_RELEASE_SHA }}");
});
test("the executable checkout guard cannot be conditionally disabled", () => {
assert.ok(checkoutGuard);
assert.equal(checkoutGuard.if, undefined);
assert.equal(checkoutGuard["continue-on-error"], undefined);
assert.equal(
checkoutGuard.run.trim(),
[
'ACTUAL_SHA="$(git rev-parse HEAD)"',
'EXPECTED_COMMIT_SHA="$(git rev-parse "${EXPECTED_RELEASE_SHA}^{commit}")"',
'if [ "$ACTUAL_SHA" != "$EXPECTED_COMMIT_SHA" ]; then',
' echo "::error::Expected release commit $EXPECTED_COMMIT_SHA, checked out $ACTUAL_SHA"',
" exit 1",
"fi",
].join("\n"),
);
});
test("stable release tag recovery is idempotent and immutable", () => {
assert.ok(createReleaseTag);
assert.equal(createReleaseTag.if, "github.event_name == 'pull_request'");
assert.equal(
createReleaseTag.run.trim(),
[
'TAG="v$VERSION"',
'EXPECTED_TAG_SHA="$(git rev-parse HEAD)"',
"",
"verify_remote_tag() {",
' git fetch --force --no-tags origin "+refs/tags/$TAG:refs/tags/$TAG"',
' ACTUAL_TAG_SHA="$(git rev-parse "refs/tags/$TAG^{commit}")"',
' if [ "$ACTUAL_TAG_SHA" != "$EXPECTED_TAG_SHA" ]; then',
' echo "::error::Release tag $TAG points to $ACTUAL_TAG_SHA, expected $EXPECTED_TAG_SHA"',
" exit 1",
" fi",
' echo "Release tag $TAG already exists at the expected commit — skipping"',
"}",
"",
'if [ -n "$(git ls-remote --refs origin "refs/tags/$TAG")" ]; then',
" verify_remote_tag",
"else",
' git tag --no-sign "$TAG" "$EXPECTED_TAG_SHA"',
' if ! git push origin "refs/tags/$TAG"; then',
" # A concurrent retry may have created the tag after ls-remote.",
' git tag -d "$TAG"',
" verify_remote_tag",
" fi",
"fi",
].join("\n"),
);
});
test("stable release tag creation survives retries and rejects a mismatched commit", () => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-release-tag-test-"));
const origin = join(root, "origin.git");
const checkout = join(root, "checkout");
try {
execFileSync("git", ["init", "--bare", origin], { stdio: "pipe", timeout: 5_000 });
execFileSync("git", ["init", checkout], { stdio: "pipe", timeout: 5_000 });
git(checkout, "config", "user.name", "HyperFrames Test");
git(checkout, "config", "user.email", "test@hyperframes.invalid");
git(checkout, "commit", "--allow-empty", "-m", "release commit");
git(checkout, "branch", "-M", "main");
git(checkout, "remote", "add", "origin", origin);
git(checkout, "push", "-u", "origin", "main");
const releaseSha = git(checkout, "rev-parse", "HEAD");
const firstRun = runCreateReleaseTag(checkout, "9.8.7");
assert.equal(firstRun.status, 0, `${firstRun.stdout}\n${firstRun.stderr}`);
assert.equal(git(checkout, "rev-parse", "refs/tags/v9.8.7^{commit}"), releaseSha);
const retry = runCreateReleaseTag(checkout, "9.8.7");
assert.equal(retry.status, 0, `${retry.stdout}\n${retry.stderr}`);
assert.match(retry.stdout, /already exists at the expected commit/);
git(checkout, "commit", "--allow-empty", "-m", "different commit");
const mismatch = runCreateReleaseTag(checkout, "9.8.7");
assert.equal(mismatch.status, 1, `${mismatch.stdout}\n${mismatch.stderr}`);
assert.match(mismatch.stdout, /points to .* expected/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+74 -58
View File
@@ -2,7 +2,6 @@
import { execFileSync } from "node:child_process";
const VERSION_RE = /^\d+\.\d+\.\d+(?:-([0-9A-Za-z-]+)(?:\.[0-9A-Za-z-]+)*)?$/;
const STABLE_BRANCH_RE = /^origin\/(main|release\/v.+)$/;
const PRERELEASE_BRANCH_RE = /^origin\/(next|alpha|beta|rc|canary|prerelease\/.+)$/;
const RELEASE_PR_RE = /^release\/v\d+\.\d+\.\d+$/;
@@ -26,59 +25,62 @@ export function normalizeRemoteBranches(output) {
.filter((line) => line && !line.includes("HEAD ->"));
}
export function validateReleaseChannel({ version, distTag, eventName, prHeadRef, remoteBranches }) {
const errors = [];
if (!VERSION_RE.test(version)) {
errors.push(`Invalid release version "${version}". Expected x.y.z or x.y.z-channel.N.`);
return errors;
}
function validateDistTag(version, distTag) {
const expectedTag = expectedDistTag(version);
const isPrerelease = expectedTag !== "latest";
if (distTag === expectedTag) return [];
return [
`Version "${version}" must publish with npm dist-tag "${expectedTag}", got "${distTag}".`,
];
}
if (distTag !== expectedTag) {
function validateMergedReleasePr({ version, prHeadRef }) {
const errors = [];
if (!RELEASE_PR_RE.test(prHeadRef)) {
errors.push(
`Version "${version}" must publish with npm dist-tag "${expectedTag}", got "${distTag}".`,
`Merged release PRs must come from release/vX.Y.Z branches, got "${prHeadRef || "<empty>"}".`,
);
}
if (eventName === "pull_request") {
if (!RELEASE_PR_RE.test(prHeadRef)) {
errors.push(
`Merged release PRs must come from release/vX.Y.Z branches, got "${prHeadRef || "<empty>"}".`,
);
}
if (isPrerelease) {
errors.push(
"Merged release PRs publish stable releases only. Publish prereleases from next/alpha tags instead.",
);
}
return errors;
}
if (eventName !== "push" && eventName !== "workflow_dispatch") {
errors.push(`Unsupported publish event "${eventName}".`);
return errors;
}
const allowedBranch = isPrerelease
? remoteBranches.some((branch) => PRERELEASE_BRANCH_RE.test(branch))
: remoteBranches.some((branch) => STABLE_BRANCH_RE.test(branch));
if (!allowedBranch) {
const expectedBranches = isPrerelease
? "origin/next, origin/alpha, origin/beta, origin/rc, origin/canary, or origin/prerelease/*"
: "origin/main or origin/release/v*";
const actualBranches = remoteBranches.length > 0 ? remoteBranches.join(", ") : "<none>";
if (expectedDistTag(version) !== "latest") {
errors.push(
`Tag v${version} is on ${actualBranches}, but ${distTag} releases must be reachable from ${expectedBranches}.`,
"Merged release PRs publish stable releases only. Publish prereleases from next/alpha tags instead.",
);
}
return errors;
}
function validatePrereleaseTagPush({ version, distTag, remoteBranches }) {
if (expectedDistTag(version) === "latest") {
return [
"Stable tag publishing is disabled. Merge a reviewed release/vX.Y.Z PR into main and rerun that immutable merge event for recovery.",
];
}
const allowedBranch = remoteBranches.some((branch) => PRERELEASE_BRANCH_RE.test(branch));
if (allowedBranch) return [];
const actualBranches = remoteBranches.length > 0 ? remoteBranches.join(", ") : "<none>";
return [
`Tag v${version} is on ${actualBranches}, but ${distTag} releases must be reachable from origin/next, origin/alpha, origin/beta, origin/rc, origin/canary, or origin/prerelease/*.`,
];
}
const EVENT_VALIDATORS = new Map([
["pull_request", validateMergedReleasePr],
["push", validatePrereleaseTagPush],
]);
function validateReleaseSource(input) {
const validator = EVENT_VALIDATORS.get(input.eventName);
return validator ? validator(input) : [`Unsupported publish event "${input.eventName}".`];
}
export function validateReleaseChannel(input) {
if (!VERSION_RE.test(input.version)) {
return [`Invalid release version "${input.version}". Expected x.y.z or x.y.z-channel.N.`];
}
return [...validateDistTag(input.version, input.distTag), ...validateReleaseSource(input)];
}
function readRemoteBranchesContainingHead() {
const sha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
const output = execFileSync("git", ["branch", "-r", "--contains", sha], {
@@ -87,30 +89,44 @@ function readRemoteBranchesContainingHead() {
return normalizeRemoteBranches(output);
}
function main() {
const version = process.env.VERSION ?? "";
const distTag = process.env.DIST_TAG ?? "";
const eventName = process.env.EVENT_NAME ?? "";
const prHeadRef = process.env.PR_HEAD_REF ?? "";
const remoteBranches = eventName === "pull_request" ? [] : readRemoteBranchesContainingHead();
function readEnv(name) {
return process.env[name] ?? "";
}
const errors = validateReleaseChannel({
version,
distTag,
function readRemoteBranchesForEvent(eventName) {
return eventName === "pull_request" ? [] : readRemoteBranchesContainingHead();
}
function readValidationInput() {
const eventName = readEnv("EVENT_NAME");
return {
version: readEnv("VERSION"),
distTag: readEnv("DIST_TAG"),
eventName,
prHeadRef,
remoteBranches,
});
prHeadRef: readEnv("PR_HEAD_REF"),
remoteBranches: readRemoteBranchesForEvent(eventName),
};
}
function reportValidation(input, errors) {
if (errors.length > 0) {
for (const error of errors) {
console.error(`::error::${error}`);
}
process.exit(1);
process.exitCode = 1;
return;
}
const branches = remoteBranches.length > 0 ? remoteBranches.join(", ") : "not required";
console.log(`Release channel validated for v${version} (${distTag}); branches: ${branches}`);
const branches =
input.remoteBranches.length > 0 ? input.remoteBranches.join(", ") : "not required";
console.log(
`Release channel validated for v${input.version} (${input.distTag}); branches: ${branches}`,
);
}
function main() {
const input = readValidationInput();
reportValidation(input, validateReleaseChannel(input));
}
if (import.meta.url === `file://${process.argv[1]}`) {
+30 -5
View File
@@ -15,7 +15,19 @@ describe("release channel validation", () => {
assert.equal(expectedDistTag("0.4.24-alpha.1"), "alpha");
});
it("allows stable tags reachable from main", () => {
it("allows a stable release from a reviewed release PR", () => {
const errors = validateReleaseChannel({
version: "0.4.24",
distTag: "latest",
eventName: "pull_request",
prHeadRef: "release/v0.4.24",
remoteBranches: [],
});
assert.deepEqual(errors, []);
});
it("blocks stable tag pushes even when reachable from main", () => {
const errors = validateReleaseChannel({
version: "0.4.24",
distTag: "latest",
@@ -24,20 +36,21 @@ describe("release channel validation", () => {
remoteBranches: ["origin/main", "origin/next"],
});
assert.deepEqual(errors, []);
assert.equal(errors.length, 1);
assert.match(errors[0], /Stable tag publishing is disabled/);
});
it("blocks stable tags that only live on prerelease branches", () => {
it("blocks stable tags that only live on an unmerged release branch", () => {
const errors = validateReleaseChannel({
version: "0.4.24",
distTag: "latest",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/next"],
remoteBranches: ["origin/release/v0.4.24"],
});
assert.equal(errors.length, 1);
assert.match(errors[0], /latest releases must be reachable from origin\/main/);
assert.match(errors[0], /Stable tag publishing is disabled/);
});
it("allows alpha tags reachable from next", () => {
@@ -92,6 +105,18 @@ describe("release channel validation", () => {
assert.match(errors.join("\n"), /stable releases only/);
});
it("rejects manual publish events", () => {
const errors = validateReleaseChannel({
version: "0.4.24",
distTag: "latest",
eventName: "workflow_dispatch",
prHeadRef: "",
remoteBranches: ["origin/main"],
});
assert.deepEqual(errors, ['Unsupported publish event "workflow_dispatch".']);
});
it("normalizes git branch output", () => {
assert.deepEqual(
normalizeRemoteBranches(" origin/HEAD -> origin/main\n* origin/main\n origin/next\n"),