authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 01:09:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 01:09:09-07:00
log7544d3d2c8e0145018d9adb7a1a650333b75362a
tree6c10301771acf6461ce57a3acd154fade1529124
parentb155c2199bdcb4fdf1920da02a223536f8c2d729
signature Signed by PGP key B5690EEEBB952194

fix(release): space automatic nightlies at least six hours apart (#10272)


5 files changed, 166 insertions(+), 32 deletions(-)

.github/scripts/check-nightly-release.cjs created+44
......@@ -0,0 +1,44 @@
1const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;
2
3// Runs after the workflow acquires the nightly concurrency lock.
4async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
5 const releases = await github.paginate(github.rest.repos.listReleases, {
6 ...context.repo,
7 per_page: 100,
8 });
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 )
16 .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
17
18 if (!lastNightly) {
19 core.info("No published nightly found. Proceeding with release.");
20 return true;
21 }
22
23 if (now - Date.parse(lastNightly.published_at) < MINIMUM_RELEASE_GAP_MS) {
24 core.info(`Nightly ${lastNightly.tag_name} was published less than six hours ago. Skipping.`);
25 return false;
26 }
27
28 const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
29 ...context.repo,
30 basehead: `${lastNightly.tag_name}...${context.sha}`,
31 per_page: 1,
32 });
33 if (comparison.status !== "ahead") {
34 core.info(
35 `Candidate commit is ${comparison.status} relative to ${lastNightly.tag_name}. Skipping.`,
36 );
37 return false;
38 }
39
40 core.info(`New commits since ${lastNightly.tag_name}, and the six-hour gap has passed.`);
41 return true;
42}
43
44module.exports = { shouldReleaseNightly };
.github/scripts/check-nightly-release.test.cjs created+101
......@@ -0,0 +1,101 @@
1const assert = require("node:assert/strict");
2const test = require("node:test");
3const { shouldReleaseNightly } = require("./check-nightly-release.cjs");
4
5const now = Date.parse("2026-09-05T12:00:00Z");
6const hour = 60 * 60 * 1000;
7const nightly = (hoursAgo, overrides = {}) => ({
8 tag_name: "v1.0.1-nightly.20260905.123",
9 draft: false,
10 published_at: new Date(now - hoursAgo * hour).toISOString(),
11 ...overrides,
12});
13
14function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) {
15 const calls = [];
16 return {
17 calls,
18 options: {
19 now,
20 context: { repo: { owner: "example", repo: "app" }, sha: "new" },
21 core: { info() {} },
22 github: {
23 rest: {
24 repos: {
25 listReleases() {},
26 async compareCommitsWithBasehead(params) {
27 calls.push(params);
28 return { data: { status: comparisonStatus } };
29 },
30 },
31 },
32 async paginate() {
33 return releases;
34 },
35 },
36 },
37 };
38}
39
40test("releases the first nightly when no nightly is published", async () => {
41 const { options } = fixture({
42 releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })],
43 });
44 assert.equal(await shouldReleaseNightly(options), true);
45});
46
47test("waits six hours after publication, including manual nightlies", async () => {
48 for (const age of [0, 3, 6 - 1 / 3600]) {
49 const { options, calls } = fixture({ releases: [nightly(age)] });
50 assert.equal(await shouldReleaseNightly(options), false);
51 assert.equal(calls.length, 0);
52 }
53});
54
55test("releases new commits at six hours and after an idle period", async () => {
56 for (const age of [6, 7, 24]) {
57 const { options } = fixture({ releases: [nightly(age)] });
58 assert.equal(await shouldReleaseNightly(options), true);
59 }
60});
61
62test("skips unchanged commits after the gap", async () => {
63 const { options } = fixture({ comparisonStatus: "identical" });
64 assert.equal(await shouldReleaseNightly(options), false);
65});
66
67test("uses publication time, not release order or the tagged commit date", async () => {
68 const { options } = fixture({
69 releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })],
70 });
71 assert.equal(await shouldReleaseNightly(options), false);
72});
73
74test("ignores stable releases and drafts when checking the gap", async () => {
75 const { options } = fixture({
76 releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)],
77 });
78 assert.equal(await shouldReleaseNightly(options), true);
79});
80
81test("compares against the published tag, including legacy nightly tags", async () => {
82 const tag = "nightly-v0.9.0";
83 const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] });
84 assert.equal(await shouldReleaseNightly(options), true);
85 assert.equal(calls[0].basehead, `${tag}...new`);
86});
87
88test("fails instead of releasing when GitHub cannot supply release state", async () => {
89 const { options } = fixture();
90 options.github.paginate = async () => {
91 throw new Error("GitHub unavailable");
92 };
93 await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/);
94});
95
96for (const status of ["behind", "diverged"]) {
97 test(`skips a candidate commit that is ${status} relative to the last nightly`, async () => {
98 const { options } = fixture({ comparisonStatus: status });
99 assert.equal(await shouldReleaseNightly(options), false);
100 });
101}
.github/workflows/ci.yml+3
......@@ -103,6 +103,9 @@ jobs:
103103 sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
104104 sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
105105
106 - name: Test nightly release checks
107 run: node --test .github/scripts/check-nightly-release.test.cjs
108
106109 - name: Test
107110 run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test
108111
.github/workflows/release.yml+14-30
......@@ -6,8 +6,8 @@ on:
66 - "v*.*.*"
77 - "!v*-nightly.*"
88 schedule:
9 # Off minute zero: GitHub delays scheduled runs most at the top of the hour.
10 - cron: "38 */3 * * *"
9 # Avoid minute zero, when GitHub scheduled jobs are busiest.
10 - cron: "8,38 * * * *"
1111 workflow_dispatch:
1212 inputs:
1313 channel:
......@@ -28,7 +28,7 @@ on:
2828# own group so a nightly never blocks them. Running publishers are never
2929# canceled, and queue: max keeps every pending run instead of the default
3030# newest-wins single slot, so a queued stable tag can never be silently
31# dropped. Queued nightlies with no new commits skip via check_changes.
31# dropped. Automatic nightlies recheck the release gap after leaving the queue.
3232concurrency:
3333 group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }}
3434 cancel-in-progress: false
......@@ -40,41 +40,25 @@ permissions:
4040
4141jobs:
4242 check_changes:
43 name: Check for changes since last nightly
43 name: Check automatic nightly release
4444 if: github.event_name == 'schedule'
4545 runs-on: blacksmith-8vcpu-ubuntu-2404
46 timeout-minutes: 5
4647 outputs:
47 has_changes: ${{ steps.check.outputs.has_changes }}
48 has_changes: ${{ steps.check.outputs.result }}
4849 steps:
4950 - name: Checkout
5051 uses: actions/checkout@v6
5152 with:
52 fetch-depth: 0
53 sparse-checkout: |
54 /*
55 !/.repos/
56 sparse-checkout-cone-mode: false
53 sparse-checkout: .github/scripts
5754
5855 - id: check
59 name: Compare HEAD to last nightly tag
60 run: |
61 last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1)
62 if [[ -z "$last_nightly_tag" ]]; then
63 echo "No previous nightly tag found. Proceeding with release."
64 echo "has_changes=true" >> "$GITHUB_OUTPUT"
65 exit 0
66 fi
67
68 last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}")
69 head_sha=$(git rev-parse HEAD)
70
71 if [[ "$last_nightly_sha" == "$head_sha" ]]; then
72 echo "No changes on main since last nightly release ($last_nightly_tag). Skipping."
73 echo "has_changes=false" >> "$GITHUB_OUTPUT"
74 else
75 echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding."
76 echo "has_changes=true" >> "$GITHUB_OUTPUT"
77 fi
56 name: Check release gap and new commits
57 uses: actions/github-script@v8
58 with:
59 script: |
60 const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs');
61 return await shouldReleaseNightly({ github, context, core });
7862
7963 preflight:
8064 name: Preflight
......@@ -228,7 +212,7 @@ jobs:
228212 name: Resolve T3 Connect public config
229213 # Consumes only the commit SHA, not preflight's resolved version, so it runs
230214 # alongside preflight instead of after it. The condition mirrors preflight's:
231 # check_changes is skipped on non-schedule events (skipped is neither failure
215 # check_changes is skipped on manual and tag releases (skipped is neither failure
232216 # nor success, so success() would be wrong here).
233217 needs: [check_changes]
234218 if: |
docs/operations/release.md+4-2
......@@ -9,7 +9,7 @@ This document covers the unified release workflow for stable and nightly desktop
99- Workflow: `.github/workflows/release.yml`
1010- Triggers:
1111 - push tag matching `v*.*.*` for stable releases
12 - scheduled nightly check every three hours
12 - scheduled nightly check every 30 minutes
1313 - manual `workflow_dispatch` for either channel
1414- Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check.
1515- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients.
......@@ -158,8 +158,10 @@ One-time Vercel dashboard setup:
158158
159159- Workflow: `.github/workflows/release.yml`
160160- Triggers:
161 - scheduled check every three hours
161 - scheduled check every 30 minutes
162162 - manual `workflow_dispatch` with `channel=nightly`
163- Automatic nightlies require new commits and at least six hours since the last nightly was published, including manual nightlies.
164- Manual nightlies bypass the time and change checks. Nightly runs remain serialized. Scheduled runs wait for an active nightly to finish, then check the publication gap before building.
163165- Runs the same desktop quality gates and artifact matrix as the tagged release flow.
164166- Publishes a GitHub prerelease only:
165167 - current tag format: `vX.Y.Z-nightly.YYYYMMDD.<run_number>`