track-release #1896
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: 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 |