ci: guard release channel publishing (#488)

This commit is contained in:
Miguel Ángel
2026-04-25 06:04:10 +02:00
committed by GitHub
parent 28b84ad8de
commit ef45f653ff
6 changed files with 291 additions and 0 deletions
+9
View File
@@ -32,6 +32,7 @@ jobs:
steps:
- uses: actions/checkout@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: >-
@@ -64,6 +65,14 @@ jobs:
echo "dist_tag=${DIST_TAG}" >> "$GITHUB_OUTPUT"
echo "Resolved version=${VERSION} dist_tag=${DIST_TAG}"
- name: Validate release channel
env:
VERSION: ${{ steps.version.outputs.version }}
DIST_TAG: ${{ steps.version.outputs.dist_tag }}
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: node scripts/validate-release-channel.mjs
- name: Create release tag
if: github.event_name == 'pull_request'
run: |
+61
View File
@@ -0,0 +1,61 @@
---
title: Release channels
description: How HyperFrames keeps alpha-only work out of stable releases.
---
HyperFrames publishes two release channels:
- Stable releases use versions like `0.4.24` and publish to the npm `latest` dist-tag.
- Prereleases use versions like `0.4.24-alpha.1` and publish to the npm dist-tag named by the prerelease suffix, such as `alpha`.
## Branch policy
Use branch separation to decide what code is eligible for each release channel.
Dist-tags only control npm install defaults; they do not remove code from a package.
- `main` is stable/releasable. Anything merged to `main` is eligible for `latest`.
- `release/v*` branches are for stable patch releases and hotfixes.
- `next`, `alpha`, `beta`, `rc`, `canary`, and `prerelease/*` branches are for prerelease integration.
If a feature should ship in alpha only, merge or retarget that PR to a prerelease branch instead of `main`.
## Stable release
Stable releases must be reachable from `origin/main` or `origin/release/v*`.
```bash
bun run set-version 0.4.24
git push origin main --tags
```
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.
## Alpha release
Alpha releases must be reachable from a prerelease branch such as `origin/next` or `origin/alpha`.
```bash
git checkout next
bun run set-version 0.4.25-alpha.1
git push origin next
git push origin v0.4.25-alpha.1
```
Consumers can install alpha builds explicitly:
```bash
npm install hyperframes@alpha
npm install @hyperframes/core@alpha
```
## CI guardrails
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*`.
- Prerelease tags must be reachable from a prerelease branch.
- Merged `release/vX.Y.Z` PRs publish stable releases only.
This prevents an alpha-only feature from being included in a stable hotfix by accident.
+1
View File
@@ -195,6 +195,7 @@
"group": "Contributing",
"pages": [
"contributing",
"contributing/release-channels",
"contributing/testing-local-changes"
]
}
+1
View File
@@ -17,6 +17,7 @@
"build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime",
"build:hyperframes-runtime:modular": "bun run --filter @hyperframes/core build:hyperframes-runtime:modular",
"verify:packed-manifests": "node scripts/verify-packed-manifests.mjs",
"validate:release-channel": "node scripts/validate-release-channel.mjs",
"set-version": "tsx scripts/set-version.ts",
"sync-schemas": "tsx scripts/sync-schemas.ts",
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
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+$/;
export function getPrereleaseId(version) {
const match = VERSION_RE.exec(version);
if (!match) {
return null;
}
return match[1] ?? null;
}
export function expectedDistTag(version) {
const prereleaseId = getPrereleaseId(version);
return prereleaseId ?? "latest";
}
export function normalizeRemoteBranches(output) {
return output
.split("\n")
.map((line) => line.replace(/^[* ]+/, "").trim())
.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;
}
const expectedTag = expectedDistTag(version);
const isPrerelease = expectedTag !== "latest";
if (distTag !== expectedTag) {
errors.push(
`Version "${version}" must publish with npm dist-tag "${expectedTag}", got "${distTag}".`,
);
}
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>";
errors.push(
`Tag v${version} is on ${actualBranches}, but ${distTag} releases must be reachable from ${expectedBranches}.`,
);
}
return errors;
}
function readRemoteBranchesContainingHead() {
const sha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
const output = execFileSync("git", ["branch", "-r", "--contains", sha], {
encoding: "utf8",
});
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();
const errors = validateReleaseChannel({
version,
distTag,
eventName,
prHeadRef,
remoteBranches,
});
if (errors.length > 0) {
for (const error of errors) {
console.error(`::error::${error}`);
}
process.exit(1);
}
const branches = remoteBranches.length > 0 ? remoteBranches.join(", ") : "not required";
console.log(`Release channel validated for v${version} (${distTag}); branches: ${branches}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
expectedDistTag,
getPrereleaseId,
normalizeRemoteBranches,
validateReleaseChannel,
} from "./validate-release-channel.mjs";
describe("release channel validation", () => {
it("derives dist-tags from stable and prerelease versions", () => {
assert.equal(getPrereleaseId("0.4.24"), null);
assert.equal(getPrereleaseId("0.4.24-alpha.1"), "alpha");
assert.equal(expectedDistTag("0.4.24"), "latest");
assert.equal(expectedDistTag("0.4.24-alpha.1"), "alpha");
});
it("allows stable tags reachable from main", () => {
const errors = validateReleaseChannel({
version: "0.4.24",
distTag: "latest",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/main", "origin/next"],
});
assert.deepEqual(errors, []);
});
it("blocks stable tags that only live on prerelease branches", () => {
const errors = validateReleaseChannel({
version: "0.4.24",
distTag: "latest",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/next"],
});
assert.equal(errors.length, 1);
assert.match(errors[0], /latest releases must be reachable from origin\/main/);
});
it("allows alpha tags reachable from next", () => {
const errors = validateReleaseChannel({
version: "0.4.24-alpha.1",
distTag: "alpha",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/next"],
});
assert.deepEqual(errors, []);
});
it("blocks prerelease tags from stable branches", () => {
const errors = validateReleaseChannel({
version: "0.4.24-alpha.1",
distTag: "alpha",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/main"],
});
assert.equal(errors.length, 1);
assert.match(errors[0], /alpha releases must be reachable from origin\/next/);
});
it("blocks dist-tag mismatches", () => {
const errors = validateReleaseChannel({
version: "0.4.24-alpha.1",
distTag: "latest",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/next"],
});
assert.equal(errors.length, 1);
assert.match(errors[0], /must publish with npm dist-tag "alpha"/);
});
it("keeps merged release PRs stable-only", () => {
const errors = validateReleaseChannel({
version: "0.4.24-alpha.1",
distTag: "alpha",
eventName: "pull_request",
prHeadRef: "release/v0.4.24-alpha.1",
remoteBranches: [],
});
assert.equal(errors.length, 2);
assert.match(errors.join("\n"), /release\/vX\.Y\.Z/);
assert.match(errors.join("\n"), /stable releases only/);
});
it("normalizes git branch output", () => {
assert.deepEqual(
normalizeRemoteBranches(" origin/HEAD -> origin/main\n* origin/main\n origin/next\n"),
["origin/main", "origin/next"],
);
});
});