Skip to content

Commit 1ebd52e

Browse files
pmaxhoganclaude
andauthored
feat(updater): floor the dev channel to stable so dev never falls behind (#20)
* docs(updater): design spec for flooring dev channel to stable Guarantees the rolling dev updater channel never falls behind the stable channel. Floors dev to stable at release time by copying the (channel-agnostic) stable manifest into the dev path when stable is newer - no rebuild. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137BoWLRRPzC9XqA14KHyFU * docs(updater): revise dev-floor spec after codex review Codex flagged that a release.yml-only floor is leaky: - a stale in-flight dev build can redeploy below-stable (separate concurrency) - deploy-landing.yml redeploys the whole site and can undo the floor - macOS manual-download link keys off the dev channel, sending floored dev users to the stale /tag/dev release Revisions: floor before every whole-site driven-updates deploy (release.yml, dev-channel.yml, deploy-landing.yml); add a local hard-gate assertion (remote smoke becomes secondary); harden comparePrecedence (strip leading v); fix the macOS About.vue link to follow the offered version shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137BoWLRRPzC9XqA14KHyFU * feat(updater): floor the dev channel to stable so dev never falls behind The rolling dev updater channel could advertise a version BELOW stable: dev versions self-correct only on a dev build, so a stable release with no following dev build left dev pointing at an older version (observed: stable 0.2.0, dev 0.1.1-dev.24), stranding dev-channel users. A Tauri update manifest body is channel-agnostic (the channel lives only in the path; the url points at permanent tag assets; both channels share one updater key), so flooring dev to stable is just copying the stable manifest into the dev path. scripts/floor-dev-channel.mjs does this per target (copy when stable is newer, seed when dev is absent, keep when dev is ahead) and asserts dev>=stable on the LOCAL tree before deploy - a hard gate immune to Pages propagation lag. Per codex review, the floor runs before EVERY whole-site driven-updates deploy, not just release.yml: release.yml, dev-channel.yml (closes the stale in-flight dev-build race across concurrency groups), and deploy-landing.yml (a landing redeploy can't undo the floor). release.yml's post-deploy smoke also checks dev>=stable as a secondary eventual verification. Also fixes the macOS manual-download link (About.vue): it now follows the offered VERSION shape, so a floored dev user (clean 0.2.0 on the dev channel) is sent to /releases/latest (which has the assets), not the stale /tag/dev. Tests: comparePrecedence (SemVer 11, leading-v + all-numeric-SHA tolerant), floorChannel (floor/keep/seed/missing-stable), assertFloored, and the macOS link cases. Design: docs/superpowers/specs/2026-06-25-dev-channel-floor-design.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137BoWLRRPzC9XqA14KHyFU * fix(updater): harden floor + sync spec after codex impl review Codex review of the implementation found no P1; addressing the P2s: - floor-dev-channel.mjs now validates the stable manifest's serveable shape (non-empty platforms with url + signature) before copying it into dev, so a malformed stable manifest is never propagated to a second channel. Adds a test. - Spec sync: drop the never-implemented --stable-version flag (the floor reads each target's stable version from --stable-dir uniformly), document the --ge compare mode and the shape validation, and correct the macOS link target to /releases/latest (intentional: always-current, matches the existing stable path) rather than /tag/v<version>. Full ui vitest suite green (181 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137BoWLRRPzC9XqA14KHyFU --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 83097f3 commit 1ebd52e

8 files changed

Lines changed: 895 additions & 8 deletions

File tree

.github/workflows/deploy-landing.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ jobs:
6868
- name: Overlay live dev manifests (do not wipe dev)
6969
run: bash scripts/fetch-live-channel.sh dev site/updates "$UPDATES_BASE"
7070

71+
# dev-channel floor: this whole-site redeploy must not REPUBLISH a below-
72+
# stable dev manifest (which would undo a release-time floor). Floor the
73+
# overlaid live dev up to the overlaid live stable and assert dev>=stable
74+
# before deploy. Self-healing regardless of deploy order
75+
# (docs/superpowers/specs/2026-06-25-dev-channel-floor-design.md).
76+
- name: Floor dev channel to stable (dev must never be below stable)
77+
run: |
78+
node scripts/floor-dev-channel.mjs \
79+
--stable-dir site/updates/stable \
80+
--dev-dir site/updates/dev
81+
7182
- name: Show assembled site tree
7283
run: |
7384
set -euo pipefail

.github/workflows/dev-channel.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,19 @@ jobs:
405405
- name: Overlay live stable manifests (do not wipe the other channel)
406406
run: bash scripts/fetch-live-channel.sh stable site/updates "$UPDATES_BASE"
407407

408+
# dev-channel floor: a dev build whose checkout predates a stable release
409+
# computes a STALE (below-stable) dev version, and this workflow is in a
410+
# separate concurrency group from release.yml, so without this it could
411+
# publish a below-stable dev manifest (re-burying dev under stable). Floor the
412+
# freshly-built dev manifests up to the overlaid LIVE stable, and assert
413+
# dev>=stable locally before deploy
414+
# (docs/superpowers/specs/2026-06-25-dev-channel-floor-design.md).
415+
- name: Floor dev channel to stable (dev must never be below stable)
416+
run: |
417+
node scripts/floor-dev-channel.mjs \
418+
--stable-dir site/updates/stable \
419+
--dev-dir site/updates/dev
420+
408421
# M12: copy the root landing page into the site/ deploy root BEFORE the
409422
# whole-site `pages deploy`. The deploy is a whole-site snapshot, so without
410423
# this a dev-channel deploy would WIPE the live landing at

.github/workflows/release.yml

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,19 @@ jobs:
273273
- name: Overlay live dev manifests (do not wipe the other channel)
274274
run: bash scripts/fetch-live-channel.sh dev site/updates "$UPDATES_BASE"
275275

276+
# dev-channel floor: dev must NEVER advertise a version below stable
277+
# (docs/superpowers/specs/2026-06-25-dev-channel-floor-design.md). The
278+
# overlay above brought the live dev manifests into the deploy tree; this
279+
# floors them up to the freshly-generated stable manifests (copy stable->dev
280+
# per target when stable is newer, seed when dev is absent) and ASSERTS
281+
# dev>=stable on the LOCAL tree before the whole-site deploy - the hard gate
282+
# that is immune to Cloudflare propagation lag.
283+
- name: Floor dev channel to stable (dev must never be below stable)
284+
run: |
285+
node scripts/floor-dev-channel.mjs \
286+
--stable-dir site/updates/stable \
287+
--dev-dir site/updates/dev
288+
276289
# M12: copy the root landing page into the site/ deploy root BEFORE the
277290
# whole-site `pages deploy`. The deploy is a whole-site snapshot, so without
278291
# this the release deploy would WIPE the live landing at driven.maxhogan.dev
@@ -330,7 +343,33 @@ jobs:
330343
echo "::error::stable smoke: ${url} returned 200 but not a valid update manifest (no version)"
331344
fail=1
332345
else
333-
echo "stable smoke OK: ${url} (version $(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).version)' "$body"))"
346+
stbver="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).version)' "$body")"
347+
echo "stable smoke OK: ${url} (version ${stbver})"
348+
# Secondary (eventual) dev-floor check: the dev channel must not be
349+
# BELOW stable for this target. The AUTHORITATIVE gate already ran
350+
# LOCALLY before deploy (the "Floor dev channel to stable" step calls
351+
# assertFloored); this just verifies the deployed result, tolerating
352+
# Pages propagation via the same bounded curl retry. A propagation
353+
# 404/lag is a WARNING here, not a hard failure - the local gate
354+
# already proved the tree correct.
355+
devurl="${UPDATES_BASE}/dev/${t}/update.json"
356+
devbody="$(mktemp)"
357+
devcode="$(curl -fsSL --retry 8 --retry-delay 5 --retry-all-errors \
358+
--connect-timeout 15 --max-time 120 \
359+
-o "$devbody" -w '%{http_code}' "$devurl" 2>/dev/null || true)"
360+
devcode="${devcode:-000}"
361+
if [ "$devcode" = "200" ]; then
362+
devver="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).version)' "$devbody")"
363+
if node scripts/floor-dev-channel.mjs --ge "$devver" "$stbver"; then
364+
echo "dev>=stable OK: ${t} (dev ${devver} >= stable ${stbver})"
365+
else
366+
echo "::error::dev smoke: dev ${devver} is BELOW stable ${stbver} for ${t}"
367+
fail=1
368+
fi
369+
else
370+
echo "::warning::dev smoke: ${devurl} returned HTTP ${devcode}; skipping dev>=stable check (propagation lag; the local pre-deploy gate already enforced it)"
371+
fi
372+
rm -f "$devbody"
334373
fi
335374
rm -f "$body"
336375
done
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# Dev channel floor: dev never falls behind stable
2+
3+
Date: 2026-06-25
4+
Status: Approved (design, revised after codex review)
5+
6+
## Problem
7+
8+
The rolling `dev` updater channel can fall **behind** the stable channel.
9+
10+
The dev version is computed by `scripts/set-dev-version.mjs` as
11+
`<stable_patch+1>-dev.<run>.<sha>`, derived from the current
12+
`[workspace.package].version`. A fresh dev build is therefore always greater
13+
than the stable release it was cut from. But the dev channel is only rebuilt
14+
when `dev-channel.yml` runs, and that workflow is deliberately gated (cost
15+
policy): it builds only on `workflow_dispatch` or a `[dev-build]` commit, never
16+
on an ordinary push to `main`, and never on a release-please release commit.
17+
18+
So when a stable release ships (`release.yml`, tag-triggered) without a
19+
subsequent dev build, the live dev manifests keep advertising the **old** dev
20+
version. Observed 2026-06-25: stable released `0.2.0` while the dev channel
21+
stayed at `0.1.1-dev.24.ac5e487` - which is *lower* than stable. A user on the
22+
dev channel is then stranded on a version older than stable, with no update
23+
path until someone manually triggers a dev build.
24+
25+
## Invariant
26+
27+
For every updater target, at all times (after any whole-site updates deploy):
28+
29+
```
30+
dev_manifest.version >= stable_manifest.version
31+
```
32+
33+
"At a minimum" the dev channel must never serve a version below stable. It may
34+
legitimately be *ahead* (a real dev build for the next version) - that case
35+
must be preserved, not clobbered.
36+
37+
## Key enabler
38+
39+
A Tauri updater manifest body is **channel-agnostic**:
40+
41+
```json
42+
{ "version": "...", "notes": "...", "pub_date": "...",
43+
"platforms": { "<os>-<arch>": { "signature": "...", "url": "..." } } }
44+
```
45+
46+
The channel appears **only** in the directory path
47+
(`updates/<channel>/<os>/<arch>/update.json`), never in the body. The required
48+
fields the updater consumes are `version`, `platforms.<key>.url`, and
49+
`platforms.<key>.signature`; there is no channel field. The stable `url` points
50+
at the permanent `/releases/download/v<version>/...` tag assets. Therefore
51+
"make the dev channel serve the stable build" is literally **copying the stable
52+
manifest JSON into the `dev/` path** - no rebuild, no re-signing, no base-URL
53+
rewriting. Both channels validate against the **same** updater public key
54+
(`tauri.conf.json`), so the copied signature verifies unchanged, and the stable
55+
tag assets already exist when its manifests are generated (the release job
56+
downloads them first).
57+
58+
## Approach: floor dev to stable at every whole-site deploy (no rebuild)
59+
60+
Three workflows publish a whole-site snapshot to the same `driven-updates`
61+
Cloudflare Pages project, and any of them can leave dev below stable:
62+
63+
- `release.yml` (stable publish) overlays the live **dev** manifests, then
64+
deploys. After a version bump the overlaid dev can be **below** the new stable.
65+
- `dev-channel.yml` (dev publish) overlays the live **stable** manifests, then
66+
deploys. A dev build whose checkout predates a stable release computes a
67+
**stale** dev version (from old `Cargo.toml`) that can be below the live
68+
stable - and the two workflows are in separate concurrency groups, so this
69+
race is real.
70+
- `deploy-landing.yml` (landing publish) overlays **both** live channels, then
71+
deploys. If it fetched a stale dev (below stable) and deploys after a release,
72+
it **undoes** the floor.
73+
74+
So the floor is not a release-only concern. We enforce the invariant at the one
75+
choke point all three share: **immediately before the whole-site
76+
`pages deploy`, after the existing `fetch-live-channel.sh` overlay(s)**. Each of
77+
the three workflows gets one added step:
78+
79+
```
80+
node scripts/floor-dev-channel.mjs \
81+
--stable-dir site/updates/stable \
82+
--dev-dir site/updates/dev
83+
```
84+
85+
All three workflows pass the SAME invocation: the floor reads each target's
86+
stable version straight from `--stable-dir` (the local stable tree), so no
87+
`--stable-version` flag is needed. `release.yml` has freshly generated stable
88+
manifests there; `dev-channel.yml` and `deploy-landing.yml` do not generate
89+
stable manifests, so their `--stable-dir` is the **overlaid live stable** tree
90+
(`fetch-live-channel.sh stable`). In all three, the floor compares per target
91+
and acts on the **local** tree about
92+
to be deployed.
93+
94+
For each of the four GA targets (`windows/x86_64`, `darwin/x86_64`,
95+
`darwin/aarch64`, `linux/x86_64`):
96+
97+
| Local dev manifest vs local stable | Action |
98+
| --- | --- |
99+
| dev version **<** stable | **overwrite** `dev/<plat>/update.json` with `stable/<plat>/update.json` (floor up) |
100+
| dev version **>=** stable | **keep** the dev manifest (dev is already ahead - untouched) |
101+
| dev **missing** (first-publish 404) | **seed** `dev/<plat>` from `stable/<plat>` |
102+
| stable **missing** (no stable yet) | keep dev as-is; nothing to floor against |
103+
104+
### Local hard gate (primary), remote smoke (secondary)
105+
106+
After acting, `floor-dev-channel.mjs` **asserts** `dev >= stable` for every
107+
target present in the local tree and exits non-zero if any target violates it.
108+
This is the **hard correctness gate**: it runs before the deploy, is immune to
109+
Cloudflare propagation lag, and aborts the deploy on a logic regression.
110+
111+
The existing `release.yml` post-deploy stable smoke is extended to also fetch
112+
each deployed `dev/<plat>/update.json` and check `dev >= stable`, but this is a
113+
**secondary** eventual-publication check using the existing bounded curl retry
114+
(Pages propagation is non-atomic), not the primary gate - so a propagation lag
115+
cannot false-fail the release while the local assertion already proved the tree
116+
correct.
117+
118+
### Composition with the existing fail-closed overlay
119+
120+
`fetch-live-channel.sh` already fails closed: a *transient* (non-404) failure to
121+
fetch a live manifest aborts the deploy. The floor step runs **after** the
122+
overlay and only ever sees either a successfully-fetched manifest or a
123+
definitive 404 (absent, handled by the seed/keep rows). It never relaxes the
124+
fail-closed guarantee. The floor needs the real live versions to compare, so
125+
this ordering is load-bearing.
126+
127+
## Components
128+
129+
### New file: `scripts/floor-dev-channel.mjs`
130+
131+
Pure-Node, no network, mirroring the tested-helper style of
132+
`generate-update-json.mjs` and `set-dev-version.mjs`.
133+
134+
- `comparePrecedence(a, b) -> -1 | 0 | 1` - SemVer §11 precedence. Strips a
135+
leading `v`, compares `major.minor.patch` numerically, and ranks a clean
136+
release above a same-core prerelease (`0.2.0 > 0.2.0-dev.30`). The only
137+
comparison the floor and the smoke ever make is **clean-release stable** vs
138+
**prerelease dev** (or two clean releases), so "release outranks same-core
139+
prerelease" is the load-bearing rule; full identifier ordering is implemented
140+
defensively (numeric identifiers numerically, with leading-zero tolerance, so
141+
an all-digit short SHA cannot throw).
142+
- `floorChannel({ stableDir, devDir, platforms, log }) -> { floored, kept,
143+
seeded, missingStable }` - the copy/keep/seed decision per target on local
144+
files only. Reads each `devDir/<plat>/update.json` and
145+
`stableDir/<plat>/update.json`, compares versions, copies the stable manifest
146+
verbatim into the dev path when stable is newer or dev is missing.
147+
The copy paths validate the stable manifest's serveable shape (non-empty
148+
`platforms` with `url` + `signature`) before copying, so a malformed stable
149+
manifest is never propagated into dev.
150+
- `assertFloored({ stableDir, devDir, platforms })` - the hard gate: throws if
151+
any target has dev `<` stable after flooring.
152+
- CLI entry parsing `--stable-dir`, `--dev-dir`, `--platforms`, and a
153+
`--ge <a> <b>` compare mode (exit 0 if `a >= b` by precedence, used by the
154+
release smoke). Wired into all three workflows.
155+
156+
### Changed: `.github/workflows/release.yml`
157+
158+
1. Add the `floor-dev-channel.mjs` step after `Overlay live dev manifests` and
159+
before the Cloudflare Pages deploy.
160+
2. Extend the post-deploy stable smoke to also assert each deployed
161+
`dev/<plat>` version is `>=` stable (secondary check, existing retry).
162+
163+
### Changed: `.github/workflows/dev-channel.yml`
164+
165+
Add the floor step in the publish job after `Overlay live stable manifests` and
166+
before the deploy. This floors a **stale in-flight dev build** up to live stable,
167+
closing the cross-workflow race.
168+
169+
### Changed: `.github/workflows/deploy-landing.yml`
170+
171+
Add the floor step after the two overlay steps and before the deploy, so a
172+
landing redeploy can never republish a below-stable dev manifest.
173+
174+
### Changed: `ui/src/views/About.vue`
175+
176+
The macOS manual-download link (`macDownloadUrl`) currently keys off
177+
`updater.available.channel === "dev"` and always points a dev user at
178+
`/releases/tag/dev`. After flooring, a macOS dev user can be offered a **stable**
179+
build (clean version, assets on the stable tag), so the dev-tag link would send
180+
them to the rolling dev release page that lacks those assets. Fix: derive the
181+
link from the **offered version shape** - a prerelease (`-dev`) offer keeps the
182+
`/releases/tag/dev` link; any clean-release offer (the floored case) links to
183+
`/releases/latest`. `/releases/latest` (rather than `/releases/tag/v<version>`)
184+
is intentional: it is always the current stable release, matches the existing
185+
stable-channel link behavior, and never points at a since-superseded tag. This
186+
makes the floored update actually obtainable on macOS, where in-app install is
187+
disabled.
188+
189+
## Testing
190+
191+
Vitest specs alongside the existing `generate-update-json` / `set-dev-version`
192+
tests:
193+
194+
- `comparePrecedence`: release > same-core prerelease; numeric patch ordering;
195+
`dev.<n>` ordering; all-numeric SHA identifier does not throw; leading `v`
196+
stripped; equal versions return 0.
197+
- `floorChannel`: stable-newer copies stable over dev; dev-newer keeps dev;
198+
dev-missing seeds; stable-missing keeps dev; a 4-target fixture mixes all
199+
cases in one run.
200+
- `assertFloored`: passes when all dev >= stable; throws naming the offending
201+
target when one is below.
202+
- `About.vue` `macDownloadUrl`: a `-dev` offer -> `/tag/dev`; a clean-release
203+
offer (incl. a floored dev offer) -> `/releases/latest`.
204+
- `floorChannel` also refuses to propagate a malformed stable manifest (valid
205+
version but no `platforms`) into dev.
206+
207+
## Out of scope (YAGNI)
208+
209+
- No change to `set-dev-version.mjs`'s version formula.
210+
- No client/updater download-logic change beyond the macOS manual-link
211+
derivation above (the version comparison the updater already does is correct).
212+
- No rebuild-on-release path (the rejected expensive alternative).
213+
- No shared cross-workflow deploy lock: flooring at every deploy point makes the
214+
invariant self-healing regardless of deploy order, which is simpler and
215+
sufficient. (Noted as the alternative to a global deploy mutex.)
216+
217+
## Cost / risk
218+
219+
Near-zero runtime cost: one local-file step per deploy plus a secondary smoke
220+
assertion; no extra build minutes. The local hard gate makes the invariant a
221+
pre-deploy correctness check rather than relying on remote propagation. Blast
222+
radius is confined to manifest assembly in the three deploy workflows plus one
223+
computed property in the macOS About view.

0 commit comments

Comments
 (0)