mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
ci: guard release channel publishing (#488)
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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.
|
||||
@@ -195,6 +195,7 @@
|
||||
"group": "Contributing",
|
||||
"pages": [
|
||||
"contributing",
|
||||
"contributing/release-channels",
|
||||
"contributing/testing-local-changes"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user