Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 46 additions & 21 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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}"
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 4 additions & 6 deletions docs/contributing/changelog-process.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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 between the PR merge and the final tag push.
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>

Expand Down
12 changes: 6 additions & 6 deletions docs/contributing/release-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions scripts/publish-workflow.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Loading
Loading