feat(canary): host updater manifest on Pages, redesign tag schema#397
Merged
Conversation
The legacy canary design used a mutable canary release+tag as the
"latest canary" pointer. GitHub Release Immutability permanently
reserves the tag name of any release it ever protects, even after the
release is deleted and the setting is disabled. This made the mutable
pointer pattern structurally incompatible with immutability and broke
the canary updater channel on 2026-05-16.
This commit redesigns canary publishing around three principles that
hold under any immutability setting:
1. Every canary tag is unique per build. New schema:
canary-{YYYYMMDDHHMMSS}-{run}-{shortsha} (lex-sortable, traceable
to commit, never reused). No tag is ever moved or recreated.
2. The "latest canary" pointer is a tracked file at
site/canary-latest.json, deployed to GitHub Pages by pages.yml.
The release subsystem is no longer involved in the pointer mechanism
at all.
3. Recovery contract is codified: if the Pages manifest is ever
unreachable, canary users switch to Stable in the About dialog,
pick up the next stable release (which always has a working
CANARY_ENDPOINT baked in), then switch back to Canary. No
maintainer intervention required.
Workflow changes (canary.yml):
* prepare job emits the new tag schema.
* publish-canary-release job unchanged (already immutability-safe
via the draft -> upload -> undraft pattern from PR #394).
* publish-canary-manifest job:
- Writes manifest to site/canary-latest.json (was: uploaded as an
asset of a mutable canary release).
- Commits to main with rebase-and-retry for races (3 attempts).
- Explicitly dispatches pages.yml via gh workflow run because
GITHUB_TOKEN-driven pushes do not auto-trigger other workflows.
- Needs �ctions: write permission for the dispatch.
* Clean up old canary releases step: filter strictly to new tag
schema (regex) so legacy canary-{N} releases are left in place
for manual cleanup. Retention bumped from 5 to 10.
Rust change (update.rs):
* CANARY_ENDPOINT -> https://dryotta.github.io/mdownreview/canary-latest.json
Docs change (updates.md):
* Channel hosting table, tag schema, and recovery contract documented
per AGENTS.md feature-doc rule.
Backward compatibility:
* Stable channel: completely unchanged. v0.4.7+ ships normally.
* Canary clients on 0.4.6-191...197: stranded on the dead
eleases/download/canary/... URL. They recover via the documented
"stable -> update -> canary" flip once next stable ships with the
new CANARY_ENDPOINT.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dryotta
added a commit
that referenced
this pull request
May 16, 2026
…cks bot push) (#398) ## Problem PR #397's first end-to-end canary run after merge failed silently. Investigation: \\\ remote: error: GH006: Protected branch update failed for refs/heads/main. remote: - Changes must be made through a pull request. remote: - Required status check "CI gate" is expected. \\\ The bot push to `main` was rejected by branch protection (correctly main requires PR + CI gate). The rebase-and-retry loop in `publish-canary-manifest` did not `exit 1` on exhaustion, so the job falsely reported success while `site/canary-latest.json` never landed and Pages was never deployed. Verified by canary build #198: tag schema worked (`canary-20260516215030-198-e95e3cf`), per-build release published, but `https://dryotta.github.io/mdownreview/canary-latest.json` returned empty. ## Fix **Drop the commit-to-main approach.** Deploy directly to GitHub Pages from canary.yml using `actions/upload-pages-artifact` + `actions/deploy-pages`. The job picks up `site/` from the workflow checkout, writes the freshly generated `canary-latest.json` into it, and deploys the combined artefact. To prevent `pages.yml` from wiping the canary manifest on subsequent docs-only deploys, `pages.yml` now curls the current Pages-served manifest before each deploy and reinjects it into `site/`. The **live Pages deployment becomes the single source of truth** for the canary manifest, kept alive by both workflows. ## Coordination | Concern | Handling | |---|---| | Race between canary deploy and docs deploy | Both jobs share `concurrency.group: pages`, `cancel-in-progress: false` serialised at the workflow trigger level | | `deploy-pages` requires the `github-pages` environment | Added to `publish-canary-manifest` | | `deploy-pages` requires `pages: write` + `id-token: write` | Added to `publish-canary-manifest` | | Cleanup of old canary releases entangled with deploy | Moved into its own `cleanup-canary-releases` job, gated on manifest success | | First-ever deploy where Pages has no manifest yet | `curl -f` fails `rm -f site/canary-latest.json` first deploy proceeds without it; subsequent canary deploys populate it | ## What about `pages.yml` losing the manifest after long inactivity? Not a real risk: the canary workflow runs on every push to main with non-docs changes, so the manifest is refreshed on every meaningful commit. `pages.yml` only runs on `site/**` changes, which are rare and almost always immediately followed by other commits that trigger canary again. ## Verification - Both YAML files parse via `yaml.safe_load` - Job graph: `prepare -> ci -> publish-canary-release -> publish-canary-manifest -> {cleanup-canary-releases, cleanup}` - `release.yml` not touched stable channel unaffected - End-to-end can only be fully verified on merge + next push to main triggering canary ## Stable channel Untouched. Verified earlier: - `release.yml` uses draft -> upload -> undraft (immutability-safe) - `v*.*.*` tags are always unique (no reservation risk) - `releases/latest/download/latest.json` works end-to-end (v0.4.6 manifest verified live) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Merged
dryotta
added a commit
that referenced
this pull request
May 16, 2026
Automated release PR. Will be auto-merged when CI is green. - Bump: `patch` (0.4.6 0.4.7) - Commits: 6 (5 user-facing after filter) - Last tag: v0.4.6 This release ships the canary updater redesign (#394, #397, #398): the canary manifest is now hosted on GitHub Pages (`https://dryotta.github.io/mdownreview/canary-latest.json`) using a unique-per-build tag schema. Stranded canary users on `0.4.6-191` ... `0.4.6-199` can recover by flipping to Stable in the About dialog, updating to `v0.4.7`, and flipping back to Canary the new `CANARY_ENDPOINT` is baked into `v0.4.7`. See `docs/features/updates.md` for the codified recovery contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Earlier today we hit
HTTP 422: Cannot upload assets to an immutable releaseon the canary build after enabling Enable release immutability. PR #394 made the upload pattern immutability-safe (draft -> upload -> undraft), but the deeper issue surfaced on the next push:GH013: Cannot create ref due to creations being restrictedwhen force-pushing the mutablecanarytag. Empirical testing confirmed:canary-tag design is structurally incompatible with the feature no workflow tweak can resurrect it.Redesign (this PR)
Three principles that hold under any immutability setting:
1. Unique-per-build canary tag schema
\
canary-{YYYYMMDDHHMMSS}-{run}-{shortsha}
\\
Example:
canary-20260516181801-198-3be43cfGITHUB_RUN_NUMBERreset (e.g. workflow YAML recreation).2. "Latest canary" pointer hosted on GitHub Pages
Manifest moves from a mutable release asset to a tracked file at
site/canary-latest.json, deployed by the existing pages.yml workflow. URL:\
https://dryotta.github.io/mdownreview/canary-latest.json
\\
Decoupled from the release subsystem entirely immune to immutability reservations now and in the future.
3. Codified recovery contract
If the Pages manifest is ever unreachable, canary users recover with three clicks:
CANARY_ENDPOINTbaked in)This is now documented in
docs/features/updates.mdas the system's intentional recovery mechanism. No maintainer intervention required.Mechanics
The
publish-canary-manifestjob now:site/canary-latest.jsonwith the new release's URLs + signatures.pages.ymlviagh workflow runbecauseGITHUB_TOKEN-driven pushes do not auto-trigger other workflows.Permissions added:
actions: write(for the dispatch).The
Clean up old canary releasesstep is filtered strictly to the new tag schema (regex) so legacycanary-{N}releases are left in place clean those up manually after the v0.4.7 cycle. Retention bumped 5 -> 10.Stable channel
Completely untouched.
release.yml,latest.jsonpublication,v*.*.*tag schema,releases/latest/download/latest.jsonendpoint all unchanged. Verified end-to-end via the v0.4.6 manifest fetch + the no-op release.yml diff.Backward compat
0.4.6-191...0.4.6-197cross_channeloverride inupdate.rs:58.Verification path
cargo check --quiet-> compilesyaml.safe_load->Out of scope (separate cleanup)
canary-build/canary-19xbranches accumulated during today's failing canary runs. Can be cleaned up manually post-merge.canary-19xreleases (191, 192, 193, 195, 196, 197) -- left in place; the new cleanup filter ignores them by design. Manual deletion at your discretion.