Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/ops/CONTRIBUTION_GOLDEN_PATH.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,47 @@ or runtime bundling. Use `npm run build:release` only for release/deploy validat
the final cross-platform signal; platform-specific Electron changes need the matching focused build
or smoke evidence.

## Local candidate loop

Source tests cannot prove that the packaged artifact boots: packaging lists, pruned dependencies,
and native binaries only fail once the tarball is installed and started. `npm run dev:candidate`
(`scripts/dev/candidate.mjs`) is the local side of the build-once / validate / promote flow from
RFC #8084: it builds one candidate, validates that exact artifact, promotes it with directory
renames, and rolls it back when the promoted slot fails its health check.

```bash
npm run dev:candidate -- run --dry-run --json # print the plan, change nothing
npm run dev:candidate -- run # build + validate + promote, auto-rollback
npm run dev:candidate -- build # npm pack + install into _artifacts/candidate/<id>/
npm run dev:candidate -- validate --id <id> # boot on a free port, /api/health + /v1/models
npm run dev:candidate -- promote --id <id> # <id> → current, current → previous
npm run dev:candidate -- rollback # swap current and previous
npm run dev:candidate -- run --from-tarball <file.tgz> # reuse a tarball built elsewhere (CI)
```

- **Build once.** `build` packs the current tree (it needs `dist/server.js`, so run
`npm run build:release` first) or copies `--from-tarball`, then installs the tarball into an
isolated npm prefix, because the tarball carries no `node_modules`. The id is the short `HEAD`
sha (`-dirty` when the tree has local changes) or `tgz-<sha256>` for a tarball. A clean id that
is already built is reused instead of rebuilt; pass `--force` to rebuild it.
- **Validate the package, not the source.** `validate` starts the installed CLI
(`serve --port <free port>`) with a fresh `DATA_DIR=<candidate>/data`, fake secrets, and the
operator's `OMNIROUTE_API_KEY`, `STORAGE_ENCRYPTION_KEY` and `INITIAL_PASSWORD` removed from the
environment, so it checks the keyless loopback posture of a fresh install. It waits for
`GET /api/health` to return 200, requires `GET /v1/models` to return 200, stops the process group,
and records the verdict in `validation.json` together with the tarball hash.
- **Promote the same artifact.** `promote` refuses a candidate without a passing validation for its
current tarball hash. The default active slot is `_artifacts/candidate/current`; `--target <dir>`
selects another directory on the same filesystem, and its previous slot is `<dir>.previous`.
Each rename is atomic, and a failure partway through reverses the renames that already ran.
- **Roll back.** `run` validates the promoted slot again and swaps `current` and `previous` back when
that check fails. A candidate that fails the first validation is never promoted.

Everything is written under the gitignored `_artifacts/candidate/`; an existing OmniRoute
installation and its data directory are never touched. The promoted CLI is
`_artifacts/candidate/current/prefix/bin/omniroute`; start it with your own `DATA_DIR` when you use
it. Exit codes: `0` success, `1` failed validation or promotion, `2` usage error or missing build.

## Local loop versus CI

| Run locally for each patch | CI supplies the broad signal |
Expand Down
1 change: 1 addition & 0 deletions docs/ops/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ Do NOT run `npm run build` followed by a separate `npm run build:cli` for deploy
- [ ] `npm run build:release` succeeds and `dist/BUILD_SHA` == `git rev-parse --short HEAD`
- [ ] `npm run check:pack-artifact` clean — no `app.__qa_backup`, `scripts/scratch`, `package-lock.json`, or other local residue
- [ ] `dist/server.js` exists after build
- [ ] Optional local packaged-runtime smoke: `npm run dev:candidate -- validate` after `npm run dev:candidate -- build` boots the packed tarball on an isolated `DATA_DIR` and checks `/api/health` + `/v1/models` (see [Contribution Golden Path](CONTRIBUTION_GOLDEN_PATH.md#local-candidate-loop))

### Tagging & Release

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"homepage": "https://omniroute.online",
"scripts": {
"dev": "node --max-old-space-size=8192 scripts/dev/run-next.mjs dev",
"dev:candidate": "node scripts/dev/candidate.mjs",
"prebuild:docs": "node scripts/docs/gen-openapi-module.mjs",
"gen:provider-reference": "bun scripts/docs/gen-provider-reference.ts",
"gen:i18n-routes": "node scripts/i18n/generate-route-namespaces.mjs",
Expand Down
5 changes: 3 additions & 2 deletions scripts/check/check-pack-boot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,10 @@ function hasExited(child) {
* reap, throw, so boot #2 cannot start against a port a zombie still holds.
*
* The child is spawned with detached:true, so it leads its own process group and
* -child.pid signals the whole tree, not just the launcher.
* -child.pid signals the whole tree, not just the launcher. Exported for reuse by the
* local candidate loop (scripts/dev/candidate.mjs), which boots packaged artifacts the same way.
*/
async function stopChild(child, graceMs = 30_000) {
export async function stopChild(child, graceMs = 30_000) {
if (!child?.pid) return;
// Fast path: already reaped (crashed mid-smoke, or exited before this call) — nothing
// left to signal or wait for.
Expand Down
Loading
Loading