Skip to content

feat(canary): host updater manifest on Pages, redesign tag schema#397

Merged
dryotta merged 1 commit into
mainfrom
feature/canary-pages-redesign
May 16, 2026
Merged

feat(canary): host updater manifest on Pages, redesign tag schema#397
dryotta merged 1 commit into
mainfrom
feature/canary-pages-redesign

Conversation

@dryotta

@dryotta dryotta commented May 16, 2026

Copy link
Copy Markdown
Owner

Problem

Earlier today we hit HTTP 422: Cannot upload assets to an immutable release on 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 restricted when force-pushing the mutable canary tag. Empirical testing confirmed:

  • Release Immutability permanently reserves the tag-name of any release it ever protects.
  • Reservations survive release deletion and disabling the setting (verified with probe-tag tests).
  • The legacy mutable-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-3be43cf

  • Globally unique (timestamp + SHA make collisions impossible).
  • Lex-sortable gh release list gives chronological order.
  • Traceable clicking the tag in GH UI lands on the exact commit.
  • Immutability-friendly every tag is created exactly once and never moved.
  • Run-number-resilient survives GITHUB_RUN_NUMBER reset (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:

  1. About dialog -> Update channel: Canary -> Stable
  2. Check for Updates -> install latest stable (which always has a working CANARY_ENDPOINT baked in)
  3. Update channel: Stable -> Canary

This is now documented in docs/features/updates.md as the system's intentional recovery mechanism. No maintainer intervention required.

Mechanics

The publish-canary-manifest job now:

  1. Generates site/canary-latest.json with the new release's URLs + signatures.
  2. Commits + pushes to main (3-attempt rebase fallback for races with concurrent human commits).
  3. Explicitly dispatches pages.yml via gh workflow run because GITHUB_TOKEN-driven pushes do not auto-trigger other workflows.

Permissions added: actions: write (for the dispatch).

The Clean up old canary releases step is filtered strictly to the new tag schema (regex) so legacy canary-{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.json publication, v*.*.* tag schema, releases/latest/download/latest.json endpoint all unchanged. Verified end-to-end via the v0.4.6 manifest fetch + the no-op release.yml diff.

Backward compat

Cohort Outcome
Stable users (v0.4.6 and earlier) Zero impact. Next stable release ships normally.
Canary users on 0.4.6-191 ... 0.4.6-197 Stranded on dead URL until they flip to Stable, update, flip back. The recovery contract is documented and tested via the existing cross_channel override in update.rs:58.
New canary users (any build from this PR forward) Auto-update works via the Pages URL.

Verification path

  • cargo check --quiet -> compiles
  • Both workflow YAMLs parse via yaml.safe_load ->
  • Only 3 files reference the canary URL pattern, all updated ->
  • End-to-end CI on this PR -> covers Rust build, type-check, lint, unit tests, native E2E, all 3 platform builds, bindings drift
  • Real-world validation: requires merge to main + at least one new push to trigger canary; first such canary run will exercise every new code path (new tag schema, manifest commit, pages.yml dispatch, Pages deploy)

Out of scope (separate cleanup)

  • ~17 orphaned canary-build/canary-19x branches accumulated during today's failing canary runs. Can be cleaned up manually post-merge.
  • Legacy canary-19x releases (191, 192, 193, 195, 196, 197) -- left in place; the new cleanup filter ignores them by design. Manual deletion at your discretion.

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 dryotta merged commit e95e3cf into main May 16, 2026
15 checks passed
@dryotta dryotta deleted the feature/canary-pages-redesign branch May 16, 2026 21:50
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>
@dryotta dryotta mentioned this pull request May 16, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant