From 22023ff5bacbd78b72c0685252251e2c0a8a99b6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 21:21:38 -0700 Subject: [PATCH 1/3] fix(release): space automatic nightlies at least six hours apart --- .github/scripts/check-nightly-release.cjs | 63 +++++++++ .../scripts/check-nightly-release.test.cjs | 120 ++++++++++++++++++ .github/workflows/ci.yml | 3 + .github/workflows/nightly-schedule.yml | 38 ++++++ .github/workflows/release.yml | 60 ++++----- docs/operations/release.md | 6 +- 6 files changed, 251 insertions(+), 39 deletions(-) create mode 100644 .github/scripts/check-nightly-release.cjs create mode 100644 .github/scripts/check-nightly-release.test.cjs create mode 100644 .github/workflows/nightly-schedule.yml diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs new file mode 100644 index 000000000000..d72a04625e61 --- /dev/null +++ b/.github/scripts/check-nightly-release.cjs @@ -0,0 +1,63 @@ +const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; + +// The scheduler checks active runs before dispatch. The release workflow checks +// the gap again under its nightly concurrency lock, in case a manual run won it. +async function shouldReleaseNightly({ + github, + context, + core, + checkActiveRuns = false, + now = Date.now(), +}) { + if (checkActiveRuns) { + for (const status of ["in_progress", "queued", "waiting", "pending", "requested"]) { + const { data } = await github.rest.actions.listWorkflowRuns({ + ...context.repo, + workflow_id: "release.yml", + status, + per_page: 1, + }); + if (data.total_count > 0) { + core.info(`A release is ${status}. Skipping this check.`); + return false; + } + } + } + + const releases = await github.paginate(github.rest.repos.listReleases, { + ...context.repo, + per_page: 100, + }); + const lastNightly = releases + .filter( + (release) => + !release.draft && + release.published_at && + (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")), + ) + .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; + + if (!lastNightly) { + core.info("No published nightly found. Proceeding with release."); + return true; + } + + if (now - Date.parse(lastNightly.published_at) < MINIMUM_RELEASE_GAP_MS) { + core.info(`Nightly ${lastNightly.tag_name} was published less than six hours ago. Skipping.`); + return false; + } + + const { data: commit } = await github.rest.repos.getCommit({ + ...context.repo, + ref: lastNightly.tag_name, + }); + if (commit.sha === context.sha) { + core.info(`No changes since ${lastNightly.tag_name}. Skipping.`); + return false; + } + + core.info(`New commits since ${lastNightly.tag_name}, and the six-hour gap has passed.`); + return true; +} + +module.exports = { shouldReleaseNightly }; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs new file mode 100644 index 000000000000..fab1a9f0ca7f --- /dev/null +++ b/.github/scripts/check-nightly-release.test.cjs @@ -0,0 +1,120 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { shouldReleaseNightly } = require("./check-nightly-release.cjs"); + +const now = Date.parse("2026-09-05T12:00:00Z"); +const hour = 60 * 60 * 1000; +const nightly = (hoursAgo, overrides = {}) => ({ + tag_name: "v1.0.1-nightly.20260905.123", + draft: false, + published_at: new Date(now - hoursAgo * hour).toISOString(), + ...overrides, +}); + +function fixture({ releases = [nightly(7)], releasedSha = "old", activeStatus } = {}) { + const calls = []; + return { + calls, + options: { + now, + context: { repo: { owner: "example", repo: "app" }, sha: "new" }, + core: { info() {} }, + github: { + rest: { + actions: { + async listWorkflowRuns(params) { + calls.push(params); + return { data: { total_count: params.status === activeStatus ? 1 : 0 } }; + }, + }, + repos: { + listReleases() {}, + async getCommit(params) { + calls.push(params); + return { data: { sha: releasedSha } }; + }, + }, + }, + async paginate() { + return releases; + }, + }, + }, + }; +} + +test("releases the first nightly when no nightly is published", async () => { + const { options } = fixture({ + releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })], + }); + assert.equal(await shouldReleaseNightly(options), true); +}); + +test("waits six hours after publication, including manual nightlies", async () => { + for (const age of [0, 3, 6 - 1 / 3600]) { + const { options, calls } = fixture({ releases: [nightly(age)] }); + assert.equal(await shouldReleaseNightly(options), false); + assert.equal(calls.length, 0); + } +}); + +test("releases new commits at six hours and after an idle period", async () => { + for (const age of [6, 7, 24]) { + const { options } = fixture({ releases: [nightly(age)] }); + assert.equal(await shouldReleaseNightly(options), true); + } +}); + +test("skips unchanged commits after the gap", async () => { + const { options } = fixture({ releasedSha: "new" }); + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("uses publication time, not release order or the tagged commit date", async () => { + const { options } = fixture({ + releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })], + }); + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("ignores stable releases and drafts when checking the gap", async () => { + const { options } = fixture({ + releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)], + }); + assert.equal(await shouldReleaseNightly(options), true); +}); + +test("resolves the published tag to a commit, including legacy nightly tags", async () => { + const tag = "nightly-v0.9.0"; + const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] }); + assert.equal(await shouldReleaseNightly(options), true); + assert.equal(calls[0].ref, tag); +}); + +for (const status of ["in_progress", "queued", "waiting", "pending", "requested"]) { + test(`skips dispatch when a release is ${status}`, async () => { + const { options } = fixture({ activeStatus: status }); + options.github.paginate = async () => assert.fail("Must stop before checking releases"); + assert.equal(await shouldReleaseNightly({ ...options, checkActiveRuns: true }), false); + }); +} + +test("dispatches when no release is active and new commits are due", async () => { + const { options } = fixture(); + assert.equal(await shouldReleaseNightly({ ...options, checkActiveRuns: true }), true); +}); + +test("rechecks publication after a manual run finishes ahead of an automatic run", async () => { + const { options } = fixture(); + assert.equal(await shouldReleaseNightly({ ...options, checkActiveRuns: true }), true); + options.github.paginate = async () => [nightly(0)]; + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("fails instead of dispatching when GitHub cannot supply release state", async () => { + const { options } = fixture(); + options.github.paginate = async () => { + throw new Error("GitHub unavailable"); + }; + await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bcb1e21ab99..e23dbd60d961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,9 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test nightly release checks + run: node --test .github/scripts/check-nightly-release.test.cjs + - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/.github/workflows/nightly-schedule.yml b/.github/workflows/nightly-schedule.yml new file mode 100644 index 000000000000..9d606ab719a9 --- /dev/null +++ b/.github/workflows/nightly-schedule.yml @@ -0,0 +1,38 @@ +name: Check nightly release + +on: + schedule: + # Avoid minute zero, when GitHub scheduled jobs are busiest. + - cron: "8,38 * * * *" + +concurrency: + group: nightly-schedule + cancel-in-progress: false + +permissions: + contents: read + actions: write + +jobs: + check: + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: .github/scripts + + - name: Dispatch nightly when ready + uses: actions/github-script@v8 + with: + script: | + const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs'); + if (await shouldReleaseNightly({ github, context, core, checkActiveRuns: true })) { + await github.rest.actions.createWorkflowDispatch({ + ...context.repo, + workflow_id: 'release.yml', + ref: context.ref, + inputs: { channel: 'nightly', automatic: 'true' }, + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2785ecb8fa78..595eec855801 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,9 +5,6 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" - schedule: - # Off minute zero: GitHub delays scheduled runs most at the top of the hour. - - cron: "38 */3 * * *" workflow_dispatch: inputs: channel: @@ -18,6 +15,11 @@ on: options: - stable - nightly + automatic: + description: "Apply scheduled nightly checks before releasing" + required: false + default: false + type: boolean version: description: "Release version (for example 1.2.3 or v1.2.3)" required: false @@ -28,9 +30,9 @@ on: # own group so a nightly never blocks them. Running publishers are never # canceled, and queue: max keeps every pending run instead of the default # newest-wins single slot, so a queued stable tag can never be silently -# dropped. Queued nightlies with no new commits skip via check_changes. +# dropped. Automatic nightlies recheck the release gap after leaving the queue. concurrency: - group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} + group: release-${{ (inputs.channel == 'nightly') && 'nightly' || 'stable' }} cancel-in-progress: false queue: max @@ -40,48 +42,32 @@ permissions: jobs: check_changes: - name: Check for changes since last nightly - if: github.event_name == 'schedule' + name: Check automatic nightly release + if: inputs.channel == 'nightly' && inputs.automatic runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 5 outputs: - has_changes: ${{ steps.check.outputs.has_changes }} + has_changes: ${{ steps.check.outputs.result }} steps: - name: Checkout uses: actions/checkout@v6 with: - fetch-depth: 0 - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + sparse-checkout: .github/scripts - id: check - name: Compare HEAD to last nightly tag - run: | - last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) - if [[ -z "$last_nightly_tag" ]]; then - echo "No previous nightly tag found. Proceeding with release." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") - head_sha=$(git rev-parse HEAD) - - if [[ "$last_nightly_sha" == "$head_sha" ]]; then - echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi + name: Check release gap and new commits + uses: actions/github-script@v8 + with: + script: | + const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs'); + return await shouldReleaseNightly({ github, context, core }); preflight: name: Preflight needs: [check_changes] if: | !failure() && !cancelled() && - (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') + (!(inputs.channel == 'nightly' && inputs.automatic) || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: @@ -124,7 +110,7 @@ jobs: NIGHTLY_SHA: ${{ github.sha }} 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}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ]]; then nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ @@ -228,12 +214,12 @@ jobs: name: Resolve T3 Connect public config # Consumes only the commit SHA, not preflight's resolved version, so it runs # alongside preflight instead of after it. The condition mirrors preflight's: - # check_changes is skipped on non-schedule events (skipped is neither failure + # check_changes is skipped on manual and tag releases (skipped is neither failure # nor success, so success() would be wrong here). needs: [check_changes] if: | !failure() && !cancelled() && - (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') + (!(inputs.channel == 'nightly' && inputs.automatic) || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 environment: @@ -333,7 +319,7 @@ jobs: needs: [check_changes] if: | !failure() && !cancelled() && - (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') + (!(inputs.channel == 'nightly' && inputs.automatic) || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 steps: diff --git a/docs/operations/release.md b/docs/operations/release.md index 02f453ab36a5..da469b93d7a9 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -9,7 +9,7 @@ This document covers the unified release workflow for stable and nightly desktop - Workflow: `.github/workflows/release.yml` - Triggers: - push tag matching `v*.*.*` for stable releases - - scheduled nightly check every three hours + - scheduled nightly check every 30 minutes via `.github/workflows/nightly-schedule.yml` - manual `workflow_dispatch` for either channel - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. @@ -158,8 +158,10 @@ One-time Vercel dashboard setup: - Workflow: `.github/workflows/release.yml` - Triggers: - - scheduled check every three hours + - scheduled check every 30 minutes via `.github/workflows/nightly-schedule.yml` - manual `workflow_dispatch` with `channel=nightly` +- Automatic nightlies require new commits and at least six hours since the last nightly was published, including manual nightlies. The scheduled check skips if a release is active or queued. +- Manual nightlies bypass the time and change checks. Nightly runs remain serialized, and automatic runs recheck after leaving the queue. - Runs the same desktop quality gates and artifact matrix as the tagged release flow. - Publishes a GitHub prerelease only: - current tag format: `vX.Y.Z-nightly.YYYYMMDD.` From 3e57be35326de2eebe5c51a47c1001789ac928aa Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 21:24:58 -0700 Subject: [PATCH 2/3] fix(release): skip stale automatic nightly commits --- .github/scripts/check-nightly-release.cjs | 11 +++++++---- .../scripts/check-nightly-release.test.cjs | 19 +++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index d72a04625e61..570be4cd4091 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -47,12 +47,15 @@ async function shouldReleaseNightly({ return false; } - const { data: commit } = await github.rest.repos.getCommit({ + const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ ...context.repo, - ref: lastNightly.tag_name, + basehead: `${lastNightly.tag_name}...${context.sha}`, + per_page: 1, }); - if (commit.sha === context.sha) { - core.info(`No changes since ${lastNightly.tag_name}. Skipping.`); + if (comparison.status !== "ahead") { + core.info( + `Candidate commit is ${comparison.status} relative to ${lastNightly.tag_name}. Skipping.`, + ); return false; } diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index fab1a9f0ca7f..df0f4b1625eb 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -11,7 +11,7 @@ const nightly = (hoursAgo, overrides = {}) => ({ ...overrides, }); -function fixture({ releases = [nightly(7)], releasedSha = "old", activeStatus } = {}) { +function fixture({ releases = [nightly(7)], comparisonStatus = "ahead", activeStatus } = {}) { const calls = []; return { calls, @@ -29,9 +29,9 @@ function fixture({ releases = [nightly(7)], releasedSha = "old", activeStatus } }, repos: { listReleases() {}, - async getCommit(params) { + async compareCommitsWithBasehead(params) { calls.push(params); - return { data: { sha: releasedSha } }; + return { data: { status: comparisonStatus } }; }, }, }, @@ -66,7 +66,7 @@ test("releases new commits at six hours and after an idle period", async () => { }); test("skips unchanged commits after the gap", async () => { - const { options } = fixture({ releasedSha: "new" }); + const { options } = fixture({ comparisonStatus: "identical" }); assert.equal(await shouldReleaseNightly(options), false); }); @@ -84,11 +84,11 @@ test("ignores stable releases and drafts when checking the gap", async () => { assert.equal(await shouldReleaseNightly(options), true); }); -test("resolves the published tag to a commit, including legacy nightly tags", async () => { +test("compares against the published tag, including legacy nightly tags", async () => { const tag = "nightly-v0.9.0"; const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] }); assert.equal(await shouldReleaseNightly(options), true); - assert.equal(calls[0].ref, tag); + assert.equal(calls[0].basehead, `${tag}...new`); }); for (const status of ["in_progress", "queued", "waiting", "pending", "requested"]) { @@ -118,3 +118,10 @@ test("fails instead of dispatching when GitHub cannot supply release state", asy }; await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/); }); + +for (const status of ["behind", "diverged"]) { + test(`skips a candidate commit that is ${status} relative to the last nightly`, async () => { + const { options } = fixture({ comparisonStatus: status }); + assert.equal(await shouldReleaseNightly(options), false); + }); +} From 5a888017f97b09610f09565e0ab2ba9fd66d295a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 00:49:37 -0700 Subject: [PATCH 3/3] refactor(release): keep nightly scheduling in the release workflow --- .github/scripts/check-nightly-release.cjs | 26 +------------ .../scripts/check-nightly-release.test.cjs | 30 +-------------- .github/workflows/nightly-schedule.yml | 38 ------------------- .github/workflows/release.yml | 20 +++++----- docs/operations/release.md | 8 ++-- 5 files changed, 17 insertions(+), 105 deletions(-) delete mode 100644 .github/workflows/nightly-schedule.yml diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 570be4cd4091..dc4b55bc6517 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -1,29 +1,7 @@ const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; -// The scheduler checks active runs before dispatch. The release workflow checks -// the gap again under its nightly concurrency lock, in case a manual run won it. -async function shouldReleaseNightly({ - github, - context, - core, - checkActiveRuns = false, - now = Date.now(), -}) { - if (checkActiveRuns) { - for (const status of ["in_progress", "queued", "waiting", "pending", "requested"]) { - const { data } = await github.rest.actions.listWorkflowRuns({ - ...context.repo, - workflow_id: "release.yml", - status, - per_page: 1, - }); - if (data.total_count > 0) { - core.info(`A release is ${status}. Skipping this check.`); - return false; - } - } - } - +// Runs after the workflow acquires the nightly concurrency lock. +async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { const releases = await github.paginate(github.rest.repos.listReleases, { ...context.repo, per_page: 100, diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index df0f4b1625eb..476773bc4e5a 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -11,7 +11,7 @@ const nightly = (hoursAgo, overrides = {}) => ({ ...overrides, }); -function fixture({ releases = [nightly(7)], comparisonStatus = "ahead", activeStatus } = {}) { +function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { const calls = []; return { calls, @@ -21,12 +21,6 @@ function fixture({ releases = [nightly(7)], comparisonStatus = "ahead", activeSt core: { info() {} }, github: { rest: { - actions: { - async listWorkflowRuns(params) { - calls.push(params); - return { data: { total_count: params.status === activeStatus ? 1 : 0 } }; - }, - }, repos: { listReleases() {}, async compareCommitsWithBasehead(params) { @@ -91,27 +85,7 @@ test("compares against the published tag, including legacy nightly tags", async assert.equal(calls[0].basehead, `${tag}...new`); }); -for (const status of ["in_progress", "queued", "waiting", "pending", "requested"]) { - test(`skips dispatch when a release is ${status}`, async () => { - const { options } = fixture({ activeStatus: status }); - options.github.paginate = async () => assert.fail("Must stop before checking releases"); - assert.equal(await shouldReleaseNightly({ ...options, checkActiveRuns: true }), false); - }); -} - -test("dispatches when no release is active and new commits are due", async () => { - const { options } = fixture(); - assert.equal(await shouldReleaseNightly({ ...options, checkActiveRuns: true }), true); -}); - -test("rechecks publication after a manual run finishes ahead of an automatic run", async () => { - const { options } = fixture(); - assert.equal(await shouldReleaseNightly({ ...options, checkActiveRuns: true }), true); - options.github.paginate = async () => [nightly(0)]; - assert.equal(await shouldReleaseNightly(options), false); -}); - -test("fails instead of dispatching when GitHub cannot supply release state", async () => { +test("fails instead of releasing when GitHub cannot supply release state", async () => { const { options } = fixture(); options.github.paginate = async () => { throw new Error("GitHub unavailable"); diff --git a/.github/workflows/nightly-schedule.yml b/.github/workflows/nightly-schedule.yml deleted file mode 100644 index 9d606ab719a9..000000000000 --- a/.github/workflows/nightly-schedule.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Check nightly release - -on: - schedule: - # Avoid minute zero, when GitHub scheduled jobs are busiest. - - cron: "8,38 * * * *" - -concurrency: - group: nightly-schedule - cancel-in-progress: false - -permissions: - contents: read - actions: write - -jobs: - check: - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: .github/scripts - - - name: Dispatch nightly when ready - uses: actions/github-script@v8 - with: - script: | - const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs'); - if (await shouldReleaseNightly({ github, context, core, checkActiveRuns: true })) { - await github.rest.actions.createWorkflowDispatch({ - ...context.repo, - workflow_id: 'release.yml', - ref: context.ref, - inputs: { channel: 'nightly', automatic: 'true' }, - }); - } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 595eec855801..48cd451e3fea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,9 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" + schedule: + # Avoid minute zero, when GitHub scheduled jobs are busiest. + - cron: "8,38 * * * *" workflow_dispatch: inputs: channel: @@ -15,11 +18,6 @@ on: options: - stable - nightly - automatic: - description: "Apply scheduled nightly checks before releasing" - required: false - default: false - type: boolean version: description: "Release version (for example 1.2.3 or v1.2.3)" required: false @@ -32,7 +30,7 @@ on: # newest-wins single slot, so a queued stable tag can never be silently # dropped. Automatic nightlies recheck the release gap after leaving the queue. concurrency: - group: release-${{ (inputs.channel == 'nightly') && 'nightly' || 'stable' }} + group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} cancel-in-progress: false queue: max @@ -43,7 +41,7 @@ permissions: jobs: check_changes: name: Check automatic nightly release - if: inputs.channel == 'nightly' && inputs.automatic + if: github.event_name == 'schedule' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 outputs: @@ -67,7 +65,7 @@ jobs: needs: [check_changes] if: | !failure() && !cancelled() && - (!(inputs.channel == 'nightly' && inputs.automatic) || needs.check_changes.outputs.has_changes == 'true') + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: @@ -110,7 +108,7 @@ jobs: NIGHTLY_SHA: ${{ github.sha }} NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ]]; then + if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ @@ -219,7 +217,7 @@ jobs: needs: [check_changes] if: | !failure() && !cancelled() && - (!(inputs.channel == 'nightly' && inputs.automatic) || needs.check_changes.outputs.has_changes == 'true') + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 environment: @@ -319,7 +317,7 @@ jobs: needs: [check_changes] if: | !failure() && !cancelled() && - (!(inputs.channel == 'nightly' && inputs.automatic) || needs.check_changes.outputs.has_changes == 'true') + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 steps: diff --git a/docs/operations/release.md b/docs/operations/release.md index da469b93d7a9..4217c76f1f1e 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -9,7 +9,7 @@ This document covers the unified release workflow for stable and nightly desktop - Workflow: `.github/workflows/release.yml` - Triggers: - push tag matching `v*.*.*` for stable releases - - scheduled nightly check every 30 minutes via `.github/workflows/nightly-schedule.yml` + - scheduled nightly check every 30 minutes - manual `workflow_dispatch` for either channel - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. @@ -158,10 +158,10 @@ One-time Vercel dashboard setup: - Workflow: `.github/workflows/release.yml` - Triggers: - - scheduled check every 30 minutes via `.github/workflows/nightly-schedule.yml` + - scheduled check every 30 minutes - manual `workflow_dispatch` with `channel=nightly` -- Automatic nightlies require new commits and at least six hours since the last nightly was published, including manual nightlies. The scheduled check skips if a release is active or queued. -- Manual nightlies bypass the time and change checks. Nightly runs remain serialized, and automatic runs recheck after leaving the queue. +- Automatic nightlies require new commits and at least six hours since the last nightly was published, including manual nightlies. +- 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. - Runs the same desktop quality gates and artifact matrix as the tagged release flow. - Publishes a GitHub prerelease only: - current tag format: `vX.Y.Z-nightly.YYYYMMDD.`