From 3060cc461458e66842dfb656d979c4acca4dfb20 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 21:20:38 -0700 Subject: [PATCH] fix(release): restrict stable channels to main --- .github/scripts/check-nightly-release.cjs | 45 ++++++++++- .../scripts/check-nightly-release.test.cjs | 77 ++++++++++++++++++- .github/workflows/release.yml | 22 ++++-- docs/operations/release.md | 6 +- 4 files changed, 141 insertions(+), 9 deletions(-) diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 82ee40da1aff47beaa938aca543d49c062aded9f..377ac36a22a8753ebb64ff383761d010ee5525f1 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -1,5 +1,43 @@ const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; +function repositoryDefaultBranch(context) { + const branch = context.payload.repository?.default_branch; + if (!branch) { + throw new Error("GitHub did not provide the repository default branch."); + } + return branch; +} + +async function assertCommitOnDefaultBranch({ github, context, sha }) { + const defaultBranch = repositoryDefaultBranch(context); + const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${sha}...${defaultBranch}`, + per_page: 1, + }); + if (comparison.status !== "ahead" && comparison.status !== "identical") { + throw new Error( + `Release commit ${sha} is not contained in ${defaultBranch} (${comparison.status}).`, + ); + } +} + +async function assertReleaseSource({ github, context, releaseChannel }) { + if (releaseChannel === "preview") return; + if (releaseChannel !== "stable" && releaseChannel !== "nightly") { + throw new Error(`Unsupported release channel: ${releaseChannel}`); + } + + const defaultBranch = repositoryDefaultBranch(context); + if (context.eventName === "workflow_dispatch" && context.ref !== `refs/heads/${defaultBranch}`) { + throw new Error( + `${releaseChannel} releases must be dispatched from ${defaultBranch}; selected ${context.ref}. Use the preview channel for branch builds.`, + ); + } + + await assertCommitOnDefaultBranch({ github, context, sha: context.sha }); +} + const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); // Newest published nightly by publication time, or undefined when none exists. @@ -64,4 +102,9 @@ async function resolveLatestNightlyCommit({ github, context, core }) { return { tag, sha: commit.sha, version }; } -module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit }; +module.exports = { + assertCommitOnDefaultBranch, + assertReleaseSource, + shouldReleaseNightly, + resolveLatestNightlyCommit, +}; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index 49ade68aeef7a4d33c5f0c2c950a5ccc21ef15e0..64d8c8d6c0bd6fa9709b1bddf3189aa338455004 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -1,6 +1,10 @@ const assert = require("node:assert/strict"); const test = require("node:test"); -const { shouldReleaseNightly } = require("./check-nightly-release.cjs"); +const { + assertCommitOnDefaultBranch, + assertReleaseSource, + shouldReleaseNightly, +} = require("./check-nightly-release.cjs"); const now = Date.parse("2026-09-05T12:00:00Z"); const hour = 60 * 60 * 1000; @@ -11,6 +15,77 @@ const nightly = (hoursAgo, overrides = {}) => ({ ...overrides, }); +function releaseSourceFixture({ + comparisonStatus = "ahead", + eventName = "workflow_dispatch", + ref = "refs/heads/main", + sha = "candidate", +} = {}) { + const calls = []; + return { + calls, + options: { + context: { + eventName, + payload: { repository: { default_branch: "main" } }, + ref, + repo: { owner: "example", repo: "app" }, + sha, + }, + github: { + rest: { + repos: { + async compareCommitsWithBasehead(params) { + calls.push(params); + return { data: { status: comparisonStatus } }; + }, + }, + }, + }, + }, + }; +} + +test("allows preview releases from any branch without consulting main", async () => { + const { options, calls } = releaseSourceFixture({ ref: "refs/heads/feature" }); + await assertReleaseSource({ ...options, releaseChannel: "preview" }); + assert.equal(calls.length, 0); +}); + +for (const releaseChannel of ["stable", "nightly"]) { + test(`rejects a manual ${releaseChannel} release from a feature branch`, async () => { + const { options, calls } = releaseSourceFixture({ ref: "refs/heads/feature" }); + await assert.rejects( + assertReleaseSource({ ...options, releaseChannel }), + new RegExp(`${releaseChannel} releases must be dispatched from main`), + ); + assert.equal(calls.length, 0); + }); + + test(`allows a manual ${releaseChannel} release from main`, async () => { + const { options, calls } = releaseSourceFixture(); + await assertReleaseSource({ ...options, releaseChannel }); + assert.equal(calls[0].basehead, "candidate...main"); + }); +} + +for (const comparisonStatus of ["behind", "diverged"]) { + test(`rejects a release commit that is ${comparisonStatus} from main`, async () => { + const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" }); + await assert.rejects( + assertCommitOnDefaultBranch({ ...options, sha: "release-commit" }), + new RegExp(`not contained in main \\(${comparisonStatus}\\)`), + ); + }); +} + +test("accepts a release commit already contained in main", async () => { + for (const comparisonStatus of ["ahead", "identical"]) { + const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" }); + await assertCommitOnDefaultBranch({ ...options, sha: "release-commit" }); + } +}); + function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { const calls = []; return { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 998e5fd7eacb67cfbacc3dc90f66d96668570656..ae43abafd85e631482e4d7e2e1cede45ce3bf73d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,12 +14,12 @@ on: channel: description: "Release channel" required: false - default: stable + default: preview type: choice options: + - preview - stable - nightly - - preview version: description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed." required: false @@ -67,15 +67,27 @@ jobs: with: script: | const { + assertCommitOnDefaultBranch, + assertReleaseSource, shouldReleaseNightly, resolveLatestNightlyCommit, } = require('./.github/scripts/check-nightly-release.cjs'); + const dispatchChannel = process.env.DISPATCH_CHANNEL || 'preview'; + const releaseChannel = context.eventName === 'schedule' + ? 'nightly' + : context.eventName === 'workflow_dispatch' + ? dispatchChannel + : 'stable'; + + await assertReleaseSource({ github, context, releaseChannel }); + if (context.eventName === 'schedule') { core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core })); core.setOutput('ref', context.sha); - } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly' && process.env.DISPATCH_CHANNEL !== 'preview') { + } else if (context.eventName === 'workflow_dispatch' && dispatchChannel === 'stable') { const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); + await assertCommitOnDefaultBranch({ github, context, sha }); core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); core.setOutput('ref', sha); core.setOutput('nightly_version', version); @@ -133,7 +145,7 @@ jobs: NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }} NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | - if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then + if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "nightly" ) ]]; then nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ @@ -146,7 +158,7 @@ jobs: echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" - elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then # Manual-only test train: exercises the whole release flow for a # commit end users must never receive. Never scheduled. # Same versioning as nightly under its own prerelease identifier. diff --git a/docs/operations/release.md b/docs/operations/release.md index fe8a8e2081ad5a27c7b7407984ef3bf23c88ed24..880d36e60c573c284940ebfcfca70d9790ca7ab5 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -8,11 +8,13 @@ This document covers the unified release workflow for stable and nightly desktop - Workflow: `.github/workflows/release.yml` - Triggers: - - manual `workflow_dispatch` with `channel=stable`, the normal way to ship stable + - manual `workflow_dispatch` with `channel=stable`, the normal way to ship stable. Stable + and nightly dispatches must select `main`; preview may select any branch. The channel defaults + to preview so an omitted selection cannot publish a stable release. - push tag matching `v*.*.*` for a stable release of an explicit commit - scheduled nightly check every 30 minutes - manual `workflow_dispatch` with `channel=nightly` - - 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..`) 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. + - 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..`) 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. - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep landing while you verify and never leak into the stable build. -- 2.54.0