authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 21:20:38-07:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 21:20:38-07:00
log3060cc461458e66842dfb656d979c4acca4dfb20
tree4a02750636d8fd53fe6b274480c301d08dcb0455
parent47ef0177dc18d328e91c64a1641cfe1f37298160

fix(release): restrict stable channels to main


4 files changed, 141 insertions(+), 9 deletions(-)

.github/scripts/check-nightly-release.cjs+44-1
...@@ -1,5 +1,43 @@...@@ -1,5 +1,43 @@
1const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;1const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;
22
3function repositoryDefaultBranch(context) {
4 const branch = context.payload.repository?.default_branch;
5 if (!branch) {
6 throw new Error("GitHub did not provide the repository default branch.");
7 }
8 return branch;
9}
10
11async function assertCommitOnDefaultBranch({ github, context, sha }) {
12 const defaultBranch = repositoryDefaultBranch(context);
13 const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
14 ...context.repo,
15 basehead: `${sha}...${defaultBranch}`,
16 per_page: 1,
17 });
18 if (comparison.status !== "ahead" && comparison.status !== "identical") {
19 throw new Error(
20 `Release commit ${sha} is not contained in ${defaultBranch} (${comparison.status}).`,
21 );
22 }
23}
24
25async function assertReleaseSource({ github, context, releaseChannel }) {
26 if (releaseChannel === "preview") return;
27 if (releaseChannel !== "stable" && releaseChannel !== "nightly") {
28 throw new Error(`Unsupported release channel: ${releaseChannel}`);
29 }
30
31 const defaultBranch = repositoryDefaultBranch(context);
32 if (context.eventName === "workflow_dispatch" && context.ref !== `refs/heads/${defaultBranch}`) {
33 throw new Error(
34 `${releaseChannel} releases must be dispatched from ${defaultBranch}; selected ${context.ref}. Use the preview channel for branch builds.`,
35 );
36 }
37
38 await assertCommitOnDefaultBranch({ github, context, sha: context.sha });
39}
40
3const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v");41const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v");
442
5// Newest published nightly by publication time, or undefined when none exists.43// Newest published nightly by publication time, or undefined when none exists.
...@@ -64,4 +102,9 @@ async function resolveLatestNightlyCommit({ github, context, core }) {...@@ -64,4 +102,9 @@ async function resolveLatestNightlyCommit({ github, context, core }) {
64 return { tag, sha: commit.sha, version };102 return { tag, sha: commit.sha, version };
65}103}
66104
67module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit };105module.exports = {
106 assertCommitOnDefaultBranch,
107 assertReleaseSource,
108 shouldReleaseNightly,
109 resolveLatestNightlyCommit,
110};
.github/scripts/check-nightly-release.test.cjs+76-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1const assert = require("node:assert/strict");1const assert = require("node:assert/strict");
2const test = require("node:test");2const test = require("node:test");
3const { shouldReleaseNightly } = require("./check-nightly-release.cjs");3const {
4 assertCommitOnDefaultBranch,
5 assertReleaseSource,
6 shouldReleaseNightly,
7} = require("./check-nightly-release.cjs");
48
5const now = Date.parse("2026-09-05T12:00:00Z");9const now = Date.parse("2026-09-05T12:00:00Z");
6const hour = 60 * 60 * 1000;10const hour = 60 * 60 * 1000;
...@@ -11,6 +15,77 @@ const nightly = (hoursAgo, overrides = {}) => ({...@@ -11,6 +15,77 @@ const nightly = (hoursAgo, overrides = {}) => ({
11 ...overrides,15 ...overrides,
12});16});
1317
18function releaseSourceFixture({
19 comparisonStatus = "ahead",
20 eventName = "workflow_dispatch",
21 ref = "refs/heads/main",
22 sha = "candidate",
23} = {}) {
24 const calls = [];
25 return {
26 calls,
27 options: {
28 context: {
29 eventName,
30 payload: { repository: { default_branch: "main" } },
31 ref,
32 repo: { owner: "example", repo: "app" },
33 sha,
34 },
35 github: {
36 rest: {
37 repos: {
38 async compareCommitsWithBasehead(params) {
39 calls.push(params);
40 return { data: { status: comparisonStatus } };
41 },
42 },
43 },
44 },
45 },
46 };
47}
48
49test("allows preview releases from any branch without consulting main", async () => {
50 const { options, calls } = releaseSourceFixture({ ref: "refs/heads/feature" });
51 await assertReleaseSource({ ...options, releaseChannel: "preview" });
52 assert.equal(calls.length, 0);
53});
54
55for (const releaseChannel of ["stable", "nightly"]) {
56 test(`rejects a manual ${releaseChannel} release from a feature branch`, async () => {
57 const { options, calls } = releaseSourceFixture({ ref: "refs/heads/feature" });
58 await assert.rejects(
59 assertReleaseSource({ ...options, releaseChannel }),
60 new RegExp(`${releaseChannel} releases must be dispatched from main`),
61 );
62 assert.equal(calls.length, 0);
63 });
64
65 test(`allows a manual ${releaseChannel} release from main`, async () => {
66 const { options, calls } = releaseSourceFixture();
67 await assertReleaseSource({ ...options, releaseChannel });
68 assert.equal(calls[0].basehead, "candidate...main");
69 });
70}
71
72for (const comparisonStatus of ["behind", "diverged"]) {
73 test(`rejects a release commit that is ${comparisonStatus} from main`, async () => {
74 const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" });
75 await assert.rejects(
76 assertCommitOnDefaultBranch({ ...options, sha: "release-commit" }),
77 new RegExp(`not contained in main \\(${comparisonStatus}\\)`),
78 );
79 });
80}
81
82test("accepts a release commit already contained in main", async () => {
83 for (const comparisonStatus of ["ahead", "identical"]) {
84 const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" });
85 await assertCommitOnDefaultBranch({ ...options, sha: "release-commit" });
86 }
87});
88
14function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) {89function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) {
15 const calls = [];90 const calls = [];
16 return {91 return {
.github/workflows/release.yml+17-5
...@@ -14,12 +14,12 @@ on:...@@ -14,12 +14,12 @@ on:
14 channel:14 channel:
15 description: "Release channel"15 description: "Release channel"
16 required: false16 required: false
17 default: stable17 default: preview
18 type: choice18 type: choice
19 options:19 options:
20 - preview
20 - stable21 - stable
21 - nightly22 - nightly
22 - preview
23 version:23 version:
24 description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed."24 description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed."
25 required: false25 required: false
...@@ -67,15 +67,27 @@ jobs:...@@ -67,15 +67,27 @@ jobs:
67 with:67 with:
68 script: |68 script: |
69 const {69 const {
70 assertCommitOnDefaultBranch,
71 assertReleaseSource,
70 shouldReleaseNightly,72 shouldReleaseNightly,
71 resolveLatestNightlyCommit,73 resolveLatestNightlyCommit,
72 } = require('./.github/scripts/check-nightly-release.cjs');74 } = require('./.github/scripts/check-nightly-release.cjs');
7375
76 const dispatchChannel = process.env.DISPATCH_CHANNEL || 'preview';
77 const releaseChannel = context.eventName === 'schedule'
78 ? 'nightly'
79 : context.eventName === 'workflow_dispatch'
80 ? dispatchChannel
81 : 'stable';
82
83 await assertReleaseSource({ github, context, releaseChannel });
84
74 if (context.eventName === 'schedule') {85 if (context.eventName === 'schedule') {
75 core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core }));86 core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core }));
76 core.setOutput('ref', context.sha);87 core.setOutput('ref', context.sha);
77 } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly' && process.env.DISPATCH_CHANNEL !== 'preview') {88 } else if (context.eventName === 'workflow_dispatch' && dispatchChannel === 'stable') {
78 const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core });89 const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core });
90 await assertCommitOnDefaultBranch({ github, context, sha });
79 core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`);91 core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`);
80 core.setOutput('ref', sha);92 core.setOutput('ref', sha);
81 core.setOutput('nightly_version', version);93 core.setOutput('nightly_version', version);
...@@ -133,7 +145,7 @@ jobs:...@@ -133,7 +145,7 @@ jobs:
133 NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }}145 NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }}
134 NIGHTLY_RUN_NUMBER: ${{ github.run_number }}146 NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
135 run: |147 run: |
136 if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then148 if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "nightly" ) ]]; then
137 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"149 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
138150
139 node scripts/resolve-nightly-release.ts \151 node scripts/resolve-nightly-release.ts \
...@@ -146,7 +158,7 @@ jobs:...@@ -146,7 +158,7 @@ jobs:
146 echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"158 echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"
147 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"159 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
148 echo "make_latest=false" >> "$GITHUB_OUTPUT"160 echo "make_latest=false" >> "$GITHUB_OUTPUT"
149 elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then161 elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then
150 # Manual-only test train: exercises the whole release flow for a162 # Manual-only test train: exercises the whole release flow for a
151 # commit end users must never receive. Never scheduled.163 # commit end users must never receive. Never scheduled.
152 # Same versioning as nightly under its own prerelease identifier.164 # Same versioning as nightly under its own prerelease identifier.
docs/operations/release.md+4-2
...@@ -8,11 +8,13 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -8,11 +8,13 @@ This document covers the unified release workflow for stable and nightly desktop
88
9- Workflow: `.github/workflows/release.yml`9- Workflow: `.github/workflows/release.yml`
10- Triggers:10- Triggers:
11 - manual `workflow_dispatch` with `channel=stable`, the normal way to ship stable11 - manual `workflow_dispatch` with `channel=stable`, the normal way to ship stable. Stable
12 and nightly dispatches must select `main`; preview may select any branch. The channel defaults
13 to preview so an omitted selection cannot publish a stable release.
12 - push tag matching `v*.*.*` for a stable release of an explicit commit14 - push tag matching `v*.*.*` for a stable release of an explicit commit
13 - scheduled nightly check every 30 minutes15 - scheduled nightly check every 30 minutes
14 - manual `workflow_dispatch` with `channel=nightly`16 - manual `workflow_dispatch` with `channel=nightly`
15 - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview.<date>.<run>`) and publishes a GitHub prerelease plus the npm packages under the `preview` dist-tag. Nothing ever selects preview on its own: it is not on the schedule, no default npm dist-tag points at it, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `npx t3@preview`, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle.17 - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview.<date>.<run>`) and publishes a GitHub prerelease plus the npm packages under the `preview` dist-tag. Preview is not on the schedule, no default npm dist-tag points at it, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `npx t3@preview`, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle.
16- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.18- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.
17 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep19 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep
18 landing while you verify and never leak into the stable build.20 landing while you verify and never leak into the stable build.