| author | |
| committer | |
| log | 001f06d543beb92cc66bb7e8d54d233a20d802b4 |
| tree | d6535fc11dda341b2169a71432cb729a2cab7f5b |
| parent | 7ac93e300ee17a4ee5e92192264fcfd438805b6f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | 1 | const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; |
| 2 | 2 | |
| 3 | // Runs after the workflow acquires the nightly concurrency lock. | |
| 4 | async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { | |
| 3 | const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); | |
| 4 | ||
| 5 | // Newest published nightly by publication time, or undefined when none exists. | |
| 6 | async function findLatestNightly({ github, context }) { | |
| 5 | 7 | const releases = await github.paginate(github.rest.repos.listReleases, { |
| 6 | 8 | ...context.repo, |
| 7 | 9 | per_page: 100, |
| 8 | 10 | }); |
| 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)) | |
| 16 | 13 | .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. | |
| 17 | async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { | |
| 18 | const lastNightly = await findLatestNightly({ github, context }); | |
| 17 | 19 | |
| 18 | 20 | if (!lastNightly) { |
| 19 | 21 | core.info("No published nightly found. Proceeding with release."); |
| ... | ... | @@ -41,4 +43,25 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() }) |
| 41 | 43 | return true; |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | module.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. | |
| 49 | async 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 | ||
| 67 | module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit }; |
.github/scripts/check-nightly-release.test.cjs+42| ... | ... | @@ -99,3 +99,45 @@ for (const status of ["behind", "diverged"]) { |
| 99 | 99 | assert.equal(await shouldReleaseNightly(options), false); |
| 100 | 100 | }); |
| 101 | 101 | } |
| 102 | ||
| 103 | const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs"); | |
| 104 | ||
| 105 | function 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 | ||
| 115 | test("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 | ||
| 133 | test("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 | ||
| 140 | test("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: |
| 19 | 19 | - stable |
| 20 | 20 | - nightly |
| 21 | 21 | 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." | |
| 23 | 23 | required: false |
| 24 | 24 | type: string |
| 25 | 25 | |
| ... | ... | @@ -39,33 +39,54 @@ permissions: |
| 39 | 39 | id-token: none |
| 40 | 40 | |
| 41 | 41 | jobs: |
| 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 | |
| 45 | 48 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 46 | 49 | timeout-minutes: 5 |
| 47 | 50 | 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 }} | |
| 49 | 54 | steps: |
| 50 | 55 | - name: Checkout |
| 51 | 56 | uses: actions/checkout@v6 |
| 52 | 57 | with: |
| 53 | 58 | sparse-checkout: .github/scripts |
| 54 | 59 | |
| 55 | - id: check | |
| 56 | name: Check release gap and new commits | |
| 60 | - id: resolve | |
| 61 | name: Resolve release commit | |
| 57 | 62 | uses: actions/github-script@v8 |
| 63 | env: | |
| 64 | DISPATCH_CHANNEL: ${{ inputs.channel }} | |
| 58 | 65 | with: |
| 59 | 66 | 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 | } | |
| 62 | 83 | |
| 63 | 84 | preflight: |
| 64 | 85 | name: Preflight |
| 65 | needs: [check_changes] | |
| 86 | needs: [resolve_commit] | |
| 66 | 87 | 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') | |
| 69 | 90 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 70 | 91 | timeout-minutes: 10 |
| 71 | 92 | outputs: |
| ... | ... | @@ -78,11 +99,12 @@ jobs: |
| 78 | 99 | cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} |
| 79 | 100 | is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} |
| 80 | 101 | make_latest: ${{ steps.release_meta.outputs.make_latest }} |
| 81 | ref: ${{ github.sha }} | |
| 102 | ref: ${{ needs.resolve_commit.outputs.ref }} | |
| 82 | 103 | steps: |
| 83 | 104 | - name: Checkout |
| 84 | 105 | uses: actions/checkout@v6 |
| 85 | 106 | with: |
| 107 | ref: ${{ needs.resolve_commit.outputs.ref }} | |
| 86 | 108 | fetch-depth: 0 |
| 87 | 109 | sparse-checkout: | |
| 88 | 110 | /* |
| ... | ... | @@ -104,8 +126,9 @@ jobs: |
| 104 | 126 | env: |
| 105 | 127 | DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} |
| 106 | 128 | DISPATCH_VERSION: ${{ github.event.inputs.version }} |
| 129 | NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }} | |
| 107 | 130 | NIGHTLY_DATE: ${{ github.run_started_at }} |
| 108 | NIGHTLY_SHA: ${{ github.sha }} | |
| 131 | NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }} | |
| 109 | 132 | NIGHTLY_RUN_NUMBER: ${{ github.run_number }} |
| 110 | 133 | run: | |
| 111 | 134 | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then |
| ... | ... | @@ -123,9 +146,9 @@ jobs: |
| 123 | 146 | echo "make_latest=false" >> "$GITHUB_OUTPUT" |
| 124 | 147 | else |
| 125 | 148 | if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then |
| 126 | raw="${DISPATCH_VERSION}" | |
| 149 | raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}" | |
| 127 | 150 | 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 | |
| 129 | 152 | exit 1 |
| 130 | 153 | fi |
| 131 | 154 | else |
| ... | ... | @@ -210,14 +233,12 @@ jobs: |
| 210 | 233 | |
| 211 | 234 | relay_public_config: |
| 212 | 235 | 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] | |
| 218 | 239 | 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') | |
| 221 | 242 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 222 | 243 | timeout-minutes: 5 |
| 223 | 244 | environment: |
| ... | ... | @@ -239,7 +260,7 @@ jobs: |
| 239 | 260 | - name: Checkout |
| 240 | 261 | uses: actions/checkout@v6 |
| 241 | 262 | with: |
| 242 | ref: ${{ github.sha }} | |
| 263 | ref: ${{ needs.resolve_commit.outputs.ref }} | |
| 243 | 264 | sparse-checkout: | |
| 244 | 265 | /* |
| 245 | 266 | !/.repos/ |
| ... | ... | @@ -312,19 +333,19 @@ jobs: |
| 312 | 333 | # machine. node-pty is N-API, so one binary works across all WSL Node versions. |
| 313 | 334 | build_wsl_node_pty: |
| 314 | 335 | 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] | |
| 318 | 339 | 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') | |
| 321 | 342 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 322 | 343 | timeout-minutes: 15 |
| 323 | 344 | steps: |
| 324 | 345 | - name: Checkout |
| 325 | 346 | uses: actions/checkout@v6 |
| 326 | 347 | with: |
| 327 | ref: ${{ github.sha }} | |
| 348 | ref: ${{ needs.resolve_commit.outputs.ref }} | |
| 328 | 349 | sparse-checkout: | |
| 329 | 350 | /* |
| 330 | 351 | !/.repos/ |
docs/operations/release.md+21-10| ... | ... | @@ -8,9 +8,18 @@ This document covers the unified release workflow for stable and nightly desktop |
| 8 | 8 | |
| 9 | 9 | - Workflow: `.github/workflows/release.yml` |
| 10 | 10 | - 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 | |
| 12 | 13 | - 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. | |
| 14 | 23 | - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. |
| 15 | 24 | - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. |
| 16 | 25 | - Builds four artifacts in parallel for both channels: |
| ... | ... | @@ -291,8 +300,8 @@ risk, manually dispatch `channel=nightly`; this still publishes a real nightly n |
| 291 | 300 | prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or |
| 292 | 301 | commit a version bump to `main`. Only run it when a real nightly release is acceptable. |
| 293 | 302 | |
| 294 | Manual `channel=stable` with a version input is also a real stable-channel release. Omitting signing | |
| 295 | secrets only makes platform artifacts unsigned; it does not prevent publication. | |
| 303 | Manual `channel=stable` is also a real stable-channel release. Omitting signing secrets only makes | |
| 304 | platform artifacts unsigned; it does not prevent publication. | |
| 296 | 305 | |
| 297 | 306 | ## 2) Apple signing + notarization setup (macOS) |
| 298 | 307 | |
| ... | ... | @@ -370,17 +379,19 @@ Checklist: |
| 370 | 379 | |
| 371 | 380 | ## 4) Ongoing release checklist |
| 372 | 381 | |
| 373 | 1. Ensure `main` is green in CI. | |
| 374 | 2. Bump app version as needed. | |
| 375 | 3. Create release tag: `vX.Y.Z`. | |
| 376 | 4. Push tag. | |
| 377 | 5. Verify workflow steps: | |
| 382 | 1. Pick the latest nightly and verify it: run the smoke test above against its artifacts and | |
| 383 | check the nightly channel for regressions. | |
| 384 | 2. Dispatch the Release workflow with `channel=stable`. Leave `version` empty unless the version | |
| 385 | should differ from the one the nightly previewed. | |
| 386 | 3. 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. | |
| 388 | 4. Verify workflow steps: | |
| 378 | 389 | - preflight passes |
| 379 | 390 | - release quality checks pass |
| 380 | 391 | - all matrix builds pass |
| 381 | 392 | - `publish_cli` publishes the exact release version before the release job |
| 382 | 393 | - release job uploads expected files |
| 383 | 6. Smoke test downloaded artifacts. | |
| 394 | 5. Smoke test downloaded artifacts. | |
| 384 | 395 | |
| 385 | 396 | ## 5) Troubleshooting |
| 386 | 397 |