Skip to content

track-release

track-release #1896

Workflow file for this run

name: track-release
# Keep the in-browser demo on the latest OVMX release TAG, with ZERO secrets.
# OVMX cuts point releases as git tags (0.3-1, 0.3-2, …) — not GitHub Releases —
# so we track tags and BUILD the bootable image from source at that tag (no
# dependency on published assets). Runs in openvmx-site, commits to itself with
# the built-in GITHUB_TOKEN. Everything it reads (vms source, our qemu-wasm
# Release) is public.
on:
schedule:
- cron: '7-59/10 * * * *' # every 10 min (:07,:17,…): a tag cut auto-cascades
# within ~10 min, zero-touch (no PAT/dispatch needed —
# the repository_dispatch receiver below stays inert as
# an opt-in fast path if a token is ever provisioned).
# ~10 min is the safe floor (GitHub throttles tighter);
# concurrency: track-release below serialises overlaps.
workflow_dispatch:
inputs:
tag:
description: force a specific vms tag (else the latest release-like tag)
required: false
force:
description: re-capture + redeploy even if DEPLOYED_TAG already matches (e.g. after a demo-harness change)
type: boolean
default: false
# Fired immediately by the vms release.yml on a tag cut (client_payload.tag), so
# the demo/site cascade runs ON the tag instead of waiting for the 3h poll. The
# schedule above stays as a backstop. INERT until release.yml sends the dispatch.
repository_dispatch:
types: [release-tagged]
permissions:
contents: write
concurrency: track-release
jobs:
track:
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Resolve latest release tag, compare to deployed
id: r
run: |
# workflow_dispatch input, else the repository_dispatch payload tag (tag
# cut), else (schedule) resolve the newest release-like tag by polling.
TAG='${{ github.event.inputs.tag || github.event.client_payload.tag }}'
if [ -z "$TAG" ]; then
# Release tags come in two flavours: bare (0.3-8) and V-prefixed
# (V0.3-9, V0.4). Accept an OPTIONAL leading V so the tracker
# actually advances past 0.3-8 to V0.4; sort -V still ranks V0.4
# newest. The matched string is used verbatim below (clone
# --branch, gh release download, DEPLOYED_TAG compare), so the V
# is preserved end-to-end -- no separate normalization needed.
TAG=$(git ls-remote --tags https://github.com/3dl-dev/vms 'refs/tags/*' \
| sed 's#.*refs/tags/##; s/\^{}//' \
| grep -E '^V?[0-9]+\.[0-9]+(-[0-9]+)?$' | sort -V | tail -1)
fi
if [ -z "$TAG" ]; then echo "no release-like tag found."; echo "skip=1" >> "$GITHUB_OUTPUT"; exit 0; fi
DEPLOYED=$(cat boot/DEPLOYED_TAG 2>/dev/null || true)
echo "latest=$TAG deployed=$DEPLOYED force=${{ github.event.inputs.force }}"
if [ "$TAG" = "$DEPLOYED" ] && [ "${{ github.event.inputs.force }}" != "true" ]; then
echo "already on $TAG (pass force=true to re-capture)."; echo "skip=1" >> "$GITHUB_OUTPUT"; exit 0
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# Demo capture can be DELIBERATELY blocked for this tag (e.g. a cut whose
# install-reboot the snapshot can't cleanly capture). boot/DEMO_PIN.json
# is the explicit, tracked acknowledgment. When it validly pins the
# current demo image (pinned_to == DEPLOYED) against THIS tag
# (blocked_from == TAG), skip the expensive build+capture (still cascade
# the version labels + feed below) so the job is green and cheap instead
# of rebuilding + failing verify every 3h. force=true overrides the pin.
if [ "${{ github.event.inputs.force }}" != "true" ] \
&& python3 -c "import json,sys; p=json.load(open('boot/DEMO_PIN.json')); sys.exit(0 if p.get('pinned_to')=='$DEPLOYED' and p.get('blocked_from')=='$TAG' else 1)" 2>/dev/null; then
echo "demo capture is pinned/blocked for $TAG (boot/DEMO_PIN.json) — labels+feed only, capture skipped."
echo "capture_skip=1" >> "$GITHUB_OUTPUT"
fi
- name: Gate — public manuals must not lie about the commands (drift check at the tag)
if: steps.r.outputs.skip != '1'
run: |
# Before deploying anything for this tag, prove the Installation Guide's
# machine-checkable command block still matches the '# GUIDE-STEP'
# commands in the vms install gate AT THIS TAG. Reuse the vms comparator
# verbatim (do not fork it) so both repos agree on the check. Drift here
# fails the cut: a release can never ship a public guide whose install
# commands disagree with the gate that proves them.
T='${{ steps.r.outputs.tag }}'
rm -rf vmsdrift
git clone --depth 1 --branch "$T" https://github.com/3dl-dev/vms vmsdrift
python3 vmsdrift/tools/check_guide_drift.py \
--gate vmsdrift/tests/qemu/test_product_install_e2e.sh \
--guide docs/installation/index.html
- name: Gate — public manuals must be GROUNDED in the compat register (major/minor cuts only)
if: steps.r.outputs.skip != '1'
run: |
# A MAJOR/MINOR cut carries a doc-authoring obligation (docs/releasing.md):
# the manuals must describe what actually shipped. This gate is the
# mechanical backstop for that — it proves every capability claim in a
# manual matches the Compatibility Surface Register at the release tag,
# so a cut can never ship a guide that calls a facility "not yet
# available" after it graduated to implemented/verified, or documents a
# facility the register marks absent/facade-risk as if it worked.
#
# MAJOR/MINOR vs POINT split: release tags come in two shapes. Point
# releases carry a -N suffix (0.3-4, V0.3-9) and are maintenance-only,
# with NO doc-authoring obligation, so they SKIP this gate. Major/minor
# tags have no suffix (0.4, V0.4, 1.0) and MUST pass it.
T='${{ steps.r.outputs.tag }}'
if echo "$T" | grep -qE '^V?[0-9]+\.[0-9]+-[0-9]+$'; then
echo "point release $T — manual-grounding gate SKIPPED (major/minor cuts only)."
exit 0
fi
if ! echo "$T" | grep -qE '^V?[0-9]+\.[0-9]+$'; then
echo "tag $T is not a bare major/minor tag — skipping manual-grounding."
exit 0
fi
echo "major/minor cut $T — running the manual-grounding gate."
# Render compat-surface.json from the register AT THIS TAG, in a python
# container (no host installs), then check the site manuals against it.
[ -d vmsdrift ] || git clone --depth 1 --branch "$T" https://github.com/3dl-dev/vms vmsdrift
# The Compatibility Surface Register post-dates some early tags. You
# cannot ground a manual against a register that did not exist at the
# cut, so if the generator is absent at this tag, skip rather than fail.
if [ ! -f vmsdrift/tools/compat/render_compat.py ]; then
echo "no compat register at $T (predates docs/compat/) — grounding SKIPPED."
exit 0
fi
docker run --rm -v "$PWD/vmsdrift:/w" -w /w python:3.12-slim \
sh -c 'pip install --quiet pyyaml && python3 tools/compat/render_compat.py'
docker run --rm -v "$PWD:/site" -w /site python:3.12-slim \
python3 tools/check_manual_grounding.py \
--surface vmsdrift/build/compat-surface.json \
--manual docs/installation/index.html \
--version "$T"
- name: Cascade the version labels + release feed (INDEPENDENT of the demo capture)
if: steps.r.outputs.skip != '1'
run: |
# The product-edition version shown on the site (SPD line, "Now on …")
# and the Atom release feed follow the RELEASE TAG, not the demo image.
# These are cheap text/data regens and MUST advance the moment a release
# ships — even when the expensive, fragile demo capture is blocked (e.g.
# a cut whose first boot install-reboots and can't be cleanly snapshot-
# ted). Keeping this OUT of the capture-gated commit below is what stops
# the "site still says V0.5 because the demo couldn't be captured" drift.
# The demo's OWN badge (data-demo-version) tracks DEPLOYED_TAG and is
# rewritten only when a clean capture actually advances the demo.
T='${{ steps.r.outputs.tag }}'
# Atom feed — derived from vms git tags (reuse reconcile.py, do not fork).
rm -rf vmsfeed
git clone --quiet --filter=blob:none https://github.com/3dl-dev/vms vmsfeed
git -C vmsfeed fetch --tags --quiet
python3 vmsfeed/tools/roadmap/reconcile.py --feed-only --site-dir "$PWD"
rm -rf vmsfeed
# Product-edition spans in the landing page + the documentation-set manuals.
sed -i "s#\(data-ovmx-version>\)[^<]*\(</span>\)#\1${T}\2#g" index.html || true
for m in docs/installation/index.html; do
[ -f "$m" ] && sed -i "s#\(data-ovmx-version>\)[^<]*\(</span>\)#\1${T}\2#g" "$m" || true
done
git config user.name 'ovmx-release-bot'; git config user.email 'bot@3dl.dev'
git add index.html docs/installation/index.html atom.xml
if git diff --cached --quiet; then echo "labels/feed already current for $T"; exit 0; fi
git commit -m "site: track OVMX ${T} — version labels + release feed"
for i in 1 2 3 4 5; do
git pull --rebase --autostash origin main && git push origin HEAD:main && exit 0
echo "push $i rejected, retrying…"; sleep 5
done
echo "push failed after retries"; exit 1
- name: Obtain boot assets (prefer published Release, else build from source)
if: steps.r.outputs.skip != '1' && steps.r.outputs.capture_skip != '1'
env: { GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }
run: |
T='${{ steps.r.outputs.tag }}'; mkdir -p rel
if gh release download "$T" --repo 3dl-dev/vms -D rel \
-p vmlinuz -p initramfs-ovmx-slim.cpio.gz -p ovmx-distrib.img 2>/dev/null \
&& [ -f rel/ovmx-distrib.img ]; then
echo "using PUBLISHED release assets for $T (no build needed)"
else
echo "no release assets for $T — building the bootable image from source"
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /opt/hostedtoolcache/CodeQL || true
git clone --depth 1 --branch "$T" https://github.com/3dl-dev/vms vmssrc
( cd vmssrc && docker build -f distro/Dockerfile.bootable -t ovmx-boot . )
cid=$(docker create ovmx-boot)
docker cp "$cid:/boot/vmlinuz" rel/vmlinuz
docker cp "$cid:/boot/initramfs-ovmx-slim.cpio.gz" rel/initramfs-ovmx-slim.cpio.gz
docker cp "$cid:/boot/ovmx-distrib.img" rel/ovmx-distrib.img
docker rm "$cid" >/dev/null
fi
ls -la rel
- name: Install qemu-img + Playwright
if: steps.r.outputs.skip != '1' && steps.r.outputs.capture_skip != '1'
run: |
sudo apt-get update && sudo apt-get install -y qemu-utils
npm init -y >/dev/null 2>&1 && npm i -D playwright@1.47.0
npx playwright install --with-deps chromium
- name: Download our qemu-wasm binary (fork Release)
if: steps.r.outputs.skip != '1' && steps.r.outputs.capture_skip != '1'
env: { GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }
run: |
mkdir -p qw
gh release download --repo 3dl-dev/qemu-wasm -D qw \
-p qemu-system-x86_64.wasm -p out.js -p qemu-system-x86_64.worker.js
- name: Assemble capture harness + capture snapshot
if: steps.r.outputs.skip != '1' && steps.r.outputs.capture_skip != '1'
run: |
H=cap; mkdir -p "$H/assets"
cp tools/webdemo/capture-index.html "$H/index.html"
cp qw/qemu-system-x86_64.wasm qw/out.js qw/qemu-system-x86_64.worker.js "$H/"
cp boot/load-rom.js boot/load-rom.data boot/coi-serviceworker.js "$H/"
cp boot/assets/xterm.js boot/assets/xterm.css boot/assets/xterm-pty.js "$H/assets/"
cp rel/vmlinuz "$H/vmlinuz"; cp rel/initramfs-ovmx-slim.cpio.gz "$H/initramfs-ovmx.cpio.gz"
qemu-img convert -O qcow2 rel/ovmx-distrib.img "$H/distrib.qcow2"
gzip -9 -c "$H/distrib.qcow2" > "$H/sysdisk.qcow2.gz"; rm -f "$H/distrib.qcow2"
node tools/webdemo/coi-server.js "$H" 8099 &
sleep 2
node tools/webdemo/capture.js http://localhost:8099 snap.qcow2
gzip -9 -c snap.qcow2 > sysdisk.qcow2.gz && ls -la sysdisk.qcow2.gz
- name: Verify the demo resumes INTO the startup dialog — else auto-pin (try-then-honestly-pin)
id: gate
if: steps.r.outputs.skip != '1' && steps.r.outputs.capture_skip != '1'
run: |
# Assemble EXACTLY what would be committed: the real demo boot/ page +
# the fresh binaries + the just-captured snapshot. Then prove the resume
# replays the OVMX startup (not a snapshot stuck at Username, and that
# it loadvm's in wasm and the reveal works).
#
# TRY-THEN-HONESTLY-PIN: if the gate FAILS, this tag genuinely can't be
# captured — so instead of failing red every 3h (or a human preemptively
# hand-pinning, which silently froze the demo at V0.5-5 for two releases),
# AUTO-WRITE an honest boot/DEMO_PIN.json (demo stays at DEPLOYED, this tag
# blocked, with the REAL gate error as the reason) and commit it. The pin
# is thus only ever a CONSEQUENCE of a real failure, never preemptive; a
# later passing capture removes it (see the commit step).
rm -rf ver; cp -r boot ver
cp qw/qemu-system-x86_64.wasm qw/out.js qw/qemu-system-x86_64.worker.js ver/
cp rel/vmlinuz ver/vmlinuz
cp rel/initramfs-ovmx-slim.cpio.gz ver/initramfs-ovmx.cpio.gz
cp sysdisk.qcow2.gz ver/sysdisk.qcow2.gz
node tools/webdemo/coi-server.js ver 8098 &
sleep 2
if node tools/webdemo/verify.js http://localhost:8098 2>gate.err; then
echo "resume-gate PASSED"; echo "gate_ok=1" >> "$GITHUB_OUTPUT"
else
DEPLOYED=$(cat boot/DEPLOYED_TAG 2>/dev/null || echo unknown)
TAG='${{ steps.r.outputs.tag }}'
REASON="Capture resume-gate failed $(date -u +%Y-%m-%d): the qemu-wasm demo did not resume into the OVMX startup/Username: from a fresh ${TAG} capture. Last error: $(tail -c 240 gate.err | tr '\n"' ' ')"
TRACK="Auto-written by track-release.yml on a REAL resume-gate failure. The demo honestly stays at ${DEPLOYED} until ${TAG} is capturable. Do NOT hand-add a pin preemptively — only an actual failure writes this; a passing capture removes it."
python3 -c 'import json,sys; json.dump({"pinned_to":sys.argv[1],"blocked_from":sys.argv[2],"reason":sys.argv[3],"tracking":sys.argv[4]}, open("boot/DEMO_PIN.json","w"), indent=2)' "$DEPLOYED" "$TAG" "$REASON" "$TRACK"
git config user.name 'ovmx-release-bot'; git config user.email 'bot@3dl.dev'
git add boot/DEMO_PIN.json
git commit -m "demo: auto-pin at ${DEPLOYED} — ${TAG} capture failed the resume-gate (honest try-then-pin)"
for i in 1 2 3 4 5; do
git pull --rebase --autostash origin main && git push origin HEAD:main && break
echo "pin push $i rejected, retrying…"; sleep 5
done
echo "gate_ok=0" >> "$GITHUB_OUTPUT"
echo "::warning::resume-gate FAILED for ${TAG}; demo auto-pinned at ${DEPLOYED} (honest). See boot/DEMO_PIN.json."
fi
- name: Regenerate the social unfurl from THIS release's live console
if: steps.r.outputs.skip != '1' && steps.r.outputs.capture_skip != '1' && steps.gate.outputs.gate_ok == '1'
# The social card is COSMETIC and must never gate the demo deploy. It is a
# fresh COLD boot (no snapshot), so a slow release's STARTUP.COM can trip
# glamor's timeout even though the demo the visitor sees — a RESUME the
# verify gate already proved reaches Username: — is fine. Without this, a
# bare `if:` is implicitly `success() && …`, so a failed card here poisons
# success() and SKIPS the independent commit step below (which shares the
# same gate_ok guard, not this step's outcome). Decouple it: a stale/absent
# card is acceptable; a demo stuck a release behind for a cosmetic reason is
# not. Give the cold boot generous headroom too, so the card usually lands.
continue-on-error: true
env:
GLAMOR_TIMEOUT_S: '600'
run: |
# Frame a REAL screenshot of this release's running console (the live
# SHOW USERS view) into the 1200x630 social card. Drives the SAME
# assembled 'ver/' demo the verify step just proved boots, so the
# unfurl always shows exactly what deploys — nothing mocked. Version and
# node in the card are read off the running guest.
cp tools/webdemo/glamor-index.html ver/glamor-index.html
node tools/webdemo/coi-server.js ver 8097 &
sleep 2
node tools/webdemo/glamor.js http://localhost:8097 og-demo.png
ls -la og-demo.png
- name: Update boot payload + commit
if: steps.r.outputs.skip != '1' && steps.r.outputs.capture_skip != '1' && steps.gate.outputs.gate_ok == '1'
run: |
cp qw/qemu-system-x86_64.wasm qw/out.js qw/qemu-system-x86_64.worker.js boot/
cp rel/vmlinuz boot/vmlinuz
cp rel/initramfs-ovmx-slim.cpio.gz boot/initramfs-ovmx.cpio.gz
cp sysdisk.qcow2.gz boot/sysdisk.qcow2.gz
# This step is reached only after verify.js proved the fresh capture
# resumes into the startup dialog AND the Username: prompt — so the demo
# image now genuinely boots this tag. Advance DEPLOYED_TAG accordingly.
echo '${{ steps.r.outputs.tag }}' > boot/DEPLOYED_TAG
TAG=$(cat boot/DEPLOYED_TAG)
# PAYLOAD_VER is the cache-buster for the demo PAYLOAD, so it is keyed to
# DEPLOYED_TAG (the image just captured), not the product edition.
VER="${TAG}-${{ github.run_number }}"
sed -i "s/const PAYLOAD_VER = '[^']*'/const PAYLOAD_VER = '${VER}'/" boot/qemu-worker.js || true
# The VISIBLE demo badge on the landing page states what the demo really
# boots (distinct from the product-edition data-ovmx-version, cascaded
# earlier). It follows DEPLOYED_TAG so it can never overstate the demo.
sed -i "s#\(data-demo-version>\)[^<]*\(</span>\)#\1${TAG}\2#g" index.html || true
# The demo now matches this release, so any deliberate demo-lag pin no
# longer applies — remove it (cascade-verify treats deployed==latest as
# current regardless, and a stale pin would only confuse a later reader).
git rm -q --ignore-unmatch boot/DEMO_PIN.json || rm -f boot/DEMO_PIN.json
git config user.name 'ovmx-release-bot'; git config user.email 'bot@3dl.dev'
git add boot index.html og-demo.png
if git diff --cached --quiet; then echo "no change"; exit 0; fi
git commit -m "demo: auto-track OVMX ${{ steps.r.outputs.tag }}"
# rebase-and-retry so a concurrent push to main doesn't lose the deploy
for i in 1 2 3 4 5; do
git pull --rebase --autostash origin main && git push origin HEAD:main && exit 0
echo "push $i rejected, retrying…"; sleep 5
done
echo "push failed after retries"; exit 1