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 @@
11const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;
22
3// Runs after the workflow acquires the nightly concurrency lock.
4async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
3const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v");
4
5// Newest published nightly by publication time, or undefined when none exists.
6async function findLatestNightly({ github, context }) {
57 const releases = await github.paginate(github.rest.repos.listReleases, {
68 ...context.repo,
79 per_page: 100,
810 });
9 const lastNightly = releases
10 .filter(
11 (release) =>
12 !release.draft &&
13 release.published_at &&
14 (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")),
15 )
11 return releases
12 .filter((release) => !release.draft && release.published_at && isNightlyTag(release.tag_name))
1613 .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
1820 if (!lastNightly) {
1921 core.info("No published nightly found. Proceeding with release.");
......@@ -41,4 +43,25 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() })
4143 return true;
4244}
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"]) {
9999 assert.equal(await shouldReleaseNightly(options), false);
100100 });
101101}
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:
1919 - stable
2020 - nightly
2121 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."
2323 required: false
2424 type: string
2525
......@@ -39,33 +39,54 @@ permissions:
3939 id-token: none
4040
4141jobs:
42 check_changes:
43 name: Check automatic nightly release
44 if: github.event_name == 'schedule'
42 # Picks the commit every later job builds. Nightlies and tag pushes build the
43 # triggering commit. Manual stable releases build the commit of the latest
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
4548 runs-on: blacksmith-8vcpu-ubuntu-2404
4649 timeout-minutes: 5
4750 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 }}
4954 steps:
5055 - name: Checkout
5156 uses: actions/checkout@v6
5257 with:
5358 sparse-checkout: .github/scripts
5459
55 - id: check
56 name: Check release gap and new commits
60 - id: resolve
61 name: Resolve release commit
5762 uses: actions/github-script@v8
63 env:
64 DISPATCH_CHANNEL: ${{ inputs.channel }}
5865 with:
5966 script: |
60 const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs');
61 return await shouldReleaseNightly({ github, context, core });
67 const {
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
6384 preflight:
6485 name: Preflight
65 needs: [check_changes]
86 needs: [resolve_commit]
6687 if: |
67 !failure() && !cancelled() &&
68 (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
88 needs.resolve_commit.result == 'success' &&
89 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
6990 runs-on: blacksmith-8vcpu-ubuntu-2404
7091 timeout-minutes: 10
7192 outputs:
......@@ -78,11 +99,12 @@ jobs:
7899 cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}
79100 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
80101 make_latest: ${{ steps.release_meta.outputs.make_latest }}
81 ref: ${{ github.sha }}
102 ref: ${{ needs.resolve_commit.outputs.ref }}
82103 steps:
83104 - name: Checkout
84105 uses: actions/checkout@v6
85106 with:
107 ref: ${{ needs.resolve_commit.outputs.ref }}
86108 fetch-depth: 0
87109 sparse-checkout: |
88110 /*
......@@ -104,8 +126,9 @@ jobs:
104126 env:
105127 DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
106128 DISPATCH_VERSION: ${{ github.event.inputs.version }}
129 NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }}
107130 NIGHTLY_DATE: ${{ github.run_started_at }}
108 NIGHTLY_SHA: ${{ github.sha }}
131 NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }}
109132 NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
110133 run: |
111134 if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then
......@@ -123,9 +146,9 @@ jobs:
123146 echo "make_latest=false" >> "$GITHUB_OUTPUT"
124147 else
125148 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
126 raw="${DISPATCH_VERSION}"
149 raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}"
127150 if [[ -z "$raw" ]]; then
128 echo "workflow_dispatch stable releases require the version input." >&2
151 echo "workflow_dispatch stable releases need a version input or a published nightly." >&2
129152 exit 1
130153 fi
131154 else
......@@ -210,14 +233,12 @@ jobs:
210233
211234 relay_public_config:
212235 name: Resolve T3 Connect public config
213 # Consumes only the commit SHA, not preflight's resolved version, so it runs
214 # alongside preflight instead of after it. The condition mirrors preflight's:
215 # check_changes is skipped on manual and tag releases (skipped is neither failure
216 # nor success, so success() would be wrong here).
217 needs: [check_changes]
236 # Consumes only the release commit, not preflight's resolved version, so it
237 # runs alongside preflight instead of after it. The condition mirrors preflight's.
238 needs: [resolve_commit]
218239 if: |
219 !failure() && !cancelled() &&
220 (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
240 needs.resolve_commit.result == 'success' &&
241 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
221242 runs-on: blacksmith-8vcpu-ubuntu-2404
222243 timeout-minutes: 5
223244 environment:
......@@ -239,7 +260,7 @@ jobs:
239260 - name: Checkout
240261 uses: actions/checkout@v6
241262 with:
242 ref: ${{ github.sha }}
263 ref: ${{ needs.resolve_commit.outputs.ref }}
243264 sparse-checkout: |
244265 /*
245266 !/.repos/
......@@ -312,19 +333,19 @@ jobs:
312333 # machine. node-pty is N-API, so one binary works across all WSL Node versions.
313334 build_wsl_node_pty:
314335 name: Build WSL node-pty (linux-x64)
315 # Same gating as relay_public_config: only the commit SHA is needed, so this
316 # runs alongside preflight. See the condition comment there.
317 needs: [check_changes]
336 # Same gating as relay_public_config: only the release commit is needed, so
337 # this runs alongside preflight. See the condition comment there.
338 needs: [resolve_commit]
318339 if: |
319 !failure() && !cancelled() &&
320 (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
340 needs.resolve_commit.result == 'success' &&
341 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
321342 runs-on: blacksmith-8vcpu-ubuntu-2404
322343 timeout-minutes: 15
323344 steps:
324345 - name: Checkout
325346 uses: actions/checkout@v6
326347 with:
327 ref: ${{ github.sha }}
348 ref: ${{ needs.resolve_commit.outputs.ref }}
328349 sparse-checkout: |
329350 /*
330351 !/.repos/
docs/operations/release.md+21-10
......@@ -8,9 +8,18 @@ This document covers the unified release workflow for stable and nightly desktop
88
99- Workflow: `.github/workflows/release.yml`
1010- Triggers:
11 - push tag matching `v*.*.*` for stable releases
11 - 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
1213 - scheduled nightly check every 30 minutes
13 - manual `workflow_dispatch` for either channel
14 - 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.
1423- Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check.
1524- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients.
1625- Builds four artifacts in parallel for both channels:
......@@ -291,8 +300,8 @@ risk, manually dispatch `channel=nightly`; this still publishes a real nightly n
291300prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or
292301commit 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 signing
295secrets only makes platform artifacts unsigned; it does not prevent publication.
303Manual `channel=stable` is also a real stable-channel release. Omitting signing secrets only makes
304platform artifacts unsigned; it does not prevent publication.
296305
297306## 2) Apple signing + notarization setup (macOS)
298307
......@@ -370,17 +379,19 @@ Checklist:
370379
371380## 4) Ongoing release checklist
372381
3731. Ensure `main` is green in CI.
3742. Bump app version as needed.
3753. Create release tag: `vX.Y.Z`.
3764. Push tag.
3775. Verify workflow steps:
3821. Pick the latest nightly and verify it: run the smoke test above against its artifacts and
383 check the nightly channel for regressions.
3842. Dispatch the Release workflow with `channel=stable`. Leave `version` empty unless the version
385 should differ from the one the nightly previewed.
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:
378389 - preflight passes
379390 - release quality checks pass
380391 - all matrix builds pass
381392 - `publish_cli` publishes the exact release version before the release job
382393 - release job uploads expected files
3836. Smoke test downloaded artifacts.
3945. Smoke test downloaded artifacts.
384395
385396## 5) Troubleshooting
386397