authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 15:27:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 15:27:21-07:00
log001f06d543beb92cc66bb7e8d54d233a20d802b4
treed6535fc11dda341b2169a71432cb729a2cab7f5b
parent7ac93e300ee17a4ee5e92192264fcfd438805b6f
signature Signed by PGP key B5690EEEBB952194

feat(ci): ship stable releases from the latest nightly commit (#10410)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 147 insertions(+), 50 deletions(-)

.github/scripts/check-nightly-release.cjs+33-10
...@@ -1,19 +1,21 @@...@@ -1,19 +1,21 @@
1const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;1const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;
22
3// Runs after the workflow acquires the nightly concurrency lock.3const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v");
4async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {4
5// Newest published nightly by publication time, or undefined when none exists.
6async function findLatestNightly({ github, context }) {
5 const releases = await github.paginate(github.rest.repos.listReleases, {7 const releases = await github.paginate(github.rest.repos.listReleases, {
6 ...context.repo,8 ...context.repo,
7 per_page: 100,9 per_page: 100,
8 });10 });
9 const lastNightly = releases11 return releases
10 .filter(12 .filter((release) => !release.draft && release.published_at && isNightlyTag(release.tag_name))
11 (release) =>
12 !release.draft &&
13 release.published_at &&
14 (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")),
15 )
16 .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];13 .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
14}
15
16// Runs after the workflow acquires the nightly concurrency lock.
17async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
18 const lastNightly = await findLatestNightly({ github, context });
1719
18 if (!lastNightly) {20 if (!lastNightly) {
19 core.info("No published nightly found. Proceeding with release.");21 core.info("No published nightly found. Proceeding with release.");
...@@ -41,4 +43,25 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() })...@@ -41,4 +43,25 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() })
41 return true;43 return true;
42}44}
4345
44module.exports = { shouldReleaseNightly };46// Stable releases build the commit the latest nightly shipped, so the stable
47// build is one nightly users already ran. Returns the nightly tag, its commit,
48// and the stable version that nightly was a preview of.
49async function resolveLatestNightlyCommit({ github, context, core }) {
50 const lastNightly = await findLatestNightly({ github, context });
51 if (!lastNightly) {
52 throw new Error("No published nightly found. Stable releases build the latest nightly commit.");
53 }
54
55 const tag = lastNightly.tag_name;
56 // repos.getCommit dereferences annotated tags, so this is the commit either way.
57 const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag });
58 const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1];
59 if (!version) {
60 throw new Error(`Cannot derive a stable version from nightly tag ${tag}.`);
61 }
62
63 core.info(`Latest nightly ${tag} shipped ${commit.sha} as a preview of ${version}.`);
64 return { tag, sha: commit.sha, version };
65}
66
67module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit };
.github/scripts/check-nightly-release.test.cjs+42
...@@ -99,3 +99,45 @@ for (const status of ["behind", "diverged"]) {...@@ -99,3 +99,45 @@ for (const status of ["behind", "diverged"]) {
99 assert.equal(await shouldReleaseNightly(options), false);99 assert.equal(await shouldReleaseNightly(options), false);
100 });100 });
101}101}
102
103const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs");
104
105function nightlyCommitFixture({ releases, commitSha = "abc123" }) {
106 const refs = [];
107 const { options } = fixture({ releases });
108 options.github.rest.repos.getCommit = async ({ ref }) => {
109 refs.push(ref);
110 return { data: { sha: commitSha } };
111 };
112 return { options, refs };
113}
114
115test("stable releases resolve the commit of the newest published nightly", async () => {
116 const { options, refs } = nightlyCommitFixture({
117 releases: [
118 nightly(10, { tag_name: "v1.0.1-nightly.20260905.100" }),
119 nightly(1, { tag_name: "v1.0.1-nightly.20260905.123" }),
120 nightly(0, { tag_name: "v1.0.0" }),
121 nightly(0, { draft: true, tag_name: "v1.0.1-nightly.20260905.999" }),
122 ],
123 commitSha: "deadbeef",
124 });
125 assert.deepEqual(await resolveLatestNightlyCommit(options), {
126 tag: "v1.0.1-nightly.20260905.123",
127 sha: "deadbeef",
128 version: "1.0.1",
129 });
130 assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]);
131});
132
133test("stable releases derive the version from legacy nightly tags", async () => {
134 const { options } = nightlyCommitFixture({
135 releases: [nightly(1, { tag_name: "nightly-v0.9.0-nightly.20260905.5" })],
136 });
137 assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0");
138});
139
140test("stable releases fail without a published nightly", async () => {
141 const { options } = nightlyCommitFixture({ releases: [nightly(0, { tag_name: "v1.0.0" })] });
142 await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly/);
143});
.github/workflows/release.yml+51-30
...@@ -19,7 +19,7 @@ on:...@@ -19,7 +19,7 @@ on:
19 - stable19 - stable
20 - nightly20 - nightly
21 version:21 version:
22 description: "Release version (for example 1.2.3 or v1.2.3)"22 description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed."
23 required: false23 required: false
24 type: string24 type: string
2525
...@@ -39,33 +39,54 @@ permissions:...@@ -39,33 +39,54 @@ permissions:
39 id-token: none39 id-token: none
4040
41jobs:41jobs:
42 check_changes:42 # Picks the commit every later job builds. Nightlies and tag pushes build the
43 name: Check automatic nightly release43 # triggering commit. Manual stable releases build the commit of the latest
44 if: github.event_name == 'schedule'44 # published nightly, so stable only ever ships a build that nightly users
45 # have already run. Scheduled runs also decide here whether a nightly is due.
46 resolve_commit:
47 name: Resolve release commit
45 runs-on: blacksmith-8vcpu-ubuntu-240448 runs-on: blacksmith-8vcpu-ubuntu-2404
46 timeout-minutes: 549 timeout-minutes: 5
47 outputs:50 outputs:
48 has_changes: ${{ steps.check.outputs.result }}51 ref: ${{ steps.resolve.outputs.ref }}
52 nightly_version: ${{ steps.resolve.outputs.nightly_version }}
53 has_changes: ${{ steps.resolve.outputs.has_changes }}
49 steps:54 steps:
50 - name: Checkout55 - name: Checkout
51 uses: actions/checkout@v656 uses: actions/checkout@v6
52 with:57 with:
53 sparse-checkout: .github/scripts58 sparse-checkout: .github/scripts
5459
55 - id: check60 - id: resolve
56 name: Check release gap and new commits61 name: Resolve release commit
57 uses: actions/github-script@v862 uses: actions/github-script@v8
63 env:
64 DISPATCH_CHANNEL: ${{ inputs.channel }}
58 with:65 with:
59 script: |66 script: |
60 const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs');67 const {
61 return await shouldReleaseNightly({ github, context, core });68 shouldReleaseNightly,
69 resolveLatestNightlyCommit,
70 } = require('./.github/scripts/check-nightly-release.cjs');
71
72 if (context.eventName === 'schedule') {
73 core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core }));
74 core.setOutput('ref', context.sha);
75 } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') {
76 const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core });
77 core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`);
78 core.setOutput('ref', sha);
79 core.setOutput('nightly_version', version);
80 } else {
81 core.setOutput('ref', context.sha);
82 }
6283
63 preflight:84 preflight:
64 name: Preflight85 name: Preflight
65 needs: [check_changes]86 needs: [resolve_commit]
66 if: |87 if: |
67 !failure() && !cancelled() &&88 needs.resolve_commit.result == 'success' &&
68 (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')89 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
69 runs-on: blacksmith-8vcpu-ubuntu-240490 runs-on: blacksmith-8vcpu-ubuntu-2404
70 timeout-minutes: 1091 timeout-minutes: 10
71 outputs:92 outputs:
...@@ -78,11 +99,12 @@ jobs:...@@ -78,11 +99,12 @@ jobs:
78 cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}99 cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}
79 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}100 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
80 make_latest: ${{ steps.release_meta.outputs.make_latest }}101 make_latest: ${{ steps.release_meta.outputs.make_latest }}
81 ref: ${{ github.sha }}102 ref: ${{ needs.resolve_commit.outputs.ref }}
82 steps:103 steps:
83 - name: Checkout104 - name: Checkout
84 uses: actions/checkout@v6105 uses: actions/checkout@v6
85 with:106 with:
107 ref: ${{ needs.resolve_commit.outputs.ref }}
86 fetch-depth: 0108 fetch-depth: 0
87 sparse-checkout: |109 sparse-checkout: |
88 /*110 /*
...@@ -104,8 +126,9 @@ jobs:...@@ -104,8 +126,9 @@ jobs:
104 env:126 env:
105 DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}127 DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
106 DISPATCH_VERSION: ${{ github.event.inputs.version }}128 DISPATCH_VERSION: ${{ github.event.inputs.version }}
129 NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }}
107 NIGHTLY_DATE: ${{ github.run_started_at }}130 NIGHTLY_DATE: ${{ github.run_started_at }}
108 NIGHTLY_SHA: ${{ github.sha }}131 NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }}
109 NIGHTLY_RUN_NUMBER: ${{ github.run_number }}132 NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
110 run: |133 run: |
111 if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then134 if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then
...@@ -123,9 +146,9 @@ jobs:...@@ -123,9 +146,9 @@ jobs:
123 echo "make_latest=false" >> "$GITHUB_OUTPUT"146 echo "make_latest=false" >> "$GITHUB_OUTPUT"
124 else147 else
125 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then148 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
126 raw="${DISPATCH_VERSION}"149 raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}"
127 if [[ -z "$raw" ]]; then150 if [[ -z "$raw" ]]; then
128 echo "workflow_dispatch stable releases require the version input." >&2151 echo "workflow_dispatch stable releases need a version input or a published nightly." >&2
129 exit 1152 exit 1
130 fi153 fi
131 else154 else
...@@ -210,14 +233,12 @@ jobs:...@@ -210,14 +233,12 @@ jobs:
210233
211 relay_public_config:234 relay_public_config:
212 name: Resolve T3 Connect public config235 name: Resolve T3 Connect public config
213 # Consumes only the commit SHA, not preflight's resolved version, so it runs236 # Consumes only the release commit, not preflight's resolved version, so it
214 # alongside preflight instead of after it. The condition mirrors preflight's:237 # runs alongside preflight instead of after it. The condition mirrors preflight's.
215 # check_changes is skipped on manual and tag releases (skipped is neither failure238 needs: [resolve_commit]
216 # nor success, so success() would be wrong here).
217 needs: [check_changes]
218 if: |239 if: |
219 !failure() && !cancelled() &&240 needs.resolve_commit.result == 'success' &&
220 (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')241 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
221 runs-on: blacksmith-8vcpu-ubuntu-2404242 runs-on: blacksmith-8vcpu-ubuntu-2404
222 timeout-minutes: 5243 timeout-minutes: 5
223 environment:244 environment:
...@@ -239,7 +260,7 @@ jobs:...@@ -239,7 +260,7 @@ jobs:
239 - name: Checkout260 - name: Checkout
240 uses: actions/checkout@v6261 uses: actions/checkout@v6
241 with:262 with:
242 ref: ${{ github.sha }}263 ref: ${{ needs.resolve_commit.outputs.ref }}
243 sparse-checkout: |264 sparse-checkout: |
244 /*265 /*
245 !/.repos/266 !/.repos/
...@@ -312,19 +333,19 @@ jobs:...@@ -312,19 +333,19 @@ jobs:
312 # machine. node-pty is N-API, so one binary works across all WSL Node versions.333 # machine. node-pty is N-API, so one binary works across all WSL Node versions.
313 build_wsl_node_pty:334 build_wsl_node_pty:
314 name: Build WSL node-pty (linux-x64)335 name: Build WSL node-pty (linux-x64)
315 # Same gating as relay_public_config: only the commit SHA is needed, so this336 # Same gating as relay_public_config: only the release commit is needed, so
316 # runs alongside preflight. See the condition comment there.337 # this runs alongside preflight. See the condition comment there.
317 needs: [check_changes]338 needs: [resolve_commit]
318 if: |339 if: |
319 !failure() && !cancelled() &&340 needs.resolve_commit.result == 'success' &&
320 (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')341 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
321 runs-on: blacksmith-8vcpu-ubuntu-2404342 runs-on: blacksmith-8vcpu-ubuntu-2404
322 timeout-minutes: 15343 timeout-minutes: 15
323 steps:344 steps:
324 - name: Checkout345 - name: Checkout
325 uses: actions/checkout@v6346 uses: actions/checkout@v6
326 with:347 with:
327 ref: ${{ github.sha }}348 ref: ${{ needs.resolve_commit.outputs.ref }}
328 sparse-checkout: |349 sparse-checkout: |
329 /*350 /*
330 !/.repos/351 !/.repos/
docs/operations/release.md+21-10
...@@ -8,9 +8,18 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -8,9 +8,18 @@ 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 - push tag matching `v*.*.*` for stable releases11 - manual `workflow_dispatch` with `channel=stable`, the normal way to ship stable
12 - push tag matching `v*.*.*` for a stable release of an explicit commit
12 - scheduled nightly check every 30 minutes13 - scheduled nightly check every 30 minutes
13 - manual `workflow_dispatch` for either channel14 - manual `workflow_dispatch` with `channel=nightly`
15- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.
16 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep
17 landing while you verify and never leak into the stable build.
18 - The version defaults to the one the nightly previewed (`0.0.39-nightly.*` ships as `0.0.39`).
19 Pass the `version` input to override it, for example for a minor bump.
20 - The stable tag is created on the nightly's commit when the GitHub Release is published.
21 - Pushing a `vX.Y.Z` tag by hand still works and builds exactly the tagged commit. Use it when
22 the commit to ship is not the latest nightly, such as a cherry-picked fix on a release branch.
14- Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check.23- Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check.
15- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients.24- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients.
16- Builds four artifacts in parallel for both channels:25- Builds four artifacts in parallel for both channels:
...@@ -291,8 +300,8 @@ risk, manually dispatch `channel=nightly`; this still publishes a real nightly n...@@ -291,8 +300,8 @@ risk, manually dispatch `channel=nightly`; this still publishes a real nightly n
291prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or300prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or
292commit a version bump to `main`. Only run it when a real nightly release is acceptable.301commit a version bump to `main`. Only run it when a real nightly release is acceptable.
293302
294Manual `channel=stable` with a version input is also a real stable-channel release. Omitting signing303Manual `channel=stable` is also a real stable-channel release. Omitting signing secrets only makes
295secrets only makes platform artifacts unsigned; it does not prevent publication.304platform artifacts unsigned; it does not prevent publication.
296305
297## 2) Apple signing + notarization setup (macOS)306## 2) Apple signing + notarization setup (macOS)
298307
...@@ -370,17 +379,19 @@ Checklist:...@@ -370,17 +379,19 @@ Checklist:
370379
371## 4) Ongoing release checklist380## 4) Ongoing release checklist
372381
3731. Ensure `main` is green in CI.3821. Pick the latest nightly and verify it: run the smoke test above against its artifacts and
3742. Bump app version as needed.383 check the nightly channel for regressions.
3753. Create release tag: `vX.Y.Z`.3842. Dispatch the Release workflow with `channel=stable`. Leave `version` empty unless the version
3764. Push tag.385 should differ from the one the nightly previewed.
3775. Verify workflow steps:3863. Confirm the `Resolve release commit` notice names the nightly tag and commit you verified. If a
387 newer nightly published in between, the run builds that one instead.
3884. Verify workflow steps:
378 - preflight passes389 - preflight passes
379 - release quality checks pass390 - release quality checks pass
380 - all matrix builds pass391 - all matrix builds pass
381 - `publish_cli` publishes the exact release version before the release job392 - `publish_cli` publishes the exact release version before the release job
382 - release job uploads expected files393 - release job uploads expected files
3836. Smoke test downloaded artifacts.3945. Smoke test downloaded artifacts.
384395
385## 5) Troubleshooting396## 5) Troubleshooting
386397