Phase log 68: roadmap R02 (project phase 45), CI release gating and speed
Date 2026-10-10. Start commit b989266 (0.42.5; the maintainer had pushed R01 and W1 meanwhile). Nothing committed, staged or pushed; no workflow has run on GitHub. Mapping R02 to 45 is in STATUS.md and PHASES.md (next free number is 46).
Goal
Make it impossible to publish a release from a red commit, give WireGuard, nginx and Compose real validators, and shorten the Windows critical path without weakening a check. Scope items 1 to 9 of the session records/roadmap/phases/02_ci_release_gating_and_speed.md. Mid-session the maintainer pasted three failures from the first push (below); those were fixed first.
The three CI failures from the push of b989266 (fixed, not R02 scope)
- Docs workflow, step "Assemble the site":
ENOENT ... website/assets/shots/manifest.json. And 2. Frontend job, testfills every placeholder, marks the current nav item...: same error. Cause:.gitignoreline 69 isshots/, which also ignoreswebsite/assets/shots/, so the WebP images and the manifest were never committed (git ls-files website | grep -c shotswas 0;git check-ignore -vnamed the rule). The pasted advice (create an empty manifest, make the build tolerate a missing one) would have hidden a site with no images. Fix: the folder is nowwebsite/assets/screens/(not ignored),build-site.mjs,site-shots.sh,index.htmland the tests follow, and two new tests ("has every local asset a page or the stylesheet names", "has a screenshot manifest that matches the images on disk") fail on a clean checkout exactly as CI did (reproduced by hiding the folder: 3 failures; restored: green). The ignore rule itself is untouched (the maintainer's.gitignore; renaming the folder is the robust fix). - CI, Rust (windows-latest):
history::tests::a_save_that_changes_a_config_is_snapshotted_in_the_backgroundpanickedcalled Result::unwrap() on an Err value: "database is locked"atunv-server/src/history.rs:125. The pasted analysis called it a timing flake in the background snapshot. It is deterministic setup: line 125 isopen_db(...)inside the test helperhub(), which named its temp folder fromSystemTime::now()nanoseconds with no per-test tag. Windows' clock ticks coarsely, so tests that start in the same instant (the log shows the failure at the very start of the run, 18:27:25) got the same folder and the samevault.db, and one hit a locked database. Fix:hub_dir_name()adds the pid and a process-wide counter; testhubs_created_in_the_same_clock_tick_never_share_a_folderfeeds a frozen clock 200 times. Ran the history tests three times locally (6 passed each) and clippy onunv-server. I audited the otheras_nanos()temp-dir helpers: all carry a per-test tag, a pid, or serve a single test function, so onlyhub()was exposed. Not reproduced on Windows (no Windows machine); the mechanism is argued from the log line and the code, so treat it as the probable cause until a Windows run is green.
Re-verified claims (plan vs code)
ci.ymlwas onerustjob per OS running test,cargo check --all-targets, fmt, clippy, deny, bundled check in sequence: true. Last fully green Windows run (ffe3c2b, run 37992936583): job 32 min, Test 18, Check the whole workspace 10. Other recent Windows runs on main: 7 to 19 min, all failing or cancelled early (b989266 5 min Test then fail; 5ea7516 18; 0aa5609 19; 7250c56 13; b408fff 14).build.yml:metadecidedpublish;desktop/headless/dockerneeded onlymeta;dockerpushed three tags withpush: publishand ran its smoke test after the push: true.exporters.ymlhad ssh, apache, haproxy, ansible, postgres, traefik, kubernetes, prometheus, grafana, homepage, grafana-probe; no wireguard, nginx or compose: true. Itspull_requestpath list was a shorter copy of thepushlist (noenrich.rs, no workflow file): true.- All 17 pinned action references were SHA-pinned; 13 ran on Node 20 (checkout v4, setup-node v4, upload/download-artifact v4, configure/deploy-pages, docker/*, release-action, rust-cache, codeql-action v3). Composite ones (rust-toolchain, cosign-installer, lychee, upload-pages-artifact) have no runtime of their own.
cargo-denywascargo installed from source on every run: true.
Changes, file by file
.github/workflows/ci.yml:workflow_callwith agateinput; concurrency groupci-<ref>-gate|pushandcancel-in-progress: !inputs.gate(without the split the gate and the push-triggered CI on one ref would cancel each other);rustreplaced bytestandlint(both Linux and Windows); fmt, deny and the bundled check stay Linux-only; clippy on both;cargo check --workspaceremoved (clippy--all-targetscompiles the same code and reports more); cargo-deny viataiki-e/install-actionpinned tocargo-deny@0.20.2(the manifest lists 0.20.2);frontendjob unchanged apart from pins..github/actions/rust-setup/action.yml(new composite action): Linux packages, the Windows Strawberry Perl/NASM block and its verification, rust-toolchain, rust-cache (key per caller:test/lint, because they build different artefacts), Node,npm ci,vite build. One copy of the long Windows block instead of two..github/workflows/build.yml: newcijob (uses: ./.github/workflows/ci.yml,with: gate: true, only whenbuild == 'true');desktop,headless,dockerneed[meta, ci];releaseneeds[meta, ci, desktop, headless, docker]. Docker:push: false, load: true, smoke test, then a "Push the tags" step (if: publish == 'true') thatdocker pushes version, minor andlatest. No tag exists in the registry until the image started..github/workflows/exporters.yml: jobswireguard,nginx,compose, each with a control that must be refused and the repo's usual "proves nothing" message; one path list for both triggers, extended withsrc/ts/chunks/**,vault-core/src/config_check.rs,unv-cli/src/{exporters,chunks,refs}.rs,tests/cli-parity.test.ts,tests/stack.test.ts..github/workflows/ui-lab.yml(new): Playwright on pull requests touchingsrc/**,index.html,ui-lab/**,playwright.config.ts,vite.config.ts,package*.json; uploads artefacts on failure..github/workflows/docs.yml: cron weekly (17 4 * * 1; it was nightly)..github/workflows/codeql.yml: codeql-action v3 to v4.38.3, checkout..github/dependabot.yml: npm groupsvitest(vitest, @vitest/),tauri(@tauri-apps/),lint(eslint family, prettier, globals); cargo groupstauri,rustcrypto; github-actions covers/and/.github/actions/*(directories;directoryanddirectoriescannot be combined);rusqlite,libsqlite3-sys,argon2ignore major AND minor (the plan said major; in 0.x a minor is the breaking one and these set the on-disk format; patches still arrive). Validated against the schemastoredependabot-2.0.json: 0 errors.- Actions bumped by full SHA with version comment, each checked to run on node24 at its new tag: checkout v7.0.1, setup-node v7.1.0, upload-artifact v7.0.2, download-artifact v8.0.2, configure-pages v6.0.0, deploy-pages v5.0.1, upload-pages-artifact v5.0.0, docker/build-push v7.4.0, login v4.6.0, setup-buildx v4.4.1, release-action v1.21.0, rust-cache v2.9.2, codeql v4.38.3. Left alone on purpose:
sigstore/cosign-installerv3.9.2 (composite; v4 changes the default cosign major and the signature format the release step and the website's verify recipe rely on),dtolnay/rust-toolchain(composite), lychee-action (new, composite). tests/workflows.test.ts(19 tests): the gate graph (release and every build job havecias an ancestor; release's needs line is exact;ci.ymlis callable; gate concurrency differs and is never cancelled; ci.yml asks for no write permission), Docker order (buildpush: false, smoke test beforedocker push, push step conditional on publish), the Windows split (test and lint independent, nocargo check --workspace, nocargo install cargo-deny), validators (three jobs exist, each with a control, push and PR path lists identical, every non-glob path exists), pinning (every third-partyuses:is a 40-hex SHA with a version comment; none of the old Node 20 SHAs remain), weekly docs cron, Dependabot families, ignore rules and nodirectoryplusdirectories..claude/rules/ci-release.md: gate, Docker order, composite action, ui-lab, Dependabot conventions.
Validators, proven locally before they were written into the workflow
Each ran in a container against the repo's real fixtures, then again by extracting the run: blocks from the finished YAML and executing them verbatim:
- compose:
docker compose -f compose.yaml --env-file compose.env config -qok; a file withports: ["not-a-port"]andbogus_keyfails (services.web.bogus_key false schema). Docker Compose v5.6.0 here; the runner ships v2. - nginx (
nginx:1.27-alpinepinned by digestsha256:65645c7b...): the fixture included from a minimalhttp {}main config withssl_certificate_keyat http level and a throwaway cert generated in the container:nginx -tsuccessful; a server block with a missing semicolon and an unknown directive fails. Finding: the exporter writeslisten 443 ssl http2;, which nginx 1.27 reports as deprecated ([warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead). It still works and is accepted by every nginx, so it is a warning, not a failure; changing it means changing both the TypeScript and Rust exporters and the parity fixture, so it is left as a decision (see Next steps). - wireguard: the fixture's key text is placeholder, and
wgaccepts only 32-byte base64 keys, so the job substitutes generated keys and leaves every other line as written, thenwg-quick strip ./good.conf | wg setconf wgparse /dev/stdin. No kernel interface or root is needed:wgparses the whole file before it touches a device, so a syntax error always printsConfiguration parsing errorand a clean parse fails only at the device. Controls: an impossible prefix (AllowedIP is not in the correct format: <ip>/99) and an unknown section (Line unrecognized: [Peerz]) are both refused. Running the step for real caught a bug:wg-quick strip good.conftreats a bare name as an interface under/etc/wireguard; it needs./good.conf. A first attempt that created a real interface (ip link add wg0 type wireguard, privileged container) also worked but was rejected because it depends on the runner kernel. actionlint1.7.12 withshellcheck0.11.0 over every workflow: clean except seven pre-existing SC2034 notes about unused loop variables in older exporters jobs (not touched). It found one issue in my new step (SC2069, redirect order), fixed.
Verification summary
npm run check: eslint, tsc, prettier, Vitest 90 files / 1537 tests (was 1515). cargo fmt --all -- --check clean. cargo clippy -p unv-server --all-targets clean. Mutation checks of the workflow tests (each reverted, files restored byte for byte): releasing without ci in needs (2 failures), Docker push: true (1), nginx control message removed (1), an action pinned by @v7 (1), nightly docs cron (1). All five failed as they should; restored run 19/19.
Timing
Before (measured, ffe3c2b, the last fully green run): Windows job 32 min = Test 18 + Check the whole workspace 10 + about 4 for setup, clippy and the rest, all sequential. After: the Windows test job is setup plus the same 18-minute test run; lint runs beside it; the 10-minute check step no longer exists. Expected critical path about 20 min (minus roughly 12 min, 37 percent). This is an estimate: the new jobs have not run on a runner, and the first run of lint has a cold cache (its own key) so it is slower once. To record the real after, run in pwsh after pushing, three times over three pushes:
gh run list --workflow CI --branch main --limit 3 --json databaseId --jq '.[].databaseId' | ForEach-Object { gh run view $_ --json jobs --jq '.jobs[] | select(.name|test("windows")) | "\(.name): \(((.completedAt|fromdateiso8601)-(.startedAt|fromdateiso8601))/60|floor) min"' }
What did not run, and why
- No workflow ran on GitHub (a push is the maintainer's). So the gate has not been seen blocking a release, only proven structurally (
tests/workflows.test.tsplus the mutation checks). The manual demonstration, as the phase file asks when no push is allowed: on a throwaway branch, addassert!(false)to any test, run Build & Release byworkflow_dispatchwithpublish: trueon that branch, and confirmDesktop,CLI + server,Server imageandPublish releaseshow as skipped withCI gatered, and that no:latestappears in GHCR (gh api /user/packages/container/unenverse%2Funv-server/versions). - The composite action,
taiki-e/install-actionfor cargo-deny, the Windows split andui-lab.ymlhave only been linted and read, not executed on a runner. The Windows history-test fix is not confirmed on Windows. - Windows-specific timing numbers after the change (above).
Next steps
Only the maintainer can: push (watch the first CI and Docs runs; the first Docs run also deploys the site, whose images are now tracked); optionally run the dispatch demonstration above; enable required status checks on main if wanted. Decisions for the maintainer: whether the nginx exporter should emit the http2 on; form for new nginx (the deprecation warning above); whether .gitignore's shots/ should be narrowed. Then R03.
Traps for the next agent
sedwith#as the delimiter over text containing#silently rewrites every line (it corruptedexporters.ymlonce this session; recovered by reversing the exact prefix and verified with a diff that only additions remained). Use Python for multi-line workflow edits.- A job-level
uses:(reusable workflow) cannot also havesteps,runs-onorstrategy; permissions of the caller cap the callee, soci.ymlmust never ask for a write permission (the test checks). wg-quick strip namereads a name as an interface; give it./name.directoryanddirectoriescannot both appear in one Dependabot ecosystem entry.- mdBook-style silent creation is not the only silent failure: a
.gitignorerule that matches a directory name anywhere (shots/) can drop whole folders from a commit without a warning.