Canary publish #989
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Canary publish | |
| # The canary channel's feed. No tag: versioning belongs to the human, so this publishes | |
| # whatever `main` is at, ordered by the monotonic `buildOrder` in the manifest rather than | |
| # by the version string. See decisions/2026/08/06/stable-canary-update-channels.md. | |
| # | |
| # SCHEDULED-IF-MAIN-MOVED, NOT PER MERGE. Merges land in batches of four or five back to back, | |
| # so per-push meant four wasted sign-and-notarize cycles for one meaningful artifact. A cron | |
| # that compares a sha has no burst to collapse, which is why the group below queues instead of | |
| # cancelling in progress. | |
| # | |
| # Scheduled workflows are BEST EFFORT and can fire late. A missed tick is harmless: the next | |
| # one still sees the sha mismatch and builds, so no commit is ever permanently unpublished. | |
| on: | |
| # THE SCHEDULE IS ON, AND HERE IS THE RUN THAT EARNED IT. | |
| # | |
| # This channel was called `unstable`, and `electrobun build --env=unstable` could not | |
| # produce it: the CLI that runs is a compiled binary the vendor's `bin/electrobun.cjs` | |
| # downloads, so the patch we carried edited a file nobody executes. `--env` degraded to | |
| # `dev` on every tick, and the schedule was switched off rather than left failing hourly. | |
| # | |
| # `canary` is in the vendor's own allowlist, and that is no longer a prediction: run | |
| # 31257371545 (dispatched by hand, 11 of 11 jobs, 11m09s) published | |
| # canary-{macos-arm64,macos-x64,linux-x64,linux-arm64}-update.json — all four readable, | |
| # buildOrder 1618, sha 7a9d230fb. The built-folder check in create-release-artifacts.sh, | |
| # which is what caught the old degradation, passed on the way there. | |
| # | |
| # HALF-HOURLY ON THE ROUND HALF HOUR, at Arseny's request on 2026-08-19. It replaces the | |
| # 20-minute cadence added on 2026-08-14 to shorten the wait for Windows canary builds: at | |
| # 20-minute spacing a publish routinely met the next tick, and a Linux runner that hung an | |
| # hour on `apt-get update` left a wall of queued-then-cancelled runs. Half-hourly still beats | |
| # the original hourly `23 * * * *` and gives one run room to finish. | |
| # | |
| # Round times are deliberate and cost something: the top of the hour is the busiest slot on | |
| # shared runners, so these ticks fire later than an offset one would. Nothing here depends on | |
| # the cadence, so it is safe to change again. | |
| schedule: | |
| - cron: "0,30 * * * *" | |
| # Safe to press on an unchanged `main`: every platform skips, and a republish of the same | |
| # commit reproduces the same `buildOrder` so clients would not reinstall anyway. | |
| workflow_dispatch: | |
| inputs: | |
| force: | |
| description: "Publish every platform without comparing the feed (seeds a channel that has never published)" | |
| type: boolean | |
| default: false | |
| # ONE PUBLISH AT A TIME, QUEUED — NEVER CANCELLED MID-RUN. At hourly spacing an 11-minute run | |
| # could not meet itself; at 20-minute spacing, with scheduled ticks routinely 15-30 minutes | |
| # late, two runs overlapping is ordinary. Overlapping runs write the same places: the bucket | |
| # keys are overwritten in place, so one run's manifest can be published over the other's | |
| # payloads, and the rolling `canary` pre-release MERGES its `canary-assets.json` ledger, which | |
| # two writers turn into a race. Worse, the second run's probe sees the feed BEFORE the first | |
| # has published, so it rebuilds the same sha — a duplicate sign-and-notarize cycle for nothing. | |
| # | |
| # `cancel-in-progress: false` because cancelling a publish mid-flight is the one outcome worse | |
| # than racing: it can leave a half-written manifest authoritative. Queuing costs a delayed tick | |
| # and nothing else — when the queued run finally starts, the sha now matches and every platform | |
| # skips, which is nearly free. GitHub keeps at most one run pending per group and cancels an | |
| # older pending one; that is harmless here, because a pending run has built nothing and the | |
| # next tick still sees any sha mismatch (the same reason a missed tick is harmless). | |
| concurrency: | |
| group: canary-publish | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| jobs: | |
| # ────────────────────────────────────────────────── | |
| # Has main moved since the last publish, per platform? | |
| # ────────────────────────────────────────────────── | |
| decide: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| macos-arm64: ${{ steps.probe.outputs.macos-arm64 }} | |
| macos-x64: ${{ steps.probe.outputs.macos-x64 }} | |
| linux-x64: ${{ steps.probe.outputs.linux-x64 }} | |
| linux-arm64: ${{ steps.probe.outputs.linux-arm64 }} | |
| win-x64: ${{ steps.probe.outputs.win-x64 }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: "1.3.14" | |
| # No `bun install`: the script imports only src/shared, so this job stays seconds long | |
| # in front of four sign-and-notarize cycles. | |
| # | |
| # THE SAME CREDENTIALS THE BUILDS PUBLISH WITH, and the reason is not convenience. The | |
| # bucket grants no anonymous `s3:ListBucket`, so an unauthenticated GET answers 403 for | |
| # a key that does not exist — indistinguishable from a policy failure, which this | |
| # workflow (correctly) refuses to build on. Probing anonymously therefore made the first | |
| # publish unreachable: 403 → refuse → no manifest → 403 again, every hour, forever. | |
| - name: Probe the published canary feed | |
| id: probe | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| AWS_REGION: eu-west-1 | |
| FORCE_PUBLISH: ${{ inputs.force }} | |
| run: bun scripts/decide-canary-publish.ts | |
| # ────────────────────────────────────────────────── | |
| # The packaged Windows proof gates every publisher | |
| # ────────────────────────────────────────────────── | |
| # `workflow-windows-proof.test.ts` asserts that every job syncing the feed depends on this, | |
| # and that assertion is enumerated across the whole workflow directory precisely so a NEW | |
| # publisher like this one cannot skip it. A Windows failure must not land a partial ship. | |
| # See decisions/2026/08/06/windows-proof-post-merge-not-pull-request.md. | |
| # | |
| # NOT SCOPED BY CHANGED PATHS, unlike the post-merge proof — there is no pushed range here. | |
| # Scoped instead by whether anything publishes at all: on a quiet hour every platform skips, | |
| # so a full Windows packaging run would gate nothing. The condition must name EVERY output | |
| # `decide` emits; a platform missing from it silently loses its gate, which is why | |
| # canary-publish.test.ts derives the list from CANARY_PLATFORMS rather than trusting it. | |
| windows-proof: | |
| needs: decide | |
| if: >- | |
| needs.decide.outputs.macos-arm64 == 'true' || | |
| needs.decide.outputs.macos-x64 == 'true' || | |
| needs.decide.outputs.linux-x64 == 'true' || | |
| needs.decide.outputs.linux-arm64 == 'true' || | |
| needs.decide.outputs.win-x64 == 'true' | |
| uses: ./.github/workflows/windows-conpty-package.yml | |
| # ────────────────────────────────────────────────── | |
| # Per-platform builds — each one skipped independently | |
| # ────────────────────────────────────────────────── | |
| # `tag` is the SHORT SHA, not a version. There is no tag on this path, and passing a | |
| # constant like "canary" would overwrite one archive prefix forever; the short sha makes a | |
| # specific canary build fetchable after the root feed has moved on. The versioned prefix | |
| # therefore grows per commit and has NO retention policy today — named rather than implied. | |
| # | |
| # `publish: true` is a literal here, unlike release.yml's dry-run comparison: this workflow | |
| # exists only to publish, and it has no dry-run path. `workflow_dispatch` on an unchanged | |
| # main is made safe by the skip above, not by withholding the upload. | |
| build-macos-arm64: | |
| needs: [decide, windows-proof] | |
| if: needs.decide.outputs.macos-arm64 == 'true' | |
| uses: ./.github/workflows/release-build-macos.yml | |
| secrets: inherit | |
| with: | |
| arch: arm64 | |
| runsOn: macos-15 | |
| channel: canary | |
| tag: ${{ github.sha }} | |
| publish: true | |
| build-macos-x64: | |
| needs: [decide, windows-proof] | |
| if: needs.decide.outputs.macos-x64 == 'true' | |
| uses: ./.github/workflows/release-build-macos.yml | |
| secrets: inherit | |
| with: | |
| arch: x64 | |
| runsOn: macos-15-intel | |
| channel: canary | |
| tag: ${{ github.sha }} | |
| publish: true | |
| build-linux-x64: | |
| needs: [decide, windows-proof] | |
| if: needs.decide.outputs.linux-x64 == 'true' | |
| uses: ./.github/workflows/release-build-linux.yml | |
| secrets: inherit | |
| with: | |
| arch: x64 | |
| runsOn: ubuntu-22.04 | |
| channel: canary | |
| tag: ${{ github.sha }} | |
| publish: true | |
| bestEffort: false | |
| build-linux-arm64: | |
| needs: [decide, windows-proof] | |
| if: needs.decide.outputs.linux-arm64 == 'true' | |
| uses: ./.github/workflows/release-build-linux.yml | |
| secrets: inherit | |
| with: | |
| arch: arm64 | |
| runsOn: ubuntu-22.04-arm | |
| channel: canary | |
| tag: ${{ github.sha }} | |
| publish: true | |
| bestEffort: true | |
| # WHERE WINDOWS SHIPPED FIRST, and still the channel that gets it hourly. The build is | |
| # UNSIGNED — SmartScreen greets its first launch — so it went where the stakes are low before | |
| # it went anywhere else (decisions/2026/08/08/windows-ships-through-canary-first.md). It is now | |
| # also attached to tagged releases (decisions/2026/08/14/windows-zip-on-the-release-page.md); | |
| # nothing about this job changed when that landed. | |
| # | |
| # FAIL-CLOSED, and nothing here has to ask for it any more: release-build-windows.yml dropped its | |
| # `bestEffort` input when the stable release became fail-closed too, so a Windows failure is red | |
| # on both channels by construction. Behaviour on this channel is unchanged — the old | |
| # `bestEffort: false` was GitHub's default spelled out. | |
| build-win-x64: | |
| needs: [decide, windows-proof] | |
| if: needs.decide.outputs.win-x64 == 'true' | |
| uses: ./.github/workflows/release-build-windows.yml | |
| secrets: inherit | |
| with: | |
| arch: x64 | |
| runsOn: windows-latest | |
| channel: canary | |
| tag: ${{ github.sha }} | |
| publish: true | |
| # ────────────────────────────────────────────────── | |
| # The page a human can click — one rolling pre-release | |
| # ────────────────────────────────────────────────── | |
| # A DOWNLOAD SURFACE, NOT A SECOND FEED. Clients discover builds through the manifests the | |
| # jobs above sync to S3, ordered by `buildOrder`; nothing in the app reads a GitHub release. | |
| # This job exists because the only way to obtain a canary build was a raw bucket URL handed | |
| # over by hand — and on Windows that is the ONLY bootstrap there is, because the in-app | |
| # channel switch refuses on every non-macOS platform and its error text sends the user to | |
| # "the releases page". See decisions/2026/08/14/canary-rolling-github-prerelease.md. | |
| # | |
| # ONE ROLLING ENTRY, tag `canary`, assets replaced in place. Measured over 145 runs of this | |
| # workflow: 4.2 publishes a day, peak 9 — a release per publish is ~30 entries a week and | |
| # buries every stable release, which is the opposite of what was asked for. | |
| # | |
| # `always()` because a partial run is the normal case: each platform skips on its own feed, | |
| # so this must refresh whatever DID build. It cannot run on a quiet hour, because every | |
| # build job is then skipped and no `result` is 'success'; and it cannot run behind a failed | |
| # `decide`, for the same reason. | |
| release-page: | |
| needs: [decide, build-macos-arm64, build-macos-x64, build-linux-x64, build-linux-arm64, build-win-x64] | |
| if: >- | |
| always() && ( | |
| needs.build-macos-arm64.result == 'success' || | |
| needs.build-macos-x64.result == 'success' || | |
| needs.build-linux-x64.result == 'success' || | |
| needs.build-linux-arm64.result == 'success' || | |
| needs.build-win-x64.result == 'success' ) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| # The ONLY job here that writes to the repository, and the tag move is why. Workflow-level | |
| # permissions stay `contents: read` so a build job can never edit a release or a ref. | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: "1.3.14" | |
| # `pattern:` IS LOAD-BEARING, not tidiness. This run also carries the Windows proof's | |
| # artifacts, including the ~400 MB unpacked launch tree; an unfiltered download would | |
| # flatten every loose .exe and .dll of it into the same directory the publisher uploads | |
| # from, and attach them to the release one by one. | |
| - name: Collect the built artifacts | |
| uses: actions/download-artifact@v5 | |
| with: | |
| pattern: artifacts-* | |
| path: all-artifacts | |
| merge-multiple: true | |
| - name: List collected artifacts | |
| run: ls -lhR all-artifacts/ | |
| # No `bun install`: the script imports only src/shared, like the decide job above. | |
| - name: Refresh the rolling canary pre-release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: bun scripts/publish-canary-release.ts |