Skip to content

dev-channel

dev-channel #325

Workflow file for this run

name: dev-channel
# Rolling "dev" pre-release channel (SPEC s19.4).
#
# Produces a 0.0.0-dev.<short-sha> build for early adopters who opt into the
# dev updater channel (driven.maxhogan.dev/updates/dev/...). Same 4-row build
# matrix as release.yml, uploaded to a single rolling "dev" GitHub pre-release
# and published to Cloudflare Pages under updates/dev/.
#
# COST POLICY (DELIBERATE TRADEOFF - read before loosening the trigger):
# The full 4-row (3-OS) Tauri bundle is EXPENSIVE (macOS + Windows minutes are
# billed at a premium). Running it on EVERY push to main would silently burn the
# CI budget on commits that have no business producing a dev installer (docs,
# refactors, the release-please "chore: release" commits, etc.). So this
# workflow does NOT fire on every main push: it is GATED to fire only when
# (a) it is manually dispatched (workflow_dispatch), OR
# (b) the head commit message contains the explicit marker `[dev-build]`, OR
# (c) the nightly schedule fires AND main has moved since the commit the
# rolling `dev` release was last built from (skip otherwise - a quiet
# main costs zero build minutes).
# It also explicitly SKIPS release-please's "chore(main): release" / "release"
# commits so it never double-fires alongside the release.yml tag path.
# To cut a dev build immediately, either run it from the Actions tab or push a
# commit whose message includes `[dev-build]`; otherwise the nightly schedule
# picks up any new commits. The schedule bounds the cost to at most one 4-row
# build per day.
on:
push:
branches: [main]
schedule:
# Nightly at 09:00 UTC; the gate job skips the build when main has no new
# commits since the last published dev build.
- cron: "0 9 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
# One rolling dev build at a time. Do NOT cancel an in-flight build: most
# main pushes spawn gate-only runs (build=false), and letting one of those
# cancel a 40-minute 4-target build kills real work for nothing (this
# exact footgun cancelled the first post-2.1.0 dev build when a merge
# landed mid-build). Superseded runs queue behind the build instead.
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
gate:
name: decide whether to build a dev bundle
runs-on: ubuntu-latest
outputs:
build: ${{ steps.decide.outputs.build }}
steps:
- id: decide
env:
# head_commit is null for workflow_dispatch/schedule; guard with the ||.
COMMIT_MSG: ${{ github.event.head_commit.message }}
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
build=false
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
build=true
elif [ "$EVENT_NAME" = "schedule" ]; then
# Nightly: build only if main has moved since the commit the rolling
# `dev` release was last built from. The release body embeds that
# commit's full sha ("Rolling dev channel build <ver> (<sha>). ...").
# Fail OPEN (build) if the release or the sha is missing/unparseable -
# a wasted build is cheaper than a silently-stale dev channel.
last_sha="$(gh release view dev --repo "$GITHUB_REPOSITORY" \
--json body --jq .body 2>/dev/null \
| grep -oE '\([0-9a-f]{40}\)' | head -n1 | tr -d '()' || true)"
if [ -z "$last_sha" ]; then
echo "no prior dev build sha found - building"
build=true
elif [ "$last_sha" = "$GITHUB_SHA" ]; then
echo "main unchanged since last dev build ($last_sha) - skipping"
build=false
else
echo "main moved since last dev build ($last_sha -> $GITHUB_SHA) - building"
build=true
fi
elif printf '%s' "$COMMIT_MSG" | head -n1 | grep -qiE 'release-please|chore\(main\): release|chore: release'; then
# release-please commits ride the tag path (release.yml); never a dev build.
build=false
elif printf '%s' "$COMMIT_MSG" | grep -qF '[dev-build]'; then
build=true
fi
echo "build=$build" >> "$GITHUB_OUTPUT"
echo "dev build decision: $build (event=$EVENT_NAME)"
# R4-P1-3 (STAGE-THEN-PUBLISH): the rolling `dev` release is REUSED across runs,
# so prior runs' signed bundles accrete on it. The PREVIOUS design deleted ALL
# of the rolling release's assets BEFORE this run's matrix built/uploaded - so a
# failed or cancelled run (matrix, signing, upload, or the publish job) left the
# live `dev` manifests on Cloudflare Pages pointing at now-DELETED GitHub assets
# (broken dev auto-update until the next good run). The fix is to NEVER delete
# the previous good release's assets until this run's replacement is fully built,
# validated, AND deployed:
# 1. build -> uploads THIS run's bundles (run-unique <ver>-dev.<run>.<sha>
# names, so they never collide with the prior run's assets).
# 2. publish-dev-manifest -> downloads ONLY this run's assets (filtered by the
# computed dev version), generates + validates all 4 target
# manifests, overlays the live stable channel, deploys the
# whole-site snapshot. A failure ANYWHERE up to here leaves the
# previous good dev release (assets + live manifests) intact.
# 3. gc-stale-dev-assets -> only AFTER (2) succeeds, delete every asset on the
# rolling release that is NOT from this run.
# So there is no longer a delete-then-rebuild window. The pre-build delete job is
# gone; the generator's stale-bundle guard (R2-P1-2) is preserved by scoping the
# publish-job download to the current run's version, not by wiping first.
build:
name: build dev ${{ matrix.target }}
needs: gate
if: needs.gate.outputs.build == 'true'
strategy:
fail-fast: false
matrix:
# M10 dev-build fix: the DEV version is `<next-patch>-dev.<run>.<sha>`,
# whose pre-release identifier (`dev.<run>.<sha>`) is NON-numeric. The
# Windows MSI (WiX) bundler maps SemVer -> a 4-part numeric MSI version
# and REQUIRES the pre-release to be numeric-only (<=65535), so it rejects
# the dev pre-release ("optional pre-release identifier in app version
# must be numeric-only ... for msi target"). The dev channel only needs
# the in-app auto-updater path, which the NSIS `-setup.exe` + its updater
# artifact (.sig) fully cover - so build the Windows DEV bundle NSIS-only
# (skip MSI) via `--bundles nsis`. mac/linux have no MSI concept and pass
# the full version through, so their bundleArgs stay empty. STABLE
# (release.yml, no pre-release) is unaffected and keeps the full msi+nsis
# bundle set. See design/CODEX_NOTES.md "## M10 dev-build fix".
# Issue #25: `helperConfig` merges the per-OS externalBin helper sidecar
# at bundle time via `--config` - the Windows VSS helper (DESIGN s5.3.1)
# on Windows, and the macOS APFS mount broker (DESIGN s5.3.2) on both
# darwin targets. Each OS's bundle therefore references ONLY its own
# sidecar; linux has none, so it stays empty.
include:
- {
os: macos-latest,
target: aarch64-apple-darwin,
bundleArgs: "",
helperConfig: "--config src-tauri/tauri.apfs-helper.conf.json",
}
- {
os: macos-latest,
target: x86_64-apple-darwin,
bundleArgs: "",
helperConfig: "--config src-tauri/tauri.apfs-helper.conf.json",
}
- { os: ubuntu-22.04, target: x86_64-unknown-linux-gnu, bundleArgs: "", helperConfig: "" }
- {
os: windows-latest,
target: x86_64-pc-windows-msvc,
bundleArgs: "--bundles nsis",
helperConfig: "--config src-tauri/tauri.helper.conf.json",
}
runs-on: ${{ matrix.os }}
env:
SQLX_OFFLINE: "true"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
# R2-P1-1: derive a dev version that is (a) ABOVE the current stable release
# and (b) MONOTONIC between dev builds: <next-patch>-dev.<run_number>.<sha>,
# computed from the CURRENT [workspace.package].version (NOT hardcoded). The
# SAME byte-identical value patches the app metadata AND the generated
# manifest (the publish job recomputes it deterministically from the same
# run_number + sha + checked-out Cargo.toml). 0.0.0-dev.<sha> was LOWER than
# stable 0.1.0 (a stable user opting into dev was never offered an update)
# and short SHAs do not sort by time.
- name: Compute dev version
id: ver
shell: bash
run: |
set -euo pipefail
version="$(node scripts/set-dev-version.mjs --print-dev-version \
"${{ github.run_number }}" "$(git rev-parse --short HEAD)")"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "dev version: $version"
# R1-P1-5: patch the dev version into the canonical version sources (root
# Cargo.toml [workspace.package], tauri.conf.json, ui/package.json) BEFORE
# the Tauri build so the produced app actually reports the dev version -
# otherwise updater version comparison + paths use the stale stable version.
- name: Patch dev version into app metadata
shell: bash
run: node scripts/set-dev-version.mjs "${{ steps.ver.outputs.version }}"
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux Tauri deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev javascriptcoregtk-4.1
- uses: Swatinem/rust-cache@v2
- uses: pnpm/action-setup@v6
with:
version: 10
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: pnpm install
working-directory: ui
run: pnpm install --frozen-lockfile
# Issue #25 (DESIGN s5.3.1): prebuild the VSS helper broker binary + stage
# it as a target-triple externalBin sidecar so the Windows dev bundle ships
# it (the app build does not build the helper bin itself). Windows only.
- name: Build VSS helper sidecar (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
cargo build --release --target ${{ matrix.target }} \
-p driven-vss-helper --bin driven-vss-helper
mkdir -p src-tauri/binaries
cp "target/${{ matrix.target }}/release/driven-vss-helper.exe" \
"src-tauri/binaries/driven-vss-helper-${{ matrix.target }}.exe"
echo "staged sidecar:"; ls -la src-tauri/binaries
# DESIGN s5.3.2: prebuild the APFS snapshot mount broker binary + stage it
# as a target-triple externalBin sidecar so the darwin dev bundles ship it
# (the app build does not build the helper bin itself). macOS only.
- name: Build APFS helper sidecar (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
set -euo pipefail
cargo build --release --target ${{ matrix.target }} \
-p driven-apfs --bin driven-apfs-helper
mkdir -p src-tauri/binaries
cp "target/${{ matrix.target }}/release/driven-apfs-helper" \
"src-tauri/binaries/driven-apfs-helper-${{ matrix.target }}"
echo "staged sidecar:"; ls -la src-tauri/binaries
# Build the bundle (no GitHub Release created here; tauri-action only
# builds, then softprops/action-gh-release uploads to the rolling tag).
- name: Build + sign (tauri-action)
uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
# No tagName -> tauri-action builds but does not create/upload a
# release; we collect the artifacts and publish them ourselves so all
# four targets land on ONE rolling "dev" pre-release.
#
# R2-P2-1: codex claimed the key should be `includeUpdaterJson`. VERIFIED
# against the tauri-action action.yml + Context7 docs: the input IS
# `uploadUpdaterJson` (default true); `includeUpdaterJson` does not exist
# and would be silently ignored. Keep `uploadUpdaterJson` (false positive).
uploadUpdaterJson: false
# M10 dev-build fix: `bundleArgs` is empty for mac/linux and
# `--bundles nsis` for the Windows dev matrix entry. tauri-action passes
# `args` verbatim to `tauri build` (parsed by string-argv), and
# `tauri build -b/--bundles nsis` restricts the Windows bundle to NSIS,
# skipping the MSI whose WiX bundler rejects the non-numeric dev
# pre-release. createUpdaterArtifacts is true in tauri.conf.json, so the
# NSIS `-setup.exe` still emits its updater artifact + `.sig`, and the
# collect/generate steps still produce the windows-x86_64 manifest the
# `--require-targets` guard expects. (Verified against Context7
# tauri-action + tauri v2 docs.)
#
# Issue #25: `helperConfig` merges the externalBin VSS-helper sidecar on
# Windows and the externalBin APFS-helper sidecar on darwin (empty on
# linux).
args: --target ${{ matrix.target }} ${{ matrix.bundleArgs }} ${{ matrix.helperConfig }}
# The exact bundle paths vary per OS/target; collect everything the
# bundler produced (installers + .sig) under the target dir.
#
# R3-P1-1 + R4-P1-3: tauri-action here only BUILDS (no tagName), so its
# `releaseAssetNamePattern` cannot help - we upload the on-disk bundles
# ourselves via softprops/action-gh-release. The on-disk macOS updater
# artifact is named from the `.app` bundle (`Driven.app.tar.gz`) and carries
# NO arch (and NO version), so the aarch64 + x86_64 mac runs would upload the
# SAME basename to the ONE rolling `dev` release and collide (one arch lost /
# ARM advertised as x86_64), AND a later run could not tell its mac bundle
# apart from a previous run's. Because the stage-then-publish flow (R4-P1-3)
# no longer wipes the rolling release before building, every dev asset must
# be RUN-UNIQUE so the publish job can select exactly this run's bundles and
# the GC step can delete exactly the previous runs'. Stamp BOTH the matrix
# arch AND this run's dev version into the mac `.app.tar.gz` (and its `.sig`).
# The dev version token (<patch>-dev.<run>.<sha>, no underscores) is what the
# generator's filename parser already extracts, and is unique per run.
- name: Collect bundle artifacts
id: collect
shell: bash
env:
DEV_VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
out="dev-artifacts"
mkdir -p "$out"
# Tauri writes bundles under target/<triple>/release/bundle/**.
find "target/${{ matrix.target }}/release/bundle" -type f \
\( -name '*.dmg' -o -name '*.app.tar.gz' -o -name '*.app.tar.gz.sig' \
-o -name '*.msi' -o -name '*.msi.sig' -o -name '*-setup.exe' \
-o -name '*-setup.exe.sig' -o -name '*.AppImage' -o -name '*.AppImage.sig' \
-o -name '*.deb' \) \
-exec cp {} "$out/" \; || true
# Map the matrix Rust triple to the updater arch token.
case "${{ matrix.target }}" in
aarch64-apple-darwin) MAC_ARCH=aarch64 ;;
x86_64-apple-darwin) MAC_ARCH=x86_64 ;;
*) MAC_ARCH="" ;;
esac
if [ -n "$MAC_ARCH" ]; then
# Rename Driven.app.tar.gz -> Driven_<version>_<arch>.app.tar.gz (and
# the .sig) so the mac updater artifact carries BOTH the arch (so each
# mac arch maps to a distinct darwin/<arch> manifest) AND this run's
# version (so it is run-unique like the installers). Skip if it already
# carries the arch token (idempotent).
for f in "$out"/*.app.tar.gz "$out"/*.app.tar.gz.sig; do
[ -e "$f" ] || continue
base="$(basename "$f")"
if printf '%s' "$base" | grep -qiE 'aarch64|arm64|x86_64|x64'; then
continue
fi
# Insert _<version>_<arch> just before the `.app.tar.gz` suffix.
newbase="$(printf '%s' "$base" | sed -E "s/\\.app\\.tar\\.gz(\\.sig)?$/_${DEV_VERSION}_${MAC_ARCH}.app.tar.gz\\1/")"
if [ "$newbase" != "$base" ]; then
mv "$f" "$out/$newbase"
echo "renamed mac artifact $base -> $newbase"
fi
done
fi
echo "Collected:"; ls -la "$out" || true
- name: Publish to rolling dev pre-release
uses: softprops/action-gh-release@v3
with:
tag_name: dev
name: "Driven dev (rolling)"
body: "Rolling dev channel build ${{ steps.ver.outputs.version }} (${{ github.sha }}). Pre-release; not for production."
prerelease: true
files: dev-artifacts/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-dev-manifest:
name: publish dev update manifests
needs: build
if: needs.build.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
deployments: write
env:
# The rolling pre-release tag the dev bundles + .sig live on.
RELEASE_TAG: dev
UPDATES_BASE: https://driven.maxhogan.dev/updates
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
# R2-P1-1: recompute the SAME dev version the build job patched in - it is a
# pure function of (current Cargo.toml workspace version, github.run_number,
# short sha), all identical at this commit, so this is byte-identical to the
# value the app metadata + bundles carry. We pass it to the generator's
# --version so the manifest version matches the bundles exactly (and the
# generator's stale-bundle guard can reject any accreted old asset - R2-P1-2).
- name: Compute dev version
id: ver
shell: bash
run: |
set -euo pipefail
version="$(node scripts/set-dev-version.mjs --print-dev-version \
"${{ github.run_number }}" "$(git rev-parse --short HEAD)")"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "dev version: $version"
# R1-P1-2/4 + R4-P1-3: download the rolling `dev` release's assets so the
# generator has real bundle + `.sig` inputs (it reads them from a local
# dir). Because the stage-then-publish flow (R4-P1-3) NO LONGER wipes prior
# runs' assets before building, the rolling release can hold BOTH this run's
# bundles AND older runs'. The generator's stale-bundle guard would ERROR on
# accreted older assets, so we download ONLY the assets whose filename
# carries THIS run's computed dev version (a run-unique
# <next-patch>-dev.<run_number>.<sha> token). The build job now stamps that
# version into EVERY dev asset name (installers, AppImage, AND the mac
# `.app.tar.gz`), so a pure version-substring match selects exactly this
# run's bundles. We FAIL CLOSED if no current-run asset is found.
- name: Download THIS run's dev release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEV_VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
mkdir -p release-assets
# All asset names on the rolling release.
names="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json assets --jq '.assets[].name')"
if [ -z "$names" ]; then
echo "::error::rolling dev release has no assets to publish"
exit 1
fi
while IFS= read -r name; do
[ -z "$name" ] && continue
case "$name" in
*"$DEV_VERSION"*)
echo "download current-run asset: $name"
gh release download "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--dir release-assets \
--pattern "$name" --clobber
;;
*)
echo "skip stale asset (not this run): $name"
;;
esac
done <<< "$names"
echo "Downloaded assets:"; ls -la release-assets
if [ -z "$(ls -A release-assets)" ]; then
echo "::error::no current-run dev assets matched version $DEV_VERSION"
exit 1
fi
# R1-P1-6: notes for the dev manifest's in-app changelog (the rolling
# pre-release body).
- name: Fetch dev release notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json body --jq '.body // ""' > release-notes.md
echo "Notes bytes: $(wc -c < release-notes.md)"
# R1-P1-1/4 + R2-P1-1/2: generate updates/dev/<os>/<arch>/update.json with
# the EXACT dev version the bundles carry (--version, byte-identical to the
# app metadata), real notes, and the ROLLING `dev` tag asset base URL (the
# assets live on tag `dev` even though the bundle version is the dev
# prerelease). Passing --version also arms the generator's stale-bundle
# guard: any accreted asset whose filename version differs aborts the run.
# R3-P1-2: --require-targets makes the generator ERROR unless EVERY V1
# target produced a manifest, so a partial dev update tree (missing `.sig`,
# asset collision, mapping miss) can never deploy while CI stays green.
# R7-P1-1 (deploy path == fetch path): generate UNDER a `site/` staging root
# (`site/updates/dev/...`) and deploy the `site/` parent below, so Cloudflare
# Pages serves the manifests at `/updates/dev/...` - matching the app's fetch
# endpoint exactly. Deploying the bare `updates/` dir made its contents the
# Pages root, stripping the `/updates/` prefix and 404ing every updater check.
- name: Generate dev update.json tree
run: |
set -euo pipefail
node scripts/generate-update-json.mjs dev \
--version "${{ steps.ver.outputs.version }}" \
--assets-dir release-assets \
--notes-file release-notes.md \
--out site/updates \
--require-targets "windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64" \
--base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}"
# R3-P1-2 (defense in depth): assert every required dev manifest exists
# before overlaying the other channel + deploying the whole-site snapshot.
- name: Verify all required dev manifests exist
run: |
set -euo pipefail
for t in windows/x86_64 darwin/x86_64 darwin/aarch64 linux/x86_64; do
if [ ! -f "site/updates/dev/${t}/update.json" ]; then
echo "::error::missing required manifest site/updates/dev/${t}/update.json"
exit 1
fi
done
# R1-P1-7: `pages deploy` is a whole-site snapshot, so overlay the OTHER
# channel's (stable) currently-live manifests before deploying or this
# would clobber updates/stable. R7-P1-1: tree-dir is the staging `site/updates`.
- name: Overlay live stable manifests (do not wipe the other channel)
run: bash scripts/fetch-live-channel.sh stable site/updates "$UPDATES_BASE"
# dev-channel floor: a dev build whose checkout predates a stable release
# computes a STALE (below-stable) dev version, and this workflow is in a
# separate concurrency group from release.yml, so without this it could
# publish a below-stable dev manifest (re-burying dev under stable). Floor the
# freshly-built dev manifests up to the overlaid LIVE stable, and assert
# dev>=stable locally before deploy
# (docs/superpowers/specs/2026-06-25-dev-channel-floor-design.md).
- name: Floor dev channel to stable (dev must never be below stable)
run: |
node scripts/floor-dev-channel.mjs \
--stable-dir site/updates/stable \
--dev-dir site/updates/dev
# M12: copy the root landing page into the site/ deploy root BEFORE the
# whole-site `pages deploy`. The deploy is a whole-site snapshot, so without
# this a dev-channel deploy would WIPE the live landing at
# driven.maxhogan.dev root. The copy is ADDITIVE - it only writes
# site/{index.html,styles.css,icon.svg,404.html} and never touches
# site/updates, so the freshly generated dev + overlaid live stable manifests
# are unaffected.
- name: Assemble landing into site/ (do not wipe the root page)
run: bash scripts/assemble-landing.sh site site-landing
# R7-P1-1: deploy the `site/` PARENT (which contains `updates/`), NOT the
# bare `updates/` dir, so the served path keeps the `/updates/` prefix and
# matches the app's `updates/dev/<os>/<arch>/update.json` fetch endpoint.
- name: Deploy dev manifests to Cloudflare Pages
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy site --project-name=driven-updates --branch=main
# R7-P1-1 (post-deploy smoke): prove the deployed dev manifests are reachable
# at the EXACT public URL the in-app updater fetches
# (`$UPDATES_BASE/dev/<os>/<arch>/update.json`). Catches the deploy-path !=
# fetch-path mismatch for real: a wrong prefix or missing manifest 404s and
# FAILS the job rather than shipping a silently-broken dev channel. Validates
# 200 AND a parseable JSON manifest with a `version` for each required target.
- name: Smoke test deployed dev manifests
run: |
set -euo pipefail
# Cloudflare Pages propagation can lag the deploy by a few seconds and is
# NOT atomic across files/edges, so a freshly-deployed manifest can 404
# briefly. curl --retry-all-errors only retries an HTTP 404 when -f/--fail
# is also set (a 404 is otherwise a "successful" fetch to curl and is NOT
# retried). So -f makes the 404 a retryable error; curl's own bounded retry
# (8 attempts, 5s apart) rides out propagation - no bash sleep/poll loop.
fail=0
for t in windows/x86_64 darwin/x86_64 darwin/aarch64 linux/x86_64; do
url="${UPDATES_BASE}/dev/${t}/update.json"
body="$(mktemp)"
code="$(curl -fsSL --retry 8 --retry-delay 5 --retry-all-errors \
--connect-timeout 15 --max-time 120 \
-o "$body" -w '%{http_code}' "$url" 2>/dev/null || true)"
code="${code:-000}"
if [ "$code" != "200" ]; then
echo "::error::dev smoke: ${url} returned HTTP ${code} (expected 200)"
fail=1
rm -f "$body"
continue
fi
if ! node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));if(!v||typeof v.version!=="string"||v.version.length===0){console.error("manifest has no version string");process.exit(1)}' "$body"; then
echo "::error::dev smoke: ${url} returned 200 but not a valid update manifest (no version)"
fail=1
else
echo "dev smoke OK: ${url} (version $(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).version)' "$body"))"
fi
rm -f "$body"
done
if [ "$fail" -ne 0 ]; then
echo "::error::dev updater smoke FAILED - the deployed manifests are not reachable at the app's fetch path"
exit 1
fi
echo "all required dev manifests are reachable at ${UPDATES_BASE}/dev/<os>/<arch>/update.json"
# R4-P1-3 (the "THEN GC" half of stage-then-publish): garbage-collect the
# PREVIOUS runs' assets from the rolling `dev` release - but ONLY now, after the
# whole pipeline (build + validate + manifest generate + CF Pages deploy)
# succeeded. Until this point the previous good dev release (its assets AND the
# live manifests that point at them) stayed fully intact, so any earlier failure
# or cancellation never stranded dev users on deleted assets. We delete every
# asset whose name does NOT carry THIS run's dev version (the run-unique token
# the build job stamps into every asset name); this run's freshly published +
# already-deployed assets are kept. A failure of THIS job alone is harmless
# (worst case: stale assets linger until the next dev build GCs them) - it
# depends on publish-dev-manifest so it cannot run on a failed publish.
gc-stale-dev-assets:
name: garbage-collect superseded rolling-dev assets
needs: publish-dev-manifest
if: needs.publish-dev-manifest.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
env:
RELEASE_TAG: dev
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
# Recompute the SAME run-unique dev version (pure function of the checked-out
# Cargo.toml version + run_number + short sha) so we know which assets to KEEP.
- name: Compute dev version
id: ver
shell: bash
run: |
set -euo pipefail
version="$(node scripts/set-dev-version.mjs --print-dev-version \
"${{ github.run_number }}" "$(git rev-parse --short HEAD)")"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "dev version: $version"
- name: Delete superseded dev assets (keep this run's)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEV_VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "no rolling dev release; nothing to GC"
exit 0
fi
names="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json assets --jq '.assets[].name')"
if [ -z "$names" ]; then
echo "rolling dev release has no assets; nothing to GC"
exit 0
fi
while IFS= read -r name; do
[ -z "$name" ] && continue
case "$name" in
*"$DEV_VERSION"*)
echo "keep current-run asset: $name"
;;
*)
echo "deleting superseded dev asset: $name"
gh release delete-asset "$RELEASE_TAG" "$name" --yes \
--repo "$GITHUB_REPOSITORY"
;;
esac
done <<< "$names"