| 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 | 103 | sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources |
| 104 | 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 | 109 | - name: Test |
| 107 | 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 | 6 | - "v*.*.*" |
| 7 | 7 | - "!v*-nightly.*" |
| 8 | 8 | 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 * * * *" | |
| 11 | 11 | workflow_dispatch: |
| 12 | 12 | inputs: |
| 13 | 13 | channel: |
| ... | ... | @@ -28,7 +28,7 @@ on: |
| 28 | 28 | # own group so a nightly never blocks them. Running publishers are never |
| 29 | 29 | # canceled, and queue: max keeps every pending run instead of the default |
| 30 | 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 | 32 | concurrency: |
| 33 | 33 | group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} |
| 34 | 34 | cancel-in-progress: false |
| ... | ... | @@ -40,41 +40,25 @@ permissions: |
| 40 | 40 | |
| 41 | 41 | jobs: |
| 42 | 42 | check_changes: |
| 43 | name: Check for changes since last nightly | |
| 43 | name: Check automatic nightly release | |
| 44 | 44 | if: github.event_name == 'schedule' |
| 45 | 45 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 46 | timeout-minutes: 5 | |
| 46 | 47 | outputs: |
| 47 | has_changes: ${{ steps.check.outputs.has_changes }} | |
| 48 | has_changes: ${{ steps.check.outputs.result }} | |
| 48 | 49 | steps: |
| 49 | 50 | - name: Checkout |
| 50 | 51 | uses: actions/checkout@v6 |
| 51 | 52 | with: |
| 52 | fetch-depth: 0 | |
| 53 | sparse-checkout: | | |
| 54 | /* | |
| 55 | !/.repos/ | |
| 56 | sparse-checkout-cone-mode: false | |
| 53 | sparse-checkout: .github/scripts | |
| 57 | 54 | |
| 58 | 55 | - 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 }); | |
| 78 | 62 | |
| 79 | 63 | preflight: |
| 80 | 64 | name: Preflight |
| ... | ... | @@ -228,7 +212,7 @@ jobs: |
| 228 | 212 | name: Resolve T3 Connect public config |
| 229 | 213 | # Consumes only the commit SHA, not preflight's resolved version, so it runs |
| 230 | 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 | 216 | # nor success, so success() would be wrong here). |
| 233 | 217 | needs: [check_changes] |
| 234 | 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 | 9 | - Workflow: `.github/workflows/release.yml` |
| 10 | 10 | - Triggers: |
| 11 | 11 | - push tag matching `v*.*.*` for stable releases |
| 12 | - scheduled nightly check every three hours | |
| 12 | - scheduled nightly check every 30 minutes | |
| 13 | 13 | - manual `workflow_dispatch` for either channel |
| 14 | 14 | - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. |
| 15 | 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 | 158 | |
| 159 | 159 | - Workflow: `.github/workflows/release.yml` |
| 160 | 160 | - Triggers: |
| 161 | - scheduled check every three hours | |
| 161 | - scheduled check every 30 minutes | |
| 162 | 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 | 165 | - Runs the same desktop quality gates and artifact matrix as the tagged release flow. |
| 164 | 166 | - Publishes a GitHub prerelease only: |
| 165 | 167 | - current tag format: `vX.Y.Z-nightly.YYYYMMDD.<run_number>` |