| author | |
| committer | |
| log | 7544d3d2c8e0145018d9adb7a1a650333b75362a |
| tree | 6c10301771acf6461ce57a3acd154fade1529124 |
| parent | b155c2199bdcb4fdf1920da02a223536f8c2d729 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 166 insertions(+), 32 deletions(-)
.github/scripts/check-nightly-release.cjs created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; | ||
| 2 | |||
| 3 | // Runs after the workflow acquires the nightly concurrency lock. | ||
| 4 | async 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 | |||
| 44 | module.exports = { shouldReleaseNightly }; | ||
.github/scripts/check-nightly-release.test.cjs created+101| ... | @@ -0,0 +1,101 @@ | ||
| 1 | const assert = require("node:assert/strict"); | ||
| 2 | const test = require("node:test"); | ||
| 3 | const { shouldReleaseNightly } = require("./check-nightly-release.cjs"); | ||
| 4 | |||
| 5 | const now = Date.parse("2026-09-05T12:00:00Z"); | ||
| 6 | const hour = 60 * 60 * 1000; | ||
| 7 | const 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 | |||
| 14 | function 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 | |||
| 40 | test("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 | |||
| 47 | test("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 | |||
| 55 | test("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 | |||
| 62 | test("skips unchanged commits after the gap", async () => { | ||
| 63 | const { options } = fixture({ comparisonStatus: "identical" }); | ||
| 64 | assert.equal(await shouldReleaseNightly(options), false); | ||
| 65 | }); | ||
| 66 | |||
| 67 | test("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 | |||
| 74 | test("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 | |||
| 81 | test("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 | |||
| 88 | test("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 | |||
| 96 | for (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: | ... | @@ -103,6 +103,9 @@ jobs: |
| 103 | sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources | 103 | sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources |
| 104 | sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config | 104 | sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config |
| 105 | 105 | ||
| 106 | - name: Test nightly release checks | ||
| 107 | run: node --test .github/scripts/check-nightly-release.test.cjs | ||
| 108 | |||
| 106 | - name: Test | 109 | - name: Test |
| 107 | run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test | 110 | run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test |
| 108 | 111 |
.github/workflows/release.yml+14-30| ... | @@ -6,8 +6,8 @@ on: | ... | @@ -6,8 +6,8 @@ on: |
| 6 | - "v*.*.*" | 6 | - "v*.*.*" |
| 7 | - "!v*-nightly.*" | 7 | - "!v*-nightly.*" |
| 8 | schedule: | 8 | schedule: |
| 9 | # Off minute zero: GitHub delays scheduled runs most at the top of the hour. | 9 | # Avoid minute zero, when GitHub scheduled jobs are busiest. |
| 10 | - cron: "38 */3 * * *" | 10 | - cron: "8,38 * * * *" |
| 11 | workflow_dispatch: | 11 | workflow_dispatch: |
| 12 | inputs: | 12 | inputs: |
| 13 | channel: | 13 | channel: |
| ... | @@ -28,7 +28,7 @@ on: | ... | @@ -28,7 +28,7 @@ on: |
| 28 | # own group so a nightly never blocks them. Running publishers are never | 28 | # own group so a nightly never blocks them. Running publishers are never |
| 29 | # canceled, and queue: max keeps every pending run instead of the default | 29 | # canceled, and queue: max keeps every pending run instead of the default |
| 30 | # newest-wins single slot, so a queued stable tag can never be silently | 30 | # 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. |
| 32 | concurrency: | 32 | concurrency: |
| 33 | group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} | 33 | group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} |
| 34 | cancel-in-progress: false | 34 | cancel-in-progress: false |
| ... | @@ -40,41 +40,25 @@ permissions: | ... | @@ -40,41 +40,25 @@ permissions: |
| 40 | 40 | ||
| 41 | jobs: | 41 | jobs: |
| 42 | check_changes: | 42 | check_changes: |
| 43 | name: Check for changes since last nightly | 43 | name: Check automatic nightly release |
| 44 | if: github.event_name == 'schedule' | 44 | if: github.event_name == 'schedule' |
| 45 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 45 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 46 | timeout-minutes: 5 | ||
| 46 | outputs: | 47 | outputs: |
| 47 | has_changes: ${{ steps.check.outputs.has_changes }} | 48 | has_changes: ${{ steps.check.outputs.result }} |
| 48 | steps: | 49 | steps: |
| 49 | - name: Checkout | 50 | - name: Checkout |
| 50 | uses: actions/checkout@v6 | 51 | uses: actions/checkout@v6 |
| 51 | with: | 52 | with: |
| 52 | fetch-depth: 0 | 53 | sparse-checkout: .github/scripts |
| 53 | sparse-checkout: | | ||
| 54 | /* | ||
| 55 | !/.repos/ | ||
| 56 | sparse-checkout-cone-mode: false | ||
| 57 | 54 | ||
| 58 | - id: check | 55 | - id: check |
| 59 | name: Compare HEAD to last nightly tag | 56 | name: Check release gap and new commits |
| 60 | run: | | 57 | uses: actions/github-script@v8 |
| 61 | last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) | 58 | with: |
| 62 | if [[ -z "$last_nightly_tag" ]]; then | 59 | script: | |
| 63 | echo "No previous nightly tag found. Proceeding with release." | 60 | const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs'); |
| 64 | echo "has_changes=true" >> "$GITHUB_OUTPUT" | 61 | return await shouldReleaseNightly({ github, context, core }); |
| 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 | ||
| 78 | 62 | ||
| 79 | preflight: | 63 | preflight: |
| 80 | name: Preflight | 64 | name: Preflight |
| ... | @@ -228,7 +212,7 @@ jobs: | ... | @@ -228,7 +212,7 @@ jobs: |
| 228 | name: Resolve T3 Connect public config | 212 | name: Resolve T3 Connect public config |
| 229 | # Consumes only the commit SHA, not preflight's resolved version, so it runs | 213 | # Consumes only the commit SHA, not preflight's resolved version, so it runs |
| 230 | # alongside preflight instead of after it. The condition mirrors preflight's: | 214 | # 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 |
| 232 | # nor success, so success() would be wrong here). | 216 | # nor success, so success() would be wrong here). |
| 233 | needs: [check_changes] | 217 | needs: [check_changes] |
| 234 | if: | | 218 | if: | |
docs/operations/release.md+4-2| ... | @@ -9,7 +9,7 @@ This document covers the unified release workflow for stable and nightly desktop | ... | @@ -9,7 +9,7 @@ This document covers the unified release workflow for stable and nightly desktop |
| 9 | - Workflow: `.github/workflows/release.yml` | 9 | - Workflow: `.github/workflows/release.yml` |
| 10 | - Triggers: | 10 | - Triggers: |
| 11 | - push tag matching `v*.*.*` for stable releases | 11 | - push tag matching `v*.*.*` for stable releases |
| 12 | - scheduled nightly check every three hours | 12 | - scheduled nightly check every 30 minutes |
| 13 | - manual `workflow_dispatch` for either channel | 13 | - manual `workflow_dispatch` for either channel |
| 14 | - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. | 14 | - 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. | 15 | - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. |
| ... | @@ -158,8 +158,10 @@ One-time Vercel dashboard setup: | ... | @@ -158,8 +158,10 @@ One-time Vercel dashboard setup: |
| 158 | 158 | ||
| 159 | - Workflow: `.github/workflows/release.yml` | 159 | - Workflow: `.github/workflows/release.yml` |
| 160 | - Triggers: | 160 | - Triggers: |
| 161 | - scheduled check every three hours | 161 | - scheduled check every 30 minutes |
| 162 | - manual `workflow_dispatch` with `channel=nightly` | 162 | - 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. | ||
| 163 | - Runs the same desktop quality gates and artifact matrix as the tagged release flow. | 165 | - Runs the same desktop quality gates and artifact matrix as the tagged release flow. |
| 164 | - Publishes a GitHub prerelease only: | 166 | - Publishes a GitHub prerelease only: |
| 165 | - current tag format: `vX.Y.Z-nightly.YYYYMMDD.<run_number>` | 167 | - current tag format: `vX.Y.Z-nightly.YYYYMMDD.<run_number>` |