Repository navigation
feat(dev): local candidate build/validate/promote loop with rollback (rail 3.8.53, #8084 local side) - #16180
Merged
Conversation
Add scripts/dev/candidate.mjs (npm run dev:candidate), the local side of RFC #8084: build one candidate from the current tree (npm pack) or a prebuilt tarball into an isolated npm prefix under _artifacts/candidate/<id>, boot that packaged CLI on a free port with an isolated DATA_DIR and fake secrets, require /api/health and /v1/models to answer 200, promote the same validated artifact with compensated renames, and roll back automatically when the promoted slot fails its health check. Every command supports --dry-run and --json; child processes use argument arrays only (Hard Rule #13). stopChild is now exported from check-pack-boot.mjs so the loop reuses the same process-group shutdown instead of duplicating it.
The first real validate run against the published 3.8.51 tarball booted and answered /api/health, but /v1/models returned 401: the packaged CLI fills undefined keys from <packageRoot>/.env (postinstall copies .env.example with INITIAL_PASSWORD=CHANGEME), and any password is a credential surface for the model catalog. It also reads the default data dir's .env, derived from HOME/XDG_CONFIG_HOME/APPDATA, i.e. the operator's real ~/.omniroute/.env. The candidate now gets an isolated HOME (plus XDG/APPDATA) under its slot, INITIAL_PASSWORD pinned to an empty value, a fresh DATA_DIR on every validation, and --log so failures show server output. run --dry-run no longer claims the candidate is active.
diegosouzapw
added a commit
that referenced
this pull request
Oct 10, 2026
Brings in #16073 (RELEASE_STRATEGY.md, ROADMAP See-also), #16180 (dev:candidate, RELEASE_CHECKLIST, meta.json), #16172 (dist-tag.mjs and the dormant LTS workflows) and #16066 (Mergify checks_timeout). package.json keeps both release:dry-run-lts-cut and labels:sync. Now that the base carries them, the LTS-cut dry-run uses the real scripts/release/dist-tag.mjs resolver (the built-in fallback is gone), treats a dormant workflow missing at the cut source as NOT READY, and the runbook / GO-NO-GO docs link RELEASE_STRATEGY.md and name the real forward-port secret and nightly publish variable. The G11 fixture is the real .mergify.yml.
Contributor
CI Coverage Report
Coverage artifact was not available for this run. |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This is the local side of RFC #8084 (rail 3.8.53, task 5), exposed as
npm run dev:candidate(scripts/dev/candidate.mjs). It builds one candidate artifact, validates the packaged runtime of that exact artifact, and promotes the same artifact with directory renames. If the promoted slot then fails its health check, it rolls back automatically. It never touches an existing OmniRoute install: every write goes under the gitignored_artifacts/candidate/, unless the operator passes--target.Refs #8084
What changed
scripts/dev/candidate.mjsprovides these subcommands. All of them accept--dry-runand--json.build: runsnpm packon the current tree (requiresdist/server.js) or takes--from-tarball <file>, then runsnpm install -g --prefixinto_artifacts/candidate/<id>/prefix. A plaintar -xcannot boot because the tarball has nonode_modules([BUG] npm global install on macOS Apple Silicon: vendored dist/node_modules/better-sqlite3/prebuilds/ missing darwin-arm64.node #11242); this is the same pathcheck:pack-bootuses. The id is the short HEAD sha (-dirtywhen the tree has local changes) ortgz-<sha256>. A clean id that already exists is reused (build-once);--forcerebuilds it.validate: bootsnode <slot>/…/bin/omniroute.mjs serve --port <free port>with a freshDATA_DIR, an isolatedHOME/XDG/APPDATA, and fake secrets. It removes the operator'sOMNIROUTE_API_KEY/ROUTER_API_KEY/STORAGE_ENCRYPTION_KEYfrom the environment.GET /api/healthmust return 200 andGET /v1/modelsmust return 200 with a list. The process group is stopped afterwards, and the verdict goes tovalidation.json, bound to the tarball sha256.promote: performs the renames previous → discard, current → previous,<id>→ current. If any rename fails, the renames already applied are reversed. It refuses to promote unless there is a passing validation for this tarball hash (--forceoverrides).--target <dir>selects another active slot; the destructive or overlapping targets/, home, the repo and the candidate root are refused.rollback: swaps current and previous.run: build → validate → promote → re-validate the promoted slot → rollback if that check fails. A candidate that fails the first validation is never promoted. On a first promotion into an empty slot, a failure demotes the artifact back instead of rolling back.scripts/check/check-pack-boot.mjs: now exportsstopChildso the loop reuses the same process-group shutdown instead of duplicating it.pickTarballis reused as well. No behavior change.package.json: adds"dev:candidate": "node scripts/dev/candidate.mjs".docs/ops/CONTRIBUTION_GOLDEN_PATH.md, plus an optional smoke line under Artifact Validation indocs/ops/RELEASE_CHECKLIST.md.execFileSync/spawnwith argument arrays; values go throughenv. A static test pins this (noshell: true, noexec(/execSync(, noos.tmpdir()or/tmp).Found by the real run (and fixed in
bd1c6442dd)The first real
validateagainst the publishedomniroute@3.8.51tarball booted and returned 200 from/api/health, but/v1/modelsreturned 401. Validation correctly refused to promote, and the active slot was untouched. There were two causes, both in how the packaged CLI loads.envfiles (it fills any key that is still undefined):<packageRoot>/.env, which postinstall copies from.env.example, containsINITIAL_PASSWORD=CHANGEME. Any password counts as a credential surface for the model catalog (src/app/api/v1/models/catalogRequest.ts). Fix: pinINITIAL_PASSWORD=""(set but falsy) andOMNIROUTE_CLI_SKIP_REPO_ENV=1.<default data dir>/.env, which is derived fromHOME/XDG_CONFIG_HOME/APPDATA. That is the operator's real~/.omniroute/.env. Fix: an isolatedHOMEand XDG/APPDATA under the slot.I checked the isolation empirically: a snapshot of
~/.omniroute(paths, mtimes, sizes) taken before and after a fullvalidateboot shows no changes.Validation
node --import tsx/esm --test tests/unit/dev-candidate-loop.test.tsnode --import tsx/esm --test tests/unit/check-pack-boot.test.ts tests/unit/pack-boot-runtime-paths.test.tsnpm run dev:candidate -- run --from-tarball omniroute-3.8.51.tgz --json(published tarball, realnpm install, real boot)/api/health200 +/v1/models200 → promoted → post-promotion re-check 200/200 (the relocated npm prefix still boots).envfixstage: "validate",/v1/models: HTTP 401; active slot untouchednpm run dev:candidate -- rollbackwith no previous slotnpm run dev:candidate -- run --dry-run --json~/.omniroutearound a realvalidatenpx eslint tests/unit/dev-candidate-loop.test.ts(scripts/**is eslint-ignored by config)npm run typecheck:corenpm run check:dead-codescripts/**node scripts/check/check-test-discovery.mjstests/unit/dev-candidate-loop.test.ts, which is collected)npm run check:doc-links/npm run check:fabricated-docsnpm run check:tracked-artifactsNot run locally, per the campaign rules (shared devbox): the full unit suite and
npm run build. With no localdist/, the real run used--from-tarballwith the published 3.8.51 package; thenpm packbranch ofbuildis covered by unit tests (plan and precondition) only.