dev-channel #325
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
| 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" |