| author | |
| committer | |
| log | 3060cc461458e66842dfb656d979c4acca4dfb20 |
| tree | 4a02750636d8fd53fe6b274480c301d08dcb0455 |
| parent | 47ef0177dc18d328e91c64a1641cfe1f37298160 |
4 files changed, 141 insertions(+), 9 deletions(-)
.github/scripts/check-nightly-release.cjs+44-1| ... | ... | @@ -1,5 +1,43 @@ |
| 1 | 1 | const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; |
| 2 | 2 | |
| 3 | function 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 | ||
| 11 | async 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 | ||
| 25 | async 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 | ||
| 3 | 41 | const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); |
| 4 | 42 | |
| 5 | 43 | // Newest published nightly by publication time, or undefined when none exists. |
| ... | ... | @@ -64,4 +102,9 @@ async function resolveLatestNightlyCommit({ github, context, core }) { |
| 64 | 102 | return { tag, sha: commit.sha, version }; |
| 65 | 103 | } |
| 66 | 104 | |
| 67 | module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit }; | |
| 105 | module.exports = { | |
| 106 | assertCommitOnDefaultBranch, | |
| 107 | assertReleaseSource, | |
| 108 | shouldReleaseNightly, | |
| 109 | resolveLatestNightlyCommit, | |
| 110 | }; |
.github/scripts/check-nightly-release.test.cjs+76-1| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | const assert = require("node:assert/strict"); |
| 2 | 2 | const test = require("node:test"); |
| 3 | const { shouldReleaseNightly } = require("./check-nightly-release.cjs"); | |
| 3 | const { | |
| 4 | assertCommitOnDefaultBranch, | |
| 5 | assertReleaseSource, | |
| 6 | shouldReleaseNightly, | |
| 7 | } = require("./check-nightly-release.cjs"); | |
| 4 | 8 | |
| 5 | 9 | const now = Date.parse("2026-09-05T12:00:00Z"); |
| 6 | 10 | const hour = 60 * 60 * 1000; |
| ... | ... | @@ -11,6 +15,77 @@ const nightly = (hoursAgo, overrides = {}) => ({ |
| 11 | 15 | ...overrides, |
| 12 | 16 | }); |
| 13 | 17 | |
| 18 | function 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 | ||
| 49 | test("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 | ||
| 55 | for (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 | ||
| 72 | for (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 | ||
| 82 | test("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 | ||
| 14 | 89 | function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { |
| 15 | 90 | const calls = []; |
| 16 | 91 | return { |
.github/workflows/release.yml+17-5| ... | ... | @@ -14,12 +14,12 @@ on: |
| 14 | 14 | channel: |
| 15 | 15 | description: "Release channel" |
| 16 | 16 | required: false |
| 17 | default: stable | |
| 17 | default: preview | |
| 18 | 18 | type: choice |
| 19 | 19 | options: |
| 20 | - preview | |
| 20 | 21 | - stable |
| 21 | 22 | - nightly |
| 22 | - preview | |
| 23 | 23 | version: |
| 24 | 24 | description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed." |
| 25 | 25 | required: false |
| ... | ... | @@ -67,15 +67,27 @@ jobs: |
| 67 | 67 | with: |
| 68 | 68 | script: | |
| 69 | 69 | const { |
| 70 | assertCommitOnDefaultBranch, | |
| 71 | assertReleaseSource, | |
| 70 | 72 | shouldReleaseNightly, |
| 71 | 73 | resolveLatestNightlyCommit, |
| 72 | 74 | } = require('./.github/scripts/check-nightly-release.cjs'); |
| 73 | 75 | |
| 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 | 85 | if (context.eventName === 'schedule') { |
| 75 | 86 | core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core })); |
| 76 | 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 | 89 | const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); |
| 90 | await assertCommitOnDefaultBranch({ github, context, sha }); | |
| 79 | 91 | core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); |
| 80 | 92 | core.setOutput('ref', sha); |
| 81 | 93 | core.setOutput('nightly_version', version); |
| ... | ... | @@ -133,7 +145,7 @@ jobs: |
| 133 | 145 | NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }} |
| 134 | 146 | NIGHTLY_RUN_NUMBER: ${{ github.run_number }} |
| 135 | 147 | run: | |
| 136 | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then | |
| 148 | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "nightly" ) ]]; then | |
| 137 | 149 | nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" |
| 138 | 150 | |
| 139 | 151 | node scripts/resolve-nightly-release.ts \ |
| ... | ... | @@ -146,7 +158,7 @@ jobs: |
| 146 | 158 | echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" |
| 147 | 159 | echo "is_prerelease=true" >> "$GITHUB_OUTPUT" |
| 148 | 160 | echo "make_latest=false" >> "$GITHUB_OUTPUT" |
| 149 | elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then | |
| 161 | elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then | |
| 150 | 162 | # Manual-only test train: exercises the whole release flow for a |
| 151 | 163 | # commit end users must never receive. Never scheduled. |
| 152 | 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 | 8 | |
| 9 | 9 | - Workflow: `.github/workflows/release.yml` |
| 10 | 10 | - Triggers: |
| 11 | - manual `workflow_dispatch` with `channel=stable`, the normal way to ship stable | |
| 11 | - 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 | 14 | - push tag matching `v*.*.*` for a stable release of an explicit commit |
| 13 | 15 | - scheduled nightly check every 30 minutes |
| 14 | 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 | 18 | - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. |
| 17 | 19 | Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep |
| 18 | 20 | landing while you verify and never leak into the stable build. |