Skip to content

Canary publish

Canary publish #984

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