diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa35433..409a809 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -157,10 +157,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust toolchain - shell: bash - run: rustup show active-toolchain || rustup toolchain install - - name: Resolve version id: ver run: | @@ -224,66 +220,68 @@ jobs: --verify-tag fi - - name: Setup Node - uses: actions/setup-node@v4 + # Packaging and the npm publish are owned by the shared action, which + # also verifies what the registry serves afterwards. It reads + # artifacts//quire[.exe] (the layout normalized above). + # static-musl: the Linux binaries are fully static musl builds that run + # on glibc as well, so their packages carry no libc restriction. + # Public npm via OIDC Trusted Publishing (id-token: write above); the + # NPM_TOKEN secret is only a first-publish bootstrap and may be unset. + - name: Package and publish the npm distribution + uses: agent-ix/nodejs-actions/publish-native-npm@main with: - node-version: "22" # OIDC Trusted Publishing requires Node >= 22.14 + package: "@agent-ix/quire-cli" + binary: quire + version: ${{ steps.ver.outputs.version }} + artifacts-dir: artifacts + repository: ${{ github.repository }} + description: "Static CLI over quire-rs (parse, extract, lookup, edit, validate, schema, lint) for Markdown artifacts." + self-update-command: update + static-musl: "true" + npm-token: ${{ secrets.NPM_TOKEN }} + publish: ${{ inputs.publish }} - # OIDC Trusted Publishing needs npm >= 11.5.1. - - name: Upgrade npm for Trusted Publishing - run: npm install -g npm@11 - - # Public npm via OIDC: route @agent-ix at registry.npmjs.org with no auth - # token. `npm publish` performs the tokenless OIDC token exchange (the - # id-token: write permission above supplies the GitHub OIDC token). - # Written to $HOME so every `npm publish` (run from each package dir) picks it up. - # Public npm. Steady state is tokenless OIDC (id-token: write above). The - # NPM_TOKEN fallback exists only to BOOTSTRAP: npm requires a package to - # exist before a trusted publisher can be attached, and these 5 packages' - # binaries can only be built by this CI matrix. Add NPM_TOKEN as a repo - # secret for the first release, configure the trusted publishers on - # npmjs.com, then DELETE the secret — every later release uses OIDC. - # provenance=false: this repo is private, where provenance is unsupported. - - name: Configure npm for public npm + - name: Smoke the published npm packages + if: inputs.publish + shell: bash env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} run: | - if [ -n "$NPM_TOKEN" ]; then - printf 'registry=https://registry.npmjs.org/\n@agent-ix:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=%s\nprovenance=false\n' "$NPM_TOKEN" > "$HOME/.npmrc" - else - printf 'registry=https://registry.npmjs.org/\n@agent-ix:registry=https://registry.npmjs.org/\nprovenance=false\n' > "$HOME/.npmrc" - fi + set -euo pipefail - - name: Generate per-platform npm packages - run: | - cargo run --locked -p quire-dist -- verify-release \ - --version "${{ steps.ver.outputs.version }}" - cargo run --locked -p quire-dist -- package-npm \ - --version "${{ steps.ver.outputs.version }}" \ - --artifacts-dir artifacts + PREFIX="$(mktemp -d)" + NPMRC="$(mktemp)" + printf '@agent-ix:registry=https://registry.npmjs.org/\n' > "$NPMRC" + export NPM_CONFIG_USERCONFIG="$NPMRC" + export NPM_CONFIG_PREFIX="$PREFIX" + export PATH="$PREFIX/bin:$PATH" - - name: Publish to public npm - if: inputs.publish - run: | - set -e - # Platform packages first so the launcher's optionalDependencies resolve. - for pkg in npm/dist/*/; do - echo "publishing $pkg" - (cd "$pkg" && npm publish) + # Public npm can take a little while to serve a just-published + # version; retry with backoff rather than failing on propagation. + attempt=0 + max_attempts=8 + until npm install --global "@agent-ix/quire-cli@${VERSION}"; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge "$max_attempts" ]; then + echo "::error::npm install of @agent-ix/quire-cli@${VERSION} did not succeed after $attempt attempts" >&2 + exit 1 + fi + sleep 15 done - echo "publishing launcher" - (cd npm/quire-cli && npm publish) - # PLAT-885: package-npm only ever checked what it wrote locally. - # Publishing is a separate step that can partially fail (the launcher's - # own `npm publish` erroring after the platform packages already - # succeeded) or diverge from local state entirely -- this asserts what - # the registry actually now serves, reading the launcher's published - # optionalDependencies from the registry itself, not from the working - # tree. - - name: Verify published packages resolve - if: inputs.publish - run: | - cargo run --locked -p quire-dist -- verify-published \ - --version "${{ steps.ver.outputs.version }}" \ - --registry https://registry.npmjs.org/ + actual_version="$(quire --version | cut -d' ' -f2)" + if [ "$actual_version" != "$VERSION" ]; then + echo "::error::quire --version printed '$actual_version', expected '$VERSION'" >&2 + exit 1 + fi + + if quire update 2>update.err; then + echo "::error::quire update exited 0 under an npm install; it must defer to npm instead" >&2 + cat update.err >&2 + exit 1 + fi + if ! grep -q "npm install -g @agent-ix/quire-cli@latest" update.err; then + echo "::error::quire update's stderr did not name the exact npm command to run" >&2 + cat update.err >&2 + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca39ba..3f25841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ output schemas (see `spec/non-functional/NFR-006-cli-stability.md`). ## [Unreleased] +- **npm publishing moves to the shared `publish-native-npm` action (PLAT-1012).** `release.yml` now publishes `@agent-ix/quire-cli` and its four platform packages through `agent-ix/nodejs-actions/publish-native-npm` (`static-musl`, so the Linux packages carry no libc restriction). The repository-owned launcher `npm/quire-cli/bin/quire.js`, `quire-dist package-npm`, `verify-published`, `set-version` and `verify-release`, and the `make dist-package`, `dist-verify`, `dist-verify-published` and `set-version` targets are removed; `quire-dist verify-binary` stays. The documented local `npm.ix` publish path, which depended on those commands, is removed. FR-023, FR-024 and NFR-008 are removed, FR-022 and US-007 are rewritten, and ADR-0003 supersedes ADR-0002. + - **Breaking: digests and hashes removed from outputs.** `quire properties --json` no longer emits `statement_hash` on a criterion obligation. The `quire assurance` v1 export locator is `{path, line}` only (no `digest`), obligations carry no `statement_hash`, and symbol ids are JSON-array identity parts rather than SHA-256 hex. `quire clauses` human binding output no longer prints a clause-set digest. Follows quire-rs assurance-v1 edited in place. ## [0.38.3] - 2026-11-04 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f5df3c..6a57ea1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,6 @@ ownership in the engine. - Does any new logic belong upstream in `quire-rs`? - Does the change preserve stdout/stderr and exit-code contracts? - Are new behavioral claims backed by executable tests and `make spec`? -- Does npm distribution policy remain in the Rust `quire-dist` tool, with the - ADR-0002 Node launcher limited to transparent host selection and execution? +- Does npm packaging stay in the shared `publish-native-npm` action (ADR-0003), + with no packaging or launcher code in this repository? - Were `make ci` and the relevant release build run locally? diff --git a/Cargo.lock b/Cargo.lock index 62245c8..252058d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,9 +1171,7 @@ dependencies = [ "clap", "ix-trace-rs", "semver", - "serde_json", "tempfile", - "toml_edit", ] [[package]] @@ -1663,19 +1661,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -2032,9 +2017,6 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] [[package]] name = "wit-bindgen" diff --git a/Makefile b/Makefile index 4dc169e..043ef03 100644 --- a/Makefile +++ b/Makefile @@ -17,14 +17,7 @@ help: @echo " make clean - cargo clean" @echo " make deny - cargo deny check (advisories, bans, licenses, sources)" @echo " make audit-unsafe - Enforce // SAFETY: comments on unsafe blocks" - @echo " make dist-test - Test native npm distribution tooling" - @echo " make dist-verify - Verify committed Cargo/npm release versions" - @echo " make dist-package - Build npm packages from artifacts/" - @echo " make dist-verify-published VERSION=X.Y.Z REGISTRY= - Post-publish" - @echo " gate: the published launcher and every platform" - @echo " package its optionalDependencies declares" - @echo " resolve on REGISTRY" - @echo " make set-version VERSION=X.Y.Z - Update Cargo/npm versions" + @echo " make dist-test - Test the release tooling (verify-binary)" @echo " make bench - Latency budget (NFR-001): p95 of a quire invocation ≤ 50 ms (needs hyperfine)" @echo " make ci - All CI gates locally, including specification traceability" @@ -76,27 +69,6 @@ cargo-audit: dist-test: $(CARGO) test --locked -p quire-dist -.PHONY: dist-verify -dist-verify: - $(CARGO) run --locked -p quire-dist -- verify-release --version $(VERSION) - -.PHONY: dist-package -dist-package: - $(CARGO) run --locked -p quire-dist -- package-npm --version $(VERSION) - -# Post-publish gate (PLAT-885): after `npm publish` for every generated -# platform package and the launcher, assert each optionalDependency the -# launcher declares actually resolves at its pinned version on REGISTRY. -# Not part of `make ci` — like dist-verify/dist-package, it runs against a -# real release and a real registry, never in the offline unit-test lane. -.PHONY: dist-verify-published -dist-verify-published: - $(CARGO) run --locked -p quire-dist -- verify-published --version $(VERSION) --registry $(REGISTRY) - -.PHONY: set-version -set-version: - $(CARGO) run --locked -p quire-dist -- set-version --version $(VERSION) - .PHONY: audit-unsafe audit-unsafe: $(CARGO) run --locked -p quire-qualify -- unsafe-comments diff --git a/README.md b/README.md index 58606af..8fa3e40 100644 --- a/README.md +++ b/README.md @@ -363,10 +363,7 @@ make deny # cargo deny check licenses make deny-bans # cargo deny check bans make audit-unsafe # every unsafe block carries a // SAFETY: comment make audit-thin-boundary # src/ stays a thin wrapper over quire-rs -make dist-test # Rust package/launcher tests, including offline npm -make dist-verify VERSION=0.32.0 # check Cargo/npm release-version agreement -make set-version VERSION=0.33.0 # update Cargo/npm versions in lockstep -make dist-package VERSION=0.32.0 # generate npm/dist from artifacts/ +make dist-test # release tooling tests (verify-binary) make refresh-fixtures # re-sync tests/fixtures/iso from ../quire-rs make ci # local CI gauntlet ``` @@ -374,111 +371,16 @@ make ci # local CI gauntlet The release binary is audited to link only baseline system libraries on Linux. See `tests/audit_ldd.rs`. -## Local publish to npm.ix - -This is the procedure for the local, manual publish to the internal `npm.ix` -registry. It exists so the publish is repeatable and the steps are written -down. It is not what keeps a release correct. Two checks in `quire-dist` do -that, whichever way the publish is run: - -- `package-npm` writes the launcher's `optionalDependencies` from the - executables actually present in `artifacts/`, one entry per platform built. - It cannot write a pin for a platform it did not build, so a partial local - build produces a smaller, consistent package set. -- `make dist-verify-published` reads the launcher as the registry serves it - and fails unless every platform package it declares resolves there. - -The manually dispatched `.github/workflows/release.yml` is the other publish -path. It targets public npm, builds all four platforms in one run, and runs the -same `verify-release`, `package-npm` and `verify-published` steps. The commands -below are the local equivalent for one host. - -Prerequisites: a clean checkout of the commit being released, a Rust toolchain -(see `rust-toolchain.toml`), `npm` on `PATH`, and an `~/.npmrc` auth token for -`//npm.ix/`. Set `VERSION` to the release version (`X.Y.Z`, no leading `v`). - -1. Set the version and commit it. `set-version` updates `Cargo.toml` and - `npm/quire-cli/package.json` together. It does not touch `Cargo.lock`, and - the `--locked` builds below need the lock to agree, so refresh the - workspace entries (this does not move any dependency). - - ```bash - make set-version VERSION=$VERSION - cargo update --workspace - make dist-verify VERSION=$VERSION - git commit -am "release: $VERSION" - git status --porcelain # must print nothing - ``` - - Commit before you build. The binary records the source revision and - whether the tree was clean, and `0.32.2` shipped a binary that reported a - dirty tree and a commit that no longer existed (see `CHANGELOG.md`, - `0.32.3`). - -2. Build the release binary for each platform this machine can build, and - confirm it reports `VERSION`. Use the Rust target names from `TARGETS` in - `tools/quire-dist/src/lib.rs`; `aarch64-apple-darwin` is the example here. - - ```bash - TARGET=aarch64-apple-darwin - cargo build --locked --release --target $TARGET - cargo run --locked -p quire-dist -- verify-binary \ - --version $VERSION --binary target/$TARGET/release/quire - ``` - -3. Stage each binary as `artifacts//quire` (`quire.exe` for - Windows). The directory must hold only that file, and only catalog target - names are accepted. - - ```bash - rm -rf artifacts && mkdir -p artifacts/$TARGET - cp target/$TARGET/release/quire artifacts/$TARGET/quire - ``` - -4. Generate the npm packages. `package-npm` checks each executable's format - and rewrites `npm/quire-cli/package.json` (so the working tree is dirty - after this step) and `npm/dist/`. - - ```bash - make dist-test - make dist-package VERSION=$VERSION - git diff npm/quire-cli/package.json # optionalDependencies = platforms built - ``` - -5. Publish the platform packages, then the launcher. Every generated - `package.json` carries `publishConfig.registry` set to - `https://registry.npmjs.org/`, and `~/.npmrc` may scope `@agent-ix` to a - different registry. Pass the scope override explicitly, as `quire-dist` - itself does when it queries a registry, and run `--dry-run` first to read - the target registry from npm's own output before the real publish. - - ```bash - REGISTRY=http://npm.ix/ - for pkg in npm/dist/*/; do - (cd "$pkg" && npm publish --dry-run --@agent-ix:registry=$REGISTRY) - done - (cd npm/quire-cli && npm publish --dry-run --@agent-ix:registry=$REGISTRY) - # when the dry runs name $REGISTRY, repeat without --dry-run - ``` - - Platform packages go first so the launcher's `optionalDependencies` - resolve as soon as it appears. - -6. Check what the registry now serves. - - ```bash - make dist-verify-published VERSION=$VERSION REGISTRY=$REGISTRY - ``` - - A failure here means the registry and the package set disagree. Do not - reuse the version; publish a new one. - -7. Restore the working tree. `npm/dist/` is ignored; delete `artifacts/`. The - rewritten `npm/quire-cli/package.json` lists only the platforms this run - built, so do not commit it; `git checkout npm/quire-cli` restores it. - -This procedure has not been walked end to end against a live registry as -written. Record the first full run here, including any step that had to change. +## Releasing + +The manually dispatched `.github/workflows/release.yml` builds the four target +binaries, creates the GitHub Release, and publishes to public npm through the +shared `agent-ix/nodejs-actions/publish-native-npm` action (ADR-0003). The +action owns package layout, the `npm publish` calls and the post-publish +registry check; this repository carries no npm packaging code. Set the version +in `Cargo.toml`, refresh the lock with `cargo update --workspace`, commit, and +dispatch the workflow with `publish` left false for a dry run first. Each built +binary is checked with `quire-dist verify-binary --version X.Y.Z --binary `. ## Spec And Plan diff --git a/npm/.gitignore b/npm/.gitignore deleted file mode 100644 index aae2d13..0000000 --- a/npm/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Generated per-platform packages (built from CI artifacts at release time). -dist/ - -# LICENSE copied into the launcher by the Rust quire-dist tool at release time. -quire-cli/LICENSE diff --git a/npm/quire-cli/README.md b/npm/quire-cli/README.md deleted file mode 100644 index 96a24d4..0000000 --- a/npm/quire-cli/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# @agent-ix/quire-cli - -Prebuilt distribution of [`quire`](https://github.com/agent-ix/quire-cli), a -static CLI over [`quire-rs`](https://github.com/agent-ix/quire-rs) for parsing, -extracting, looking up, editing, validating, and inspecting Markdown artifacts. - -This npm package is a thin launcher. The actual binary ships in a per-platform -optional dependency (`@agent-ix/quire-cli--`); npm installs only the -one matching your machine. No source build and no `quire-rs` checkout is -required. - -## Install - -This package is published to **GitHub Packages**, so point the `@agent-ix` scope -at that registry. In `.npmrc`: - -``` -@agent-ix:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} -``` - -Then: - -```bash -npm install -g @agent-ix/quire-cli -quire --help -``` - -Or run without installing: - -```bash -npx @agent-ix/quire-cli parse README.md -``` - -## Supported platforms - -| os / arch | Rust target | -|-----------------|---------------------------------| -| linux-x64 | `x86_64-unknown-linux-musl` | -| linux-arm64 | `aarch64-unknown-linux-musl` | -| darwin-arm64 | `aarch64-apple-darwin` | -| win32-x64 | `x86_64-pc-windows-msvc` | - -Linux x64 covers both Intel and AMD servers; win32-x64 covers both Intel and AMD -Windows machines. macOS support targets Apple Silicon. The Linux binaries are -statically linked against musl. - -See the [main repository](https://github.com/agent-ix/quire-cli) for command -documentation. diff --git a/npm/quire-cli/bin/quire.js b/npm/quire-cli/bin/quire.js deleted file mode 100644 index f958152..0000000 --- a/npm/quire-cli/bin/quire.js +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -// Thin launcher: resolve the prebuilt `quire` binary that matches this -// platform/arch (shipped as an optional dependency) and exec it transparently, -// forwarding argv, stdio, and the exit status / terminating signal. - -const { spawnSync } = require("node:child_process"); -const fs = require("node:fs"); -const packageJson = require("../package.json"); - -const PLATFORM = process.platform; // 'linux' | 'darwin' | 'win32' | ... -const ARCH = process.arch; // 'x64' | 'arm64' | ... -const PACKAGE_PREFIX = "@agent-ix/quire-cli-"; -const OPTIONAL_DEPENDENCIES = packageJson.optionalDependencies || {}; - -function resolveBinary() { - const key = `${PLATFORM}-${ARCH}`; - const pkg = `${PACKAGE_PREFIX}${key}`; - if (!Object.prototype.hasOwnProperty.call(OPTIONAL_DEPENDENCIES, pkg)) { - const supported = Object.keys(OPTIONAL_DEPENDENCIES) - .filter((name) => name.startsWith(PACKAGE_PREFIX)) - .map((name) => name.slice(PACKAGE_PREFIX.length)) - .sort(); - throw new Error( - `quire-cli: unsupported platform "${key}".\n` + - `Prebuilt binaries exist for: ${supported.join(", ")}.\n` + - `Build from source instead: https://github.com/agent-ix/quire-cli` - ); - } - const binName = PLATFORM === "win32" ? "quire.exe" : "quire"; - try { - return require.resolve(`${pkg}/bin/${binName}`); - } catch (_) { - throw new Error( - `quire-cli: the prebuilt binary package "${pkg}" is not installed.\n` + - `It is an optional dependency — reinstall without --no-optional, or\n` + - `add "${pkg}" explicitly. (--ignore-optional / CPU-VM mismatches skip it.)` - ); - } -} - -let bin; -try { - bin = resolveBinary(); -} catch (err) { - process.stderr.write(String(err.message) + "\n"); - process.exit(1); -} - -// npm does not always preserve the executable bit through publish/install; -// best-effort restore it on POSIX before exec. -if (PLATFORM !== "win32") { - try { - fs.chmodSync(bin, 0o755); - } catch (_) { - /* non-fatal: the file may already be executable or read-only */ - } -} - -const result = spawnSync(bin, process.argv.slice(2), { stdio: "inherit" }); - -if (result.error) { - process.stderr.write(`quire-cli: failed to launch binary: ${result.error.message}\n`); - process.exit(1); -} -if (result.signal) { - // Re-raise the child's terminating signal so callers observe the same - // signal-driven exit (e.g. SIGABRT -> 134 on a panic, per FR-007). - process.kill(process.pid, result.signal); -} -process.exit(result.status === null ? 1 : result.status); diff --git a/npm/quire-cli/package.json b/npm/quire-cli/package.json deleted file mode 100644 index 29e8878..0000000 --- a/npm/quire-cli/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "author": "Agent IX ", - "bin": { - "quire": "bin/quire.js" - }, - "description": "Static CLI over quire-rs (parse, extract, lookup, edit, validate, schema, lint) for Markdown artifacts.", - "engines": { - "node": ">=16" - }, - "files": [ - "bin/", - "LICENSE" - ], - "homepage": "https://github.com/agent-ix/quire-cli#readme", - "keywords": [ - "quire", - "cli", - "markdown", - "spec", - "filament", - "agent-ix" - ], - "license": "AGPL-3.0-or-later", - "name": "@agent-ix/quire-cli", - "optionalDependencies": { - "@agent-ix/quire-cli-darwin-arm64": "0.38.3", - "@agent-ix/quire-cli-linux-arm64": "0.38.3", - "@agent-ix/quire-cli-linux-x64": "0.38.3", - "@agent-ix/quire-cli-win32-x64": "0.38.3" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/agent-ix/quire-cli.git" - }, - "type": "commonjs", - "version": "0.38.3" -} diff --git a/spec/assets/adr/0002-minimal-node-npm-launcher.md b/spec/assets/adr/0002-minimal-node-npm-launcher.md index 00544c2..18b1093 100644 --- a/spec/assets/adr/0002-minimal-node-npm-launcher.md +++ b/spec/assets/adr/0002-minimal-node-npm-launcher.md @@ -6,7 +6,7 @@ type: ADR # ADR 0002: Retain a minimal Node host for the cross-platform npm launcher -**Status**: decided (v1) — owner-approved 2026-09-09 +**Status**: superseded by [ADR-0003](./0003-publish-npm-through-shared-action.md) (originally decided v1, owner-approved 2026-09-09) **Date**: 2026-09-09 **Decision authority**: repository owner diff --git a/spec/assets/adr/0003-publish-npm-through-shared-action.md b/spec/assets/adr/0003-publish-npm-through-shared-action.md new file mode 100644 index 0000000..8da46bd --- /dev/null +++ b/spec/assets/adr/0003-publish-npm-through-shared-action.md @@ -0,0 +1,35 @@ +--- +id: ADR-0003 +title: "Publish npm through the shared native-npm action" +type: ADR +--- + +# ADR 0003: Publish npm through the shared native-npm action + +**Status**: decided (v1) — supersedes ADR-0002 +**Date**: 2026-11-08 +**Decision authority**: repository owner (PLAT-1012) + +## Context + +ADR-0002 kept a repository-owned Node launcher and a Rust `quire-dist` tool +that generated and verified the npm packages. Since 2026-09-23 +`agent-ix/nodejs-actions/publish-native-npm` does the same for every native +Agent IX CLI (quoin already uses it), so two implementations would drift. + +## Decision + +Publish `@agent-ix/quire-cli` and its platform packages through +`publish-native-npm`, with `self-update-command: update` and +`static-musl: "true"` (the Linux builds are static musl). The repository keeps +no launcher, no package generator and no registry verifier. `quire-dist` keeps +only `verify-binary`, a pre-publish check on the built executables. + +## Consequences + +- The npm channel still requires Node, as npm itself does; the launcher is the + action's. +- The launcher contract (argument and stream forwarding, signal mirroring, + platform selection, the `update` hint) is owned and tested by the action. +- Package layout, the post-publish registry check and the target catalog are no + longer specified here. diff --git a/spec/assets/adr/index.md b/spec/assets/adr/index.md index 140386d..77f66a6 100644 --- a/spec/assets/adr/index.md +++ b/spec/assets/adr/index.md @@ -9,3 +9,4 @@ description: "Index of artifacts in this directory." * [0001 Validate Lazy-Init Module Bootstrap](./0001-validate-lazy-init-module-bootstrap.md) * [0002 Retain a Minimal Node Host for the Cross-Platform npm Launcher](./0002-minimal-node-npm-launcher.md) +* [0003 Publish npm through the shared native-npm action](./0003-publish-npm-through-shared-action.md) diff --git a/spec/functional/FR-022-npm-distribution.md b/spec/functional/FR-022-npm-distribution.md new file mode 100644 index 0000000..da1e0bb --- /dev/null +++ b/spec/functional/FR-022-npm-distribution.md @@ -0,0 +1,37 @@ +--- +id: FR-022 +title: "npm distribution through the shared publish action" +type: FR +object_type: cli_command +relationships: + - target: "ix://agent-ix/quire-cli/spec/stakeholder/StR-001" + type: "implements" + cardinality: "1:1" +--- + +# FR-022: npm distribution through the shared publish action + +## Description + +The `@agent-ix/quire-cli` npm package SHALL install the native `quire` binary on +every supported platform. The release workflow SHALL publish it through the +shared `agent-ix/nodejs-actions/publish-native-npm` action (ADR-0003), which +owns package layout, the launcher, and the `npm publish` calls. This repository +contains no npm packaging or launcher implementation. + +Supported platforms are `linux-x64`, `linux-arm64`, `darwin-arm64`, and +`win32-x64`. The Linux binaries are fully static musl builds, so their packages +carry no libc restriction and install on glibc and musl hosts alike. + +## Acceptance Criteria + +| ID | Criteria | Verification | +|----|----------|--------------| +| FR-022-AC-1 | `npm install -g @agent-ix/quire-cli@` installs a `quire` that runs the native binary and reports `` from `--version`, on each supported platform. | Inspection (release workflow post-publish smoke) | +| FR-022-AC-2 | Under an npm install, `quire update` exits non-zero and names `npm install -g @agent-ix/quire-cli@latest` instead of replacing the binary. | Inspection (release workflow post-publish smoke) | +| FR-022-AC-3 | The release workflow publishes only when dispatched with `publish` true, authenticates to public npm by OIDC Trusted Publishing, and passes the four built targets as `artifacts//quire[.exe]`. | Inspection | +| FR-022-AC-4 | `quire-dist verify-binary` fails unless the built executable's `--version` equals the release version, and the release workflow runs it for each target before packaging. | Test (TC-818) | + +## Dependencies + +- **Upstream**: ADR-0003; the shared `publish-native-npm` action. diff --git a/spec/functional/FR-022-npm-launcher-process-contract.md b/spec/functional/FR-022-npm-launcher-process-contract.md deleted file mode 100644 index fe991ed..0000000 --- a/spec/functional/FR-022-npm-launcher-process-contract.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -id: FR-022 -title: "npm launcher process contract" -type: FR -object_type: cli_command -relationships: - - target: "ix://agent-ix/quire-cli/spec/stakeholder/StR-001" - type: "implements" - cardinality: "1:1" ---- - -# FR-022: npm launcher process contract - -## Description - -The `@agent-ix/quire-cli` npm package SHALL expose `quire` through the minimal -host launcher decided by ADR-0002. The launcher SHALL select only a platform -package declared in its generated `optionalDependencies` and SHALL otherwise -behave as a transparent process boundary around the packaged native binary. - -## Behavior - -1. The launcher derives `-` from the Node host and derives the - package name `@agent-ix/quire-cli--`. -2. The derived package is supported only when it is an own property of the - launcher's generated `optionalDependencies`. The launcher does not carry a - second independently maintained target allowlist. -3. The executable is `bin/quire.exe` for `win32` and `bin/quire` otherwise. -4. The launcher passes every argument after its own executable path in the - original order and inherits stdin, stdout, and stderr. -5. A normal child exit returns the identical status. A signal-terminated child - causes the launcher to terminate with the identical signal. -6. On a non-Windows host, the launcher makes one best-effort attempt to restore - executable mode `0755`; refusal is not itself fatal because the file may - already be executable. Any subsequent launch failure is fatal. - -## Acceptance Criteria - -| ID | Criteria | Verification | -|----|----------|--------------| -| FR-022-AC-1 | The installed `quire` entry selects exactly the optional package for the current platform and architecture and resolves only its fixed `bin/quire[.exe]` member. | Test (IT-159, TC-815) | -| FR-022-AC-2 | The launcher forwards the ordered host argument strings without adding, removing, or rewriting an element, inherits all three standard streams, and returns the native process's normal exit status. | Test (IT-159) | -| FR-022-AC-3 | The launcher mirrors a native process's terminating signal rather than translating it into an unrelated normal exit status. | Test (IT-162) | -| FR-022-AC-4 | An unsupported platform/architecture exits 1, lists the generated supported set, names the source-build alternative, and launches nothing. | Test (IT-160) | -| FR-022-AC-5 | A supported but absent optional package exits 1, names that exact package, explains how optional dependencies can be restored, and launches nothing. | Test (IT-160) | -| FR-022-AC-6 | A chmod refusal is tolerated, while an actual spawn error exits 1 and reports `failed to launch binary` with the host error. | Test (IT-161) | -| FR-022-AC-7 | The host file contains no version policy, package generation, binary-format validation, checksum logic, document handling, extraction, clause grammar, or source semantics. | Static test (TC-815, TC-820) | - -## Dependencies - -- **Upstream**: ADR-0002 owner disposition for the minimal npm host boundary. -- **Downstream**: FR-023 generates the launcher manifest and its closed optional - dependency set. diff --git a/spec/functional/FR-023-rust-npm-package-assembly.md b/spec/functional/FR-023-rust-npm-package-assembly.md deleted file mode 100644 index cbc77a4..0000000 --- a/spec/functional/FR-023-rust-npm-package-assembly.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -id: FR-023 -title: "Rust-owned npm package assembly" -type: FR -object_type: build_tool -relationships: - - target: "ix://agent-ix/quire-cli/spec/stakeholder/StR-001" - type: "implements" - cardinality: "1:1" ---- - -# FR-023: Rust-owned npm package assembly - -## Description - -A repository-local Rust distribution tool SHALL generate one native npm -package per platform actually built in a given run, from one typed target -catalog, and synchronize the launcher package's `optionalDependencies` to -exactly that built set. It replaces `npm/build-packages.mjs`; it is build -tooling and SHALL NOT become a second installed `quire` product binary. - -The catalog contains exactly these release targets: - -| Rust target | Host platform | Host architecture | Binary | -|---|---|---|---| -| `x86_64-unknown-linux-musl` | `linux` | `x64` | `quire` | -| `aarch64-unknown-linux-musl` | `linux` | `arm64` | `quire` | -| `aarch64-apple-darwin` | `darwin` | `arm64` | `quire` | -| `x86_64-pc-windows-msvc` | `win32` | `x64` | `quire.exe` | - -A run's built set is any non-empty subset of this catalog — the manually -dispatched GitHub release workflow builds all four; the local publish path -has only ever built `linux-x64` (PLAT-885). Both are legitimate releases. A -platform this run did not build is simply absent from the launcher's -`optionalDependencies`; the launcher's own "unsupported platform" refusal -(FR-022-AC-4) already handles that on the platforms it was never told about. -What packaging never does is declare a platform dependency at a version this -run did not build and publish — that dangling pin is the defect, not the -absence. - -## Acceptance Criteria - -| ID | Criteria | Verification | -|----|----------|--------------| -| FR-023-AC-1 | One typed Rust catalog is the production authority for Rust target, npm platform, npm architecture, package name, binary name, and expected binary format for all four targets. | Unit/static test (TC-815) | -| FR-023-AC-2 | Before changing the output tree, the tool requires one input binary for each artifact target present — a non-empty subset of the catalog — and rejects an artifact directory outside the catalog, an empty artifact set, or an incorrectly named target artifact. | Mutation test (TC-817, TC-822) | -| FR-023-AC-3 | Before changing the output tree, the tool verifies that every input has the target's expected ELF machine, Mach-O CPU type, or PE/COFF machine and rejects a binary copied from another target. | Mutation test (TC-817) | -| FR-023-AC-4 | A successful run replaces the output with exactly one scoped platform package per artifact target present — never a package for a platform this run did not build — containing only `bin/quire[.exe]`, `LICENSE`, and `package.json`; non-Windows binaries are mode `0755`. | Test (TC-816, TC-822) | -| FR-023-AC-5 | Every generated manifest declares the exact release version, `AGPL-3.0-or-later`, the matching singleton `os` and `cpu`, the public npm registry/access policy, and the repository metadata. | Test (TC-816) | -| FR-023-AC-6 | The launcher manifest receives the same release version and exactly one same-version optional dependency for each artifact target present in this run — derived from the artifacts, never carried over from a prior run's committed manifest — with no dependency for a platform outside the catalog or absent from this run. | Test (TC-816, TC-822) | -| FR-023-AC-7 | Repeated generation from identical inputs is byte-identical, and any preflight refusal leaves the prior output and launcher files byte-identical. | Test (TC-816, TC-817) | -| FR-023-AC-8 | Local `npm pack` inspection and an offline clean consumer installation prove the generated file membership, dependency resolution, license presence, and native executable selection without publishing or network access. | Test (IT-159, IT-163) | -| FR-023-AC-9 | After publish, a separate command fetches the launcher package as actually published at the release version — from the supplied npm registry, never from the local working tree — and asserts both that the launcher itself resolves there and that every platform package its *published* `optionalDependencies` names also resolves at its declared version. It fails on any package/version that does not resolve, on a registry it cannot reach, and on an empty or absent `optionalDependencies` map (never a zero-iteration pass). It queries the registry through the package's own npm scope (`--@agent-ix:registry=…`), which npm gives precedence over a bare `--registry` flag — a bare `--registry` is silently inert for every package this tool checks and was the actual reason the first cut of this gate always passed. It is invoked as a required, registry-dependent step on the manually dispatched publish path (FR-024-AC-5), guarded the same as every other publish step. `verify_binary_version` does not cover any of this — it compares only the local binary's own reported version and asserts nothing about what a registry actually serves. | Test (IT-165), Static (TC-819) | - -## Dependencies - -- **Upstream**: FR-024 supplies an accepted release version; ADR-0002 defines - the retained launcher host boundary. -- **Downstream**: the manually dispatched release workflow consumes the - generated packages. GitHub release archive/checksum remediation is not part - of this requirement and remains in `agent-ix/quire-research#63`. diff --git a/spec/functional/FR-024-rust-release-version-contract.md b/spec/functional/FR-024-rust-release-version-contract.md deleted file mode 100644 index 2b5c59b..0000000 --- a/spec/functional/FR-024-rust-release-version-contract.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -id: FR-024 -title: "Rust-owned npm release version contract" -type: FR -object_type: build_tool -relationships: - - target: "ix://agent-ix/quire-cli/spec/stakeholder/StR-001" - type: "implements" - cardinality: "1:1" ---- - -# FR-024: Rust-owned npm release version contract - -## Description - -The repository-local Rust distribution tool SHALL replace -`scripts/set_version.sh` and npm-specific inline release assertions with a -typed, fail-closed version contract. The manually dispatched workflow remains -an external host: it supplies the requested version and invokes external build, -artifact, GitHub, and npm commands, but it does not reimplement version or npm -package decisions. - -## Acceptance Criteria - -| ID | Criteria | Verification | -|----|----------|--------------| -| FR-024-AC-1 | The version command accepts a complete SemVer value and rejects missing or invalid input before changing any file. | Unit/mutation test (TC-818) | -| FR-024-AC-2 | A successful update changes only the root Cargo package version, launcher package version, and every catalog-derived launcher optional-dependency version to the identical supplied value. | Test (TC-818) | -| FR-024-AC-3 | The command validates the complete proposed Cargo and launcher state before its first replacement; a malformed launcher manifest, an empty optional-dependency set, or a dependency naming a platform outside the catalog leaves every governed file byte-identical. A launcher declaring a non-empty proper subset of the catalog is a valid input, not a mutation to reject — it is bumped like any other. | Mutation test (TC-818) | -| FR-024-AC-4 | The release verification command rejects disagreement among the requested version, root Cargo package, launcher package, launcher optional dependencies, and native binary `--version` output. | Test (IT-164, TC-818) | -| FR-024-AC-5 | The manual release workflow invokes the Rust tool for npm package generation and version assertions and contains no executable MJS, Perl, embedded Node mutation, or independently maintained npm target/version assertion. | Static test (TC-819) | -| FR-024-AC-6 | Publication remains opt-in on a manually dispatched workflow; local qualification neither dispatches the workflow nor publishes a GitHub, Cargo, or npm artifact. | Static inspection (TC-819) | - -## Dependencies - -- **Downstream**: FR-023 consumes the verified version when assembling npm - packages. diff --git a/spec/functional/FR-025-rust-qualification-tooling.md b/spec/functional/FR-025-rust-qualification-tooling.md index e5187c1..9b1667f 100644 --- a/spec/functional/FR-025-rust-qualification-tooling.md +++ b/spec/functional/FR-025-rust-qualification-tooling.md @@ -68,8 +68,7 @@ the baseline only through an explicit update operation. | FR-025-CON-1 | The Rust-source gates SHALL NOT classify syntax with grep, regular expressions alone, or a hand-written lexer | Maintainability | Inspection + mutation (TC-835) | | FR-025-CON-2 | Make targets SHALL contain orchestration only | Boundary | Static test (TC-838) | | FR-025-CON-3 | Make targets SHALL NOT interpret coverage, benchmark, or Rust-source content | Boundary | Static test (TC-838) | -| FR-025-CON-4 | `npm/quire-cli/bin/quire.js` SHALL remain the admitted npm distribution launcher | Boundary | Static test (TC-839) | -| FR-025-CON-5 | `npm/quire-cli/bin/quire.js` SHALL NOT acquire qualification semantics | Boundary | Static test (TC-839) | +| FR-025-CON-4 | The repository SHALL own no JavaScript; the npm launcher is generated by the shared publish action (ADR-0003) | Boundary | Static test (TC-839) | ## Acceptance Criteria @@ -81,7 +80,7 @@ the baseline only through an explicit update operation. | FR-025-AC-4 | The thin-boundary gate accepts admitted dispatch and rejects forbidden direct, aliased, and nested-group references with a file and syntax locus | Test (TC-835, TC-836) | | FR-025-AC-5 | The unsafe gate accepts a documented block, rejects an undocumented block, honours only exact baseline loci, rejects stale exemptions, and changes the baseline only through the explicit update operation | Test (TC-837) | | FR-025-AC-6 | Repository Make targets invoke Rust-owned gates without inline report parsing or source classification | Test (TC-838) | -| FR-025-AC-7 | No executable Python or shell qualification file remains, while the npm launcher and non-executable fixture/data formats remain | Test (TC-839) | +| FR-025-AC-7 | No executable Python or shell qualification file and no repository-owned JavaScript remain, while non-executable fixture/data formats remain | Test (TC-839) | ## Dependencies diff --git a/spec/functional/index.md b/spec/functional/index.md index 05ac9ff..50b1d03 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -28,9 +28,7 @@ description: "Index of artifacts in this directory." * [FR-019: quire symbols subcommand](./FR-019-symbols-subcommand.md) * [FR-020: quire assurance subcommand](./FR-020-assurance-export-subcommand.md) * [FR-021: quire clauses subcommand](./FR-021-clauses-subcommand.md) -* [FR-022: npm launcher process contract](./FR-022-npm-launcher-process-contract.md) -* [FR-023: Rust-owned npm package assembly](./FR-023-rust-npm-package-assembly.md) -* [FR-024: Rust-owned npm release version contract](./FR-024-rust-release-version-contract.md) +* [FR-022: npm distribution through the shared publish action](./FR-022-npm-distribution.md) * [FR-025: Rust-owned CLI qualification tooling](./FR-025-rust-qualification-tooling.md) * [FR-026: quire matrix subcommand](./FR-026-matrix-subcommand.md) * [FR-027: Cargo.lock Git-revision coherence gate](./FR-027-check-lock-subcommand.md) diff --git a/spec/non-functional/NFR-008-native-npm-distribution-tooling.md b/spec/non-functional/NFR-008-native-npm-distribution-tooling.md deleted file mode 100644 index b256a2d..0000000 --- a/spec/non-functional/NFR-008-native-npm-distribution-tooling.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -id: NFR-008 -title: "Native npm distribution tooling boundary" -type: NFR -quality_attribute: maintainability -relationships: - - target: "ix://agent-ix/quire-cli/spec/stakeholder/StR-001" - type: "constrains" - cardinality: "1:1" ---- - -# NFR-008: Native npm distribution tooling boundary - -## Statement - -The repository-local distribution tool SHALL implement first-party npm -distribution decisions, generators, validators, and test assertions in Rust. -The only approved executable foreign-language production file is the -minimal Node npm-bin host bounded by ADR-0002; all other foreign execution is an -explicitly named external package manager or release host. - -## Scope and Containment - -- Rust owns the typed target catalog, binary identity validation, package - manifests, version synchronization, parity checks, and test assertions. -- Node executes only the installed npm `bin` host seam. Local Rust tests invoke - the installed Node/npm toolchain to qualify that seam; Node owns no test assertion - or package decision. -- The manual GitHub workflow may configure credentials and invoke - external actions, Cargo, GitHub CLI, npm, and the Rust distribution tool. - Generic GitHub release archive/checksum remediation is separately owned by - `agent-ix/quire-research#63`. -- `npm/build-packages.mjs`, `scripts/set_version.sh`, and embedded Node/Perl - mutators are removed rather than wrapped. -- WASM/browser parity is deferred by the owner and is not an admission or exit - gate for this change. -- The distribution tool does not depend on `quire-rs` and does not parse Quire - artifacts. It encodes no extraction rule, formal-clause profile, grammar, - temporal/protocol behavior, or source semantic; those remain governed by the - Quire language redesign. - -## Executable-Path Inventory and Disposition - -| Existing path | Classification | #61 disposition | -|---|---|---| -| `npm/quire-cli/bin/quire.js` | Shipped npm executable host | Retain under the owner-approved ADR-0002 exact bounded seam. | -| `npm/build-packages.mjs` | First-party package generator and manifest mutator | Replace with the Rust distribution tool and remove. | -| `scripts/set_version.sh` | First-party SemVer validator plus Perl/Node mutator | Replace with the Rust distribution tool and remove. | -| npm target/version assertions inside `.github/workflows/release.yml` | Executable inline decision and validation logic | Replace with calls to the Rust distribution tool. | -| GitHub actions, Cargo builds, artifact transfer, credential setup, `gh release`, and `npm publish` | External release-host orchestration | Retain as manually dispatched host integration; it owns no package semantics or test verdict. | -| GitHub release archive normalization/checksum shell | Non-npm ecosystem packaging | Preserve in #61 and route remediation to `agent-ix/quire-research#63`. | -| WASM/browser paths | Deferred product surface | No implementation or qualification work in #61. | - -## Measurement and Evaluation - -| Metric | Target | Threshold | Method | -|--------|--------|-----------|--------| -| First-party npm decision/generator/assertion paths outside Rust | 0 | 0 | Executable-path audit | -| Foreign production files beyond the approved Node launcher | 0 | 0 | Executable-path audit | -| Unreviewed target catalogs | 0 | 0 | Catalog and workflow mutation test | -| Distribution dependencies on Quire parsing/extraction/profile crates | 0 | 0 | Manifest/source audit | - -## Verification - -`ix-trace-rs`-marked Rust tests SHALL exercise the generated packages, record -the Node/npm host versions used where the npm protocol requires them, and assert -all results. A production-derived static audit SHALL enumerate the executable -paths and fail when foreign decision logic, an additional launcher, an -undeclared target catalog, or Quire semantic dependencies appear. - -## Acceptance Criteria - -| ID | Criteria | Verification | -|----|----------|--------------| -| NFR-008-AC-1 | The only repository-owned JavaScript executed in npm distribution is `npm/quire-cli/bin/quire.js`, and it remains within every ADR-0002 boundary. | Static/mutation test (TC-815, TC-819) | -| NFR-008-AC-2 | All distribution test or audit verdicts are asserted by Rust tests carrying `ix-trace-rs` links to the owning acceptance criteria. | Static test (TC-820) | -| NFR-008-AC-3 | The distribution tool and launcher contain no document, extraction, clause, profile, grammar, temporal, protocol, or source-semantic implementation and the Rust tool has no `quire-rs` dependency. | Static test (TC-820) | -| NFR-008-AC-4 | Local qualification uses `CARGO_BUILD_JOBS=2` and no hosted-CI dispatch or publication. | Inspection | -| NFR-008-AC-5 | The retained Node/npm qualification reliance is limited to packing, clean offline installation, dependency resolution, and exercising the launcher process seam; the Rust tests decide every result. | Test/review (IT-159, IT-163) | -| NFR-008-AC-6 | Rust distribution dependencies pass the repository's license/source policy, the launcher imports only Node built-ins, and every emitted npm package declares and carries the repository's `AGPL-3.0-or-later` license. | Test/inspection (IT-163) | - -## Dependencies - -- **Upstream**: owner acceptance of ADR-0002. -- **Downstream**: `agent-ix/quire-research#63` may consume the executable-path - inventory but SHALL NOT reopen this npm boundary without a new spec cycle. diff --git a/spec/non-functional/index.md b/spec/non-functional/index.md index 60dac2f..4afbb15 100644 --- a/spec/non-functional/index.md +++ b/spec/non-functional/index.md @@ -13,5 +13,4 @@ description: "Index of artifacts in this directory." * [NFR-004: No network dependencies, no network calls](./NFR-004-no-network.md) * [NFR-005: Diagnostics inherit quire-rs [FR-017](ix://agent-ix/quire-rs/FR-017) format](./NFR-005-diagnostic-format.md) * [NFR-006: CLI surface stability under SemVer](./NFR-006-cli-stability.md) -* [NFR-008: Native npm distribution tooling boundary](./NFR-008-native-npm-distribution-tooling.md) * [NFR-009: Native qualification boundary](./NFR-009-native-qualification-boundary.md) diff --git a/spec/spec.md b/spec/spec.md index 8caf988..4d1b078 100644 --- a/spec/spec.md +++ b/spec/spec.md @@ -65,8 +65,8 @@ This specification governs: - JSON encoding of `QuireDocument`, `ExtractionResult`, `HarvestedEdge[]` on stdout - Process-level performance budgets (cold-start validate / parse / extract) - Distribution and installation surface (`cargo install`, release binary - tarballs, and the Rust-generated npm packages governed by FR-022..024 and - ADR-0002) + tarballs, and the npm packages published through the shared action governed + by FR-022 and ADR-0003) - A deterministic `assurance` process boundary over quire-rs's published source-grounded assurance export, including explicit source/module/schema premises and fail-closed drift checks diff --git a/spec/tests.md b/spec/tests.md index 22b9be9..3d67e11 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -52,7 +52,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | US-004 Extract for graph ingest | AC-1..3 | IT-004 (envelope), IT-015 (edge dedup), IT-020 (determinism), IT-099 (unknown archetype) — IT-016 (sugar field harvest) retired, FR-003 CR 2026-08-20 | ✅ | | US-005 Machine addresses section | AC-1..5 | IT-033, IT-034, IT-035, IT-036, IT-038 | ✅ | | US-006 Evidence producer exports assurance facts | Illustrative examples; binding acceptance is FR-020 AC-1..9 | IT-136..145, TC-814 | ✅ via FR-020 | -| US-007 Consumer installs native Quire through npm | AC-1..4 | IT-159..163, TC-816 | ✅ Complete | +| US-007 Consumer installs native Quire through npm | AC-1..3 | Post-publish smoke in `.github/workflows/release.yml` (inspection; runs only when the workflow is dispatched with `publish`) | Inspection | ## Functional Requirement Coverage @@ -79,9 +79,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | FR-019 symbols subcommand (the extracted symbol table) | AC-1..6 | IT-130 (human census, stdout clean), IT-131 (JSON record fields + byte-stability), IT-132 (NOT ASKED vs not tagged), IT-133 (both denominators), IT-134 (diagnostics on stderr in JSON mode), IT-135 (a language filter narrows records AND census) | ✅ Complete | | FR-020 assurance subcommand (source-grounded assurance-v1 export) | AC-1..8 | IT-136 (complete export), IT-137 (upstream schema), IT-138 (compact/pretty determinism), IT-139/IT-140 (premise/load refusal), IT-141 (empty success vs failure), IT-142 (diagnostic channels), TC-814 (thin boundary), IT-143 (no child/network execution), IT-144 (cross-language golden) | ✅ | | FR-021 clauses subcommand (generic module clause sets) | AC-1..7 | IT-154 (evaluate + three-valued JSON + provenance/schema), IT-155 (exact-version diff + schema), IT-156 (deterministic TSV), IT-157 (fail-closed inputs and version selection), TC-141 (TSV structural escaping) | ✅ Complete | -| FR-022 npm launcher process contract | AC-1..7 | IT-159 (offline install + transparent process), IT-160 (unsupported/missing), IT-161 (chmod refusal + spawn error), IT-162 (signal), TC-815 (minimal shim and single catalog), TC-820 (semantic containment) | ✅ Complete | -| FR-023 Rust-owned npm package assembly | AC-1..9 | TC-815 (catalog), TC-816 (complete deterministic packages), TC-817 (empty/wrong/extra input and atomic refusal), TC-822 (built-subset emits only its own platforms), IT-159 (offline installation), IT-163 (`npm pack` membership), IT-165 (post-publish resolvability gate, scope-registry mutation-sensitive, plus a real-network unreachable-registry case), TC-819 (the gate is a required step on the manual publish path) | ✅ Complete | -| FR-024 Rust-owned npm release version contract | AC-1..6 | TC-818 (transactional version contract), IT-164 (binary/version disagreement), TC-819 (manual workflow and native tooling audit) | ✅ Complete | +| FR-022 npm distribution through the shared publish action | AC-1..4 | Post-publish smoke in `.github/workflows/release.yml` (AC-1..3, inspection), TC-818 (AC-4 `verify-binary`) | ✅ Complete | | FR-025 Rust-owned CLI qualification tooling | AC-1..7 | TC-828..839 | ✅ Complete | | FR-026 matrix subcommand (computed `coverage_matrix` rendering — PLAT-1078, quire-rs FR-050-AC-47..51 as amended by PLAT-1077 (`statement`)/FR-051-AC-27/28) | AC-1..14 | IT-166 (happy path across all three formats), IT-167 (shared `coverage` refusals: `MissingDocumentRoot`, no traceability model, closed `--module`, `--help` resolution-order text), IT-168 (markdown requirement-table shape, `## ` heading, engine-order rows, pipe-escaping worked example), IT-169 (`Binders` cell: `path:line:column`, `(ignored)` marker, `(none)` at zero, no same-line collapse), IT-170 (truncate-then-escape order, 80-scalar-value budget, untruncated/unescaped in json/tsv), IT-171 (`--format json` envelope: `coverage_matrix` absent or the engine's bare array, passed through verbatim, provenance, byte-identity), IT-172 (`--format tsv` header/columns, untruncated statement, header-only at zero), IT-173 (both zero-population fixtures yield the one absent-key shape and render identically in every format), IT-174 (`--strict` fails on zero criteria and on `untagged`/`tagged-by-ignored-test`, passes on `tagged`/`method-without-symbol`), IT-175 (`method-without-symbol` never fails `--strict` at any binder count, renders normally), IT-176 (`--severity` is not accepted — exit 3 — runs no coverage check, `grammar_severity` promotion has no effect, no FR-017-AC-22 unread-measurement inheritance), IT-177 (no output file: nothing anywhere under `` changes), TC-841 (exit-code audit: only the applicable shared outcomes appear) | ✅ Complete | @@ -95,7 +93,6 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | NFR-004 No network (own process; scoped lazy-init via quoin is the ADR-0001 exception) | static + runtime | TC-091 (`cargo deny bans`), IT-008 (strace zero socket(), happy path), IT-081 (scoped discovery network-free), IT-143 (`assurance` no socket/child process) | ✅ | | NFR-005 Diagnostic format | unit + IT | IT-031 (each error class parses as Diagnostic JSON) | ✅ | | NFR-006 CLI stability | snapshot | IT-032 (`quire --help` snapshot pinned) | ✅ | -| NFR-008 Native npm distribution tooling boundary | Rust source/mutation audit + local host-seam qualification | TC-815, TC-819, TC-820, IT-159, IT-163 | ✅ Complete | | NFR-009 Native qualification boundary | Rust source/mutation audit + locked local qualification | TC-828..840, TC-092, TC-814 | ✅ Complete | --- @@ -154,20 +151,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | IT-157 | Malformed context and an unavailable exact version both exit 3 with an actionable diagnostic; no nearest version is guessed (`cli_clauses::it140_*`) | Integration | P0 | FR-021-AC-1, FR-021-AC-2, FR-021-AC-7 | ✅ | | IT-158 | `clauses evaluate` opens no network socket and spawns no process under `strace -fe network,process`; a missing `strace` fails the gate rather than passing it (`audit_no_network::clauses_does_not_open_inet_socket`) | Integration | P0 | FR-021-AC-7, NFR-004-AC-2 | ✅ | | TC-141 | TSV escapes tabs, newlines, carriage returns, and backslashes in a cell, preserving one logical clause per physical row (`commands::clauses::tests::tsv_cells_keep_one_record_per_line`) | Unit | P1 | FR-020-AC-5 | ✅ | -| IT-159 | Rust assembles all five local npm tarballs, npm installs the meta-package and matching optional package into a clean offline prefix, and the installed launcher runs a native probe that demonstrates unchanged argument strings, stdin/stdout/stderr, and normal exit status | Integration | P0 | US-007-AC-1, US-007-AC-2, FR-022-AC-1, FR-022-AC-2, FR-023-AC-8, NFR-008-AC-5 | ✅ | -| IT-160 | On the actual host, a generated manifest omitting the host pair produces the unsupported-host refusal and a manifest admitting the pair without its package produces the missing-package refusal; both launch nothing and name the exact repair/source-build path | Integration | P0 | US-007-AC-3, FR-022-AC-4, FR-022-AC-5 | ✅ | -| IT-161 | A resolved executable whose mode cannot be changed still launches, while a resolved non-executable directory produces the deterministic launch-failure diagnostic and exit 1 | Integration | P1 | FR-022-AC-6 | ✅ | -| IT-162 | A launcher child terminated by a host signal causes the launcher to terminate with the identical signal | Integration | P0 | US-007-AC-2, FR-022-AC-3 | ✅ | -| IT-163 | Rust assertions over `npm pack --json --dry-run` and the produced tarballs prove exact declared members, matching license bytes, public package metadata, and same-version optional dependencies without registry access or publication | Integration | P0 | US-007-AC-4, FR-023-AC-8, NFR-008-AC-5 | ✅ | -| IT-164 | Release verification accepts the matching built `quire --version` and rejects a requested, Cargo, launcher, optional-dependency, or executable version mismatch before package assembly | Integration | P0 | FR-024-AC-4 | ✅ | -| IT-165 | `verify_published` fetches the launcher package as published, by registry query, and resolves every platform package its published `optionalDependencies` names, through a real `npm view` subprocess call: a hermetic stub asserts the exact scope-registry override (`--@agent-ix:registry=…`) it is given and fails the call if it is wrong or absent — proving a dropped registry argument is caught, not silently ignored — and covers the launcher itself not resolving, one platform package pinned to a version that does not exist, `npm` printing a version that disagrees with what was requested, and an empty/absent `optionalDependencies` map failing outright rather than passing as a zero-iteration loop; a second, `#[ignore]`d case runs the same check against the real `npm` binary and a non-resolving registry host, proving the unreachable-registry path against genuine network failure rather than a simulated one | Integration | P0 | FR-023-AC-9 | ✅ | -| TC-815 | An `ix-trace-rs` Rust audit proves the typed target catalog has exactly four unique rows, the generated launcher dependencies are drawn only from those rows, and the Node host contains no second allowlist or behavior beyond ADR-0002; production-derived mutations must fail | Static | P0 | FR-022-AC-1, FR-022-AC-7, FR-023-AC-1, NFR-008-AC-1 | ✅ | -| TC-816 | Rust package-generator tests prove exact target directories, filenames, executable modes, manifest fields, license bytes, launcher synchronization, stale-output removal, and byte-identical reruns | Unit | P0 | US-007-AC-4, FR-023-AC-4, FR-023-AC-5, FR-023-AC-6, FR-023-AC-7 | ✅ | -| TC-817 | Production-derived empty, extra, wrong-name, wrong-format, and cross-target binary artifact sets are each rejected before any existing output or launcher byte changes | Unit | P0 | FR-023-AC-2, FR-023-AC-3, FR-023-AC-7 | ✅ | -| TC-818 | Rust version-contract tests accept complete SemVer, validate the complete proposed state before replacement, update only the governed Cargo/npm fields, accept a launcher declaring a non-empty proper subset of the catalog, and kill malformed-version, malformed-manifest, empty/extra-dependency, and mismatch mutations without partial preflight writes | Unit | P0 | FR-024-AC-1, FR-024-AC-2, FR-024-AC-3, FR-024-AC-4 | ✅ | -| TC-819 | A production-derived Rust audit enumerates every `.yml`/`.yaml` release workflow and npm executable path, requires manual dispatch and Rust package/version/publish-verification commands (including `verify-published`), requires every publish-adjacent step to be gated on `inputs.publish`, and rejects executable MJS, Perl, embedded Node mutation, npm target/version assertions, automatic publication, or a second JavaScript entry | Static | P0 | FR-023-AC-9, FR-024-AC-5, FR-024-AC-6, NFR-008-AC-1 | ✅ | -| TC-820 | Every new distribution test carries an `ix-trace-rs` marker, the Rust tool has no `quire-rs` dependency, and source audits reject document/extraction/clause/profile/grammar/temporal/protocol/source-semantic implementation in the tool or launcher | Static | P0 | FR-022-AC-7, NFR-008-AC-2, NFR-008-AC-3 | ✅ | -| TC-822 | A build providing only a non-empty proper subset of the catalog (linux-x64 alone — the local publish path's actual shape, PLAT-885) succeeds, emitting exactly one npm package and one launcher `optionalDependency` for the platform present, and none for the three it did not build | Unit | P0 | FR-023-AC-2, FR-023-AC-4, FR-023-AC-6 | ✅ | +| TC-818 | `quire-dist verify-binary` accepts a native executable whose `--version` equals the release version and rejects a different version or a non-SemVer release version | Unit | P0 | FR-022-AC-4 | ✅ | | IT-099 | `extract` on a document whose `type` resolves to no DSL-carrying archetype exits 1 with a diagnostic naming it; no partial extraction reaches stdout (`cli_extract::extract_no_dsl_archetype_errors_cleanly`) | Integration | P1 | FR-003-AC-2, US-004-AC-3 | ✅ | | IT-015 | Edge dedup by (source, type, target) — a twice-declared relationship and a twice-linked body target each harvest once (`cli_extract::it_015_*`) | Integration | P1 | US-004-AC-2 | ✅ | | IT-016 | ⊘ RETIRED (FR-003 CR, 2026-08-20) — Frontmatter sugar field `dependencies:` harvested (no engine ever harvested sugar fields) | Integration | P1 | FR-003-AC-3 | ⛔ | @@ -295,7 +279,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | TC-836 | Thin-boundary mutations adding forbidden calls or CLI-owned assurance types fail with file and locus while admitted dispatch passes | Static | P0 | FR-025-AC-4 | ✅ | | TC-837 | Unsafe-comment mutations prove documented, undocumented, exact-baseline, stale-baseline, and explicit-update behavior | Static | P0 | FR-025-AC-5 | ✅ | | TC-838 | Make contains only Rust gate orchestration and no report/source interpretation | Static | P1 | FR-025-AC-6, FR-025-CON-2, FR-025-CON-3 | ✅ | -| TC-839 | Executable `.py`/`.sh` qualification paths are absent and the npm launcher remains a semantics-free distribution host | Static | P0 | FR-025-AC-7, FR-025-CON-4, FR-025-CON-5 | ✅ | +| TC-839 | Executable `.py`/`.sh`/`.mjs` qualification paths are absent and the repository owns no JavaScript | Static | P0 | FR-025-AC-7, FR-025-CON-4 | ✅ | | TC-840 | Locked local gates, canonical `ix-trace-rs`, no shell-spawned qualification, and no hosted-CI change are evidenced | Static | P0 | NFR-009 | ✅ | | IT-166 | `quire matrix --scope --module $M` over a fixture whose module declares an `obligations:` source deriving a non-empty population exits 0 and renders every requirement's criteria in markdown, `--format json` and `--format tsv` (`cli_matrix::it166_*`) | Integration | P0 | FR-026-AC-1 | ✅ | | IT-167 | `matrix` shares `coverage`'s refusals verbatim: no `spec/` exits 1 as `MissingDocumentRoot`, no `traceability:` model exits 1 naming the missing declaration, `--module` is repeatable and closed, and `matrix --help` states the FR-017-AC-21 resolution-order text (`cli_matrix::it167_*`) | Integration | P0 | FR-026-AC-2 | ✅ | diff --git a/spec/usecase/US-007-install-quire-through-npm.md b/spec/usecase/US-007-install-quire-through-npm.md index ce345c6..202751b 100644 --- a/spec/usecase/US-007-install-quire-through-npm.md +++ b/spec/usecase/US-007-install-quire-through-npm.md @@ -17,18 +17,15 @@ myself. ## Acceptance -- **US-007-AC-1**: A clean offline installation from the five locally packed - npm artifacts installs one launcher and the matching optional platform - package, and `quire --version` executes the packaged native binary. -- **US-007-AC-2**: Arguments, standard streams, normal exit status, and - terminating signals cross the launcher boundary without reinterpretation. -- **US-007-AC-3**: An unsupported host or an absent matching optional package - fails with an actionable diagnostic and never substitutes another target. -- **US-007-AC-4**: Every launcher release selects platform packages at the - identical version and ships the repository's declared license. +- **US-007-AC-1**: `npm install -g @agent-ix/quire-cli` installs the matching + platform package, and `quire --version` executes the packaged native binary. +- **US-007-AC-2**: An unsupported host fails with an actionable diagnostic and + never substitutes another target. +- **US-007-AC-3**: `quire update` under an npm install defers to npm. ## Boundaries -This story governs distribution and process launching only. It adds no -document parser, extraction rule, native-language profile, clause grammar, -source semantic, WASM surface, or browser behavior. +This story governs distribution only; the package is produced by the shared +publish action (ADR-0003). It adds no document parser, extraction rule, +native-language profile, clause grammar, source semantic, WASM surface, or +browser behavior. diff --git a/tools/quire-dist/Cargo.toml b/tools/quire-dist/Cargo.toml index 1d9fd12..8f07b88 100644 --- a/tools/quire-dist/Cargo.toml +++ b/tools/quire-dist/Cargo.toml @@ -5,16 +5,14 @@ edition = "2021" rust-version = "1.98.1" publish = false default-run = "quire-dist" -description = "Repository-local release and npm distribution tooling for quire-cli" +description = "Repository-local release tooling for quire-cli" license = "AGPL-3.0-or-later" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } semver = "1" -serde_json = "1" -tempfile = "3" -toml_edit = "0.25.13" [dev-dependencies] ix-trace-rs = { version = "=0.1.0", git = "https://github.com/agent-ix/ix-trace-rs", rev = "2ce4ebf47f726b9d76388220545cd0abda8a5cfb" } +tempfile = "3" diff --git a/tools/quire-dist/README.md b/tools/quire-dist/README.md index 0a8dcbf..4f0cd6e 100644 --- a/tools/quire-dist/README.md +++ b/tools/quire-dist/README.md @@ -1,28 +1,19 @@ # quire-dist -`quire-dist` is the non-published Rust tooling for `quire-cli` releases. It is a +`quire-dist` is the non-published Rust release tooling for `quire-cli`. It is a workspace member but not an installed product binary; `cargo install --path .` continues to install only `quire`. -The tool owns the native/npm target catalog, executable-format checks, package -assembly, and Cargo/npm version agreement: +Its one command, `verify-binary --version X.Y.Z --binary `, executes a +built `quire` and fails unless its `--version` reports exactly the release +version. The release workflow runs it for each target before publishing. + +npm packaging is not done here. The release workflow publishes through the +shared `agent-ix/nodejs-actions/publish-native-npm` action (ADR-0003). + +This crate has no `quire-rs` dependency and treats executables as opaque +artifacts. ```bash -make set-version VERSION=0.33.0 -make dist-verify VERSION=0.33.0 -make dist-package VERSION=0.33.0 make dist-test ``` - -`package-npm` expects one validated executable at -`artifacts//quire[.exe]` for each target that was built (any -non-empty subset of the catalog), writes one platform package per such target -under `npm/dist/`, and sets the launcher's `optionalDependencies` to exactly -that set. It never publishes. The manually -dispatched release workflow remains responsible for invoking external build, -GitHub, and npm hosts. - -This crate has no `quire-rs` dependency and treats executables as opaque -distribution artifacts. It does not parse documents or implement extraction, -formal-clause profiles, grammar, temporal/protocol behavior, or source -semantics. diff --git a/tools/quire-dist/src/bin/quire-dist-probe.rs b/tools/quire-dist/src/bin/quire-dist-probe.rs deleted file mode 100644 index 23a05ac..0000000 --- a/tools/quire-dist/src/bin/quire-dist-probe.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Native fixture used by the offline npm launcher integration tests. - -use std::io::{self, Read}; - -fn main() { - let arguments: Vec<_> = std::env::args().skip(1).collect(); - if arguments == ["--version"] { - println!("quire 1.2.3 (distribution-test-probe)"); - return; - } - if arguments == ["--abort"] { - std::process::abort(); - } - let exit = arguments - .iter() - .position(|argument| argument == "--exit") - .and_then(|index| arguments.get(index + 1)) - .and_then(|value| value.parse::().ok()); - let mut stdin = String::new(); - io::stdin().read_to_string(&mut stdin).expect("read stdin"); - println!( - "args={}", - serde_json::to_string(&arguments).expect("JSON args") - ); - println!("stdin={stdin}"); - eprintln!("probe-stderr"); - if let Some(code) = exit { - std::process::exit(code); - } -} diff --git a/tools/quire-dist/src/lib.rs b/tools/quire-dist/src/lib.rs index 2f4d217..d297bf4 100644 --- a/tools/quire-dist/src/lib.rs +++ b/tools/quire-dist/src/lib.rs @@ -1,372 +1,20 @@ -//! Native release and npm distribution tooling for `quire-cli`. +//! Release tooling for `quire-cli`. //! -//! This crate deliberately has no dependency on `quire-rs`: distribution -//! treats every native executable as an opaque, target-identified artifact. +//! npm packaging is owned by the shared `agent-ix/nodejs-actions` +//! `publish-native-npm` action (see ADR-0003). This crate keeps only the +//! pre-publish gate that every built executable reports the release version. +//! It deliberately has no dependency on `quire-rs`. -use std::collections::BTreeSet; -use std::ffi::OsStr; -use std::fs; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; use anyhow::{bail, Context, Result}; use semver::Version; -use serde_json::{json, Map, Value}; -use tempfile::{Builder, NamedTempFile}; -use toml_edit::{value, DocumentMut}; - -/// One admitted native/npm distribution target. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct DistributionTarget { - pub rust: &'static str, - pub platform: &'static str, - pub arch: &'static str, - pub binary: &'static str, - format: BinaryFormat, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum BinaryFormat { - Elf { machine: u16 }, - MachO { cpu_type: u32 }, - Pe { machine: u16 }, -} - -/// The sole production catalog for native release and npm package targets. -pub const TARGETS: [DistributionTarget; 4] = [ - DistributionTarget { - rust: "x86_64-unknown-linux-musl", - platform: "linux", - arch: "x64", - binary: "quire", - format: BinaryFormat::Elf { machine: 62 }, - }, - DistributionTarget { - rust: "aarch64-unknown-linux-musl", - platform: "linux", - arch: "arm64", - binary: "quire", - format: BinaryFormat::Elf { machine: 183 }, - }, - DistributionTarget { - rust: "aarch64-apple-darwin", - platform: "darwin", - arch: "arm64", - binary: "quire", - format: BinaryFormat::MachO { - cpu_type: 0x0100_000c, - }, - }, - DistributionTarget { - rust: "x86_64-pc-windows-msvc", - platform: "win32", - arch: "x64", - binary: "quire.exe", - format: BinaryFormat::Pe { machine: 0x8664 }, - }, -]; - -#[derive(Debug)] -pub struct PackageNpm<'a> { - pub version: &'a str, - pub cargo_manifest: &'a Path, - pub artifacts_dir: &'a Path, - pub output_dir: &'a Path, - pub launcher_dir: &'a Path, - pub license: &'a Path, -} - -#[derive(Debug)] -pub struct SetVersion<'a> { - pub version: &'a str, - pub cargo_manifest: &'a Path, - pub launcher_manifest: &'a Path, -} - -#[derive(Debug)] -pub struct VerifyRelease<'a> { - pub version: &'a str, - pub cargo_manifest: &'a Path, - pub launcher_manifest: &'a Path, -} - -/// A post-publish gate: fetch the launcher package as it was actually -/// published at `version`, and assert every platform package it declares -/// actually resolves in the target registry too. Deliberately reads -/// everything from the registry, never from the local working tree — -/// `package_npm` already checked what it wrote *locally*; this checks what -/// publish actually produced, which can differ (the launcher's own publish -/// failed after the platform packages succeeded, or the tree moved on since -/// the commit that was published). This is the assertion PLAT-885 was filed -/// over — `verify_binary_version` never covered it, since it only ever -/// compares the local binary's own reported version. -#[derive(Debug)] -pub struct VerifyPublished<'a> { - pub version: &'a str, - pub registry: &'a str, - /// The `npm` executable to invoke `view` through — a bare name is - /// resolved on `PATH`; tests pass an explicit stub binary so the check - /// stays hermetic instead of depending on real network access. - pub npm_binary: &'a OsStr, -} fn checked_version(raw: &str) -> Result { Version::parse(raw).with_context(|| format!("invalid SemVer release version {raw:?}")) } -fn read_cargo_manifest(path: &Path) -> Result { - fs::read_to_string(path) - .with_context(|| format!("read Cargo manifest {}", path.display()))? - .parse::() - .with_context(|| format!("parse Cargo manifest {}", path.display())) -} - -fn cargo_package_version(document: &DocumentMut, path: &Path) -> Result { - document["package"]["version"] - .as_str() - .map(str::to_owned) - .with_context(|| format!("{} has no string package.version", path.display())) -} - -fn read_json(path: &Path) -> Result { - serde_json::from_slice( - &fs::read(path).with_context(|| format!("read JSON {}", path.display()))?, - ) - .with_context(|| format!("parse JSON {}", path.display())) -} - -fn object_mut<'a>(value: &'a mut Value, path: &Path) -> Result<&'a mut Map> { - value - .as_object_mut() - .with_context(|| format!("{} must contain a JSON object", path.display())) -} - -/// The published npm launcher package name. Every distribution target's -/// platform package (`package_name`) shares this scope (`@agent-ix`). -pub const LAUNCHER_PACKAGE_NAME: &str = "@agent-ix/quire-cli"; - -fn package_name(target: &DistributionTarget) -> String { - format!("@agent-ix/quire-cli-{}-{}", target.platform, target.arch) -} - -fn expected_package_names() -> BTreeSet { - TARGETS.iter().map(package_name).collect() -} - -/// The one place that decides whether an `optionalDependencies` map is -/// admissible: non-empty, and every key inside [`TARGETS`]'s catalog. Both -/// [`launcher_dependencies`] (the committed launcher manifest on disk) and -/// `verify_published` (the manifest actually served by the registry after -/// publish) funnel through this — a launcher naming a package outside the -/// catalog, or naming nothing at all, is rejected the same way regardless -/// of which of those two it was read from. `location` is used only for -/// error messages, so it names a file path in one caller and a -/// `pkg@version` registry query in the other. -fn validate_dependency_catalog(dependencies: &Map, location: &str) -> Result<()> { - if dependencies.is_empty() { - bail!( - "{location}.optionalDependencies must declare at least one platform package; an \ - empty set here is the exact dangling-pin defect PLAT-885 exists to catch, not a \ - vacuous pass" - ); - } - let observed: BTreeSet<_> = dependencies.keys().cloned().collect(); - let expected = expected_package_names(); - let unknown: BTreeSet<_> = observed.difference(&expected).cloned().collect(); - if !unknown.is_empty() { - bail!( - "{location}.optionalDependencies names packages outside the target catalog: {unknown:?} (catalog is {expected:?})" - ); - } - Ok(()) -} - -/// A launcher declares one optional dependency per platform it actually -/// ships a binary for. The catalog in [`TARGETS`] is the closed set of -/// platforms the tooling knows how to build; the launcher's declared set is -/// whatever non-empty subset of that catalog this release actually built -/// (see [`artifact_paths`]), never a name outside it and never empty — an -/// empty set here is the exact dangling-pin defect PLAT-885 exists to catch, -/// not a vacuous pass. -fn launcher_dependencies<'a>( - object: &'a mut Map, - path: &Path, -) -> Result<&'a mut Map> { - let dependencies = object - .get_mut("optionalDependencies") - .with_context(|| format!("{} has no optionalDependencies", path.display()))?; - let dependencies = dependencies - .as_object_mut() - .with_context(|| format!("{}.optionalDependencies must be an object", path.display()))?; - validate_dependency_catalog(dependencies, &path.display().to_string())?; - Ok(dependencies) -} - -fn require_json_value( - object: &Map, - key: &str, - expected: &Value, - path: &Path, -) -> Result<()> { - let observed = object - .get(key) - .with_context(|| format!("{} has no {key}", path.display()))?; - if observed != expected { - bail!( - "{}.{key} must be {expected}, observed {observed}", - path.display() - ); - } - Ok(()) -} - -fn validate_launcher_contract(object: &Map, path: &Path) -> Result<()> { - require_json_value(object, "name", &json!(LAUNCHER_PACKAGE_NAME), path)?; - require_json_value(object, "license", &json!("AGPL-3.0-or-later"), path)?; - require_json_value(object, "type", &json!("commonjs"), path)?; - require_json_value(object, "bin", &json!({ "quire": "bin/quire.js" }), path)?; - require_json_value(object, "files", &json!(["bin/", "LICENSE"]), path)?; - require_json_value( - object, - "publishConfig", - &json!({ - "registry": "https://registry.npmjs.org/", - "access": "public" - }), - path, - )?; - if object - .get("dependencies") - .and_then(Value::as_object) - .is_some_and(|dependencies| !dependencies.is_empty()) - { - bail!("{}.dependencies must be absent or empty", path.display()); - } - Ok(()) -} - -fn json_bytes(value: &Value) -> Result> { - let mut bytes = serde_json::to_vec_pretty(value).context("serialize package JSON")?; - bytes.push(b'\n'); - Ok(bytes) -} - -fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { - let parent = path - .parent() - .with_context(|| format!("{} has no parent directory", path.display()))?; - let mut temporary = NamedTempFile::new_in(parent) - .with_context(|| format!("create temporary file beside {}", path.display()))?; - temporary - .write_all(bytes) - .with_context(|| format!("write temporary file for {}", path.display()))?; - temporary - .flush() - .with_context(|| format!("flush temporary file for {}", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - temporary - .as_file() - .set_permissions(fs::Permissions::from_mode(0o644)) - .with_context(|| format!("set metadata mode for {}", path.display()))?; - } - temporary - .persist(path) - .map_err(|error| error.error) - .with_context(|| format!("replace {}", path.display()))?; - Ok(()) -} - -/// Set the root crate and generated launcher dependency versions in lockstep. -pub fn set_version(request: &SetVersion<'_>) -> Result<()> { - let version = checked_version(request.version)?.to_string(); - let mut cargo = read_cargo_manifest(request.cargo_manifest)?; - let current_cargo_version = cargo_package_version(&cargo, request.cargo_manifest)?; - checked_version(¤t_cargo_version).with_context(|| { - format!( - "{}.package.version is not valid SemVer", - request.cargo_manifest.display() - ) - })?; - cargo["package"]["version"] = value(&version); - - let mut launcher = read_json(request.launcher_manifest)?; - let launcher_object = object_mut(&mut launcher, request.launcher_manifest)?; - if !launcher_object.get("version").is_some_and(Value::is_string) { - bail!( - "{}.version must be a string", - request.launcher_manifest.display() - ); - } - let dependencies = launcher_dependencies(launcher_object, request.launcher_manifest)?; - for dependency_version in dependencies.values_mut() { - if !dependency_version.is_string() { - bail!( - "{}.optionalDependencies values must be strings", - request.launcher_manifest.display() - ); - } - *dependency_version = Value::String(version.clone()); - } - launcher_object.insert("version".to_owned(), Value::String(version)); - - let cargo_bytes = cargo.to_string().into_bytes(); - let launcher_bytes = json_bytes(&launcher)?; - write_atomic(request.cargo_manifest, &cargo_bytes)?; - write_atomic(request.launcher_manifest, &launcher_bytes)?; - Ok(()) -} - -fn verify_manifest_versions( - version: &str, - cargo_manifest: &Path, - launcher_manifest: &Path, -) -> Result<()> { - let expected = checked_version(version)?.to_string(); - let cargo = read_cargo_manifest(cargo_manifest)?; - let observed = cargo_package_version(&cargo, cargo_manifest)?; - if observed != expected { - bail!("Cargo package version {observed} does not match release version {expected}"); - } - - let mut launcher = read_json(launcher_manifest)?; - let object = object_mut(&mut launcher, launcher_manifest)?; - validate_launcher_contract(object, launcher_manifest)?; - let launcher_version = object - .get("version") - .and_then(Value::as_str) - .with_context(|| format!("{}.version must be a string", launcher_manifest.display()))?; - if launcher_version != expected { - bail!("launcher version {launcher_version} does not match release version {expected}"); - } - let dependencies = launcher_dependencies(object, launcher_manifest)?; - for (name, value) in dependencies { - let observed = value.as_str().with_context(|| { - format!( - "{}.optionalDependencies[{name:?}] must be a string", - launcher_manifest.display() - ) - })?; - if observed != expected { - bail!( - "launcher dependency {name} version {observed} does not match release version {expected}" - ); - } - } - Ok(()) -} - -/// Verify all committed release-version declarations before packaging. -pub fn verify_release(request: &VerifyRelease<'_>) -> Result<()> { - verify_manifest_versions( - request.version, - request.cargo_manifest, - request.launcher_manifest, - ) -} - /// Execute one native artifact's version surface and compare it exactly. pub fn verify_binary_version(version: &str, binary: &Path) -> Result<()> { let expected = checked_version(version)?.to_string(); @@ -396,428 +44,3 @@ pub fn verify_binary_version(version: &str, binary: &Path) -> Result<()> { } Ok(()) } - -/// Parse the npm scope (`"agent-ix"`) out of a scoped package name -/// (`"@agent-ix/quire-cli"`). Every package this tool ever names is scoped. -fn npm_scope(package: &str) -> Result<&str> { - package - .strip_prefix('@') - .and_then(|rest| rest.split('/').next()) - .filter(|scope| !scope.is_empty()) - .with_context(|| format!("{package:?} is not a scoped npm package name")) -} - -/// Build the scope-specific registry override for `package`. -/// -/// npm gives scope config (`@scope:registry=`, from any user or project -/// `.npmrc`) precedence over the bare `--registry` flag. Every package this -/// tool checks is `@agent-ix/…`, so a bare `--registry` is silently inert -/// for all of them: measured directly, -/// `npm view @agent-ix/…@ version --registry -/// http://does-not-exist.invalid/` for a version that is *not* published -/// still exits 0 and prints the version, because npm resolved the scope -/// against this host's own `.npmrc`/npmjs.org default and never consulted -/// the flag at all. That was the actual defect in the first cut of this -/// gate: it always passed, for a reason unrelated to what it claimed to -/// check. There is no bare `--registry` kept alongside "just in case" — -/// only the scope override is authoritative. -fn npm_registry_override(package: &str, registry: &str) -> Result { - let scope = npm_scope(package)?; - Ok(format!("--@{scope}:registry={registry}")) -} - -/// Assert that `name@version` resolves as an installable package on -/// `registry`. Shells out to `npm view`, which itself performs the registry -/// round trip — this function adds no interpretation `npm` did not already -/// do, and in particular it never re-derives the answer from anything -/// local (the artifact directory, the Cargo manifest, or the binary that -/// was just built). A 404, an unreachable registry, or a spawn failure all -/// surface as an `Err`; nothing here treats "npm exited non-zero" as -/// anything but a hard failure. -fn verify_package_resolves( - npm_binary: &OsStr, - name: &str, - version: &str, - registry: &str, -) -> Result<()> { - let spec = format!("{name}@{version}"); - let registry_override = npm_registry_override(name, registry)?; - let output = Command::new(npm_binary) - .args(["view", &spec, "version", ®istry_override]) - .output() - .with_context(|| format!("execute npm view {spec} version {registry_override}"))?; - if !output.status.success() { - bail!( - "{spec} does not resolve on {registry}: npm view exited with {} ({})", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - ); - } - let resolved = std::str::from_utf8(&output.stdout) - .with_context(|| format!("npm view {spec} stdout is not UTF-8"))? - .trim(); - // `npm view @ version` pins the version in the query - // itself, so a mismatch here would mean npm's own output disagreed with - // what it was asked to resolve — defend against that rather than trust - // a non-empty stdout as proof of anything. Measured: npm exits non-zero - // for a genuinely absent version against both npm.ix and public npm, so - // this branch is a defensive backstop rather than - // the primary "missing" path — but it is the one guarding against npm - // itself resolving something other than what was asked for. - if resolved != version { - bail!("{spec} did not resolve to {version:?} on {registry}: npm view printed {resolved:?}"); - } - Ok(()) -} - -/// Fetch `@` exactly as published, via `npm view --json`. -/// A non-zero exit — missing package, missing version, or an unreachable -/// registry — is the gate's primary signal here and is never downgraded. -fn fetch_published_manifest( - npm_binary: &OsStr, - package: &str, - version: &str, - registry: &str, -) -> Result { - let spec = format!("{package}@{version}"); - let registry_override = npm_registry_override(package, registry)?; - let output = Command::new(npm_binary) - .args(["view", &spec, "--json", ®istry_override]) - .output() - .with_context(|| format!("execute npm view {spec} --json {registry_override}"))?; - if !output.status.success() { - bail!( - "{spec} does not resolve on {registry}: npm view exited with {} ({})", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - ); - } - let manifest: Value = serde_json::from_slice(&output.stdout) - .with_context(|| format!("npm view {spec} --json stdout is not valid JSON"))?; - let observed_version = manifest - .get("version") - .and_then(Value::as_str) - .with_context(|| format!("npm view {spec} --json has no string .version"))?; - if observed_version != version { - bail!( - "{spec} resolved to version {observed_version:?} on {registry}, expected {version:?}" - ); - } - Ok(manifest) -} - -/// Verify the launcher package itself resolves at `request.version`, and -/// that every platform package its *published* `optionalDependencies` -/// declares also resolves — reading that map from the registry's own copy -/// of the manifest, not from `npm/quire-cli/package.json` in the working -/// tree. Two failures this specifically catches that a local-file read -/// cannot: the launcher's own `npm publish` failing after the platform -/// packages already succeeded (consumers then silently keep installing the -/// previous, broken launcher), and the working tree having moved past the -/// commit that was actually published (a local read would then verify pins -/// that were never in the published launcher at all). -/// -/// An empty (or absent) `optionalDependencies` map is refused before the -/// loop, not treated as "zero failures": a manifest with nothing to check -/// is the exact dangling-pin defect class this gate exists to catch, and a -/// loop over zero entries reporting success would be silently useless for -/// it. The catalog-membership and non-emptiness checks are the same -/// `validate_dependency_catalog` rule that governs the committed manifest -/// via `launcher_dependencies` — one rule, read from either source. -pub fn verify_published(request: &VerifyPublished<'_>) -> Result<()> { - let manifest = fetch_published_manifest( - request.npm_binary, - LAUNCHER_PACKAGE_NAME, - request.version, - request.registry, - )?; - let location = format!( - "{LAUNCHER_PACKAGE_NAME}@{} (published on {})", - request.version, request.registry - ); - let dependencies: Map = match manifest.get("optionalDependencies") { - Some(value) => value - .as_object() - .with_context(|| format!("{location}.optionalDependencies must be an object"))? - .clone(), - None => Map::new(), - }; - validate_dependency_catalog(&dependencies, &location)?; - for (name, value) in &dependencies { - let version = value.as_str().with_context(|| { - format!("{location}.optionalDependencies[{name:?}] must be a string") - })?; - verify_package_resolves(request.npm_binary, name, version, request.registry)?; - } - Ok(()) -} - -fn u16_le(bytes: &[u8], start: usize) -> Option { - Some(u16::from_le_bytes( - bytes.get(start..start + 2)?.try_into().ok()?, - )) -} - -fn u32_le(bytes: &[u8], start: usize) -> Option { - Some(u32::from_le_bytes( - bytes.get(start..start + 4)?.try_into().ok()?, - )) -} - -fn verify_binary_format(path: &Path, target: &DistributionTarget) -> Result<()> { - let mut file = - fs::File::open(path).with_context(|| format!("open binary {}", path.display()))?; - let mut bytes = Vec::with_capacity(4096); - Read::by_ref(&mut file) - .take(4096) - .read_to_end(&mut bytes) - .with_context(|| format!("read binary header {}", path.display()))?; - let matches = match target.format { - BinaryFormat::Elf { machine } => { - bytes.get(0..6) == Some(b"\x7fELF\x02\x01") && u16_le(&bytes, 18) == Some(machine) - } - BinaryFormat::MachO { cpu_type } => { - bytes.get(0..4) == Some(&[0xcf, 0xfa, 0xed, 0xfe]) - && u32_le(&bytes, 4) == Some(cpu_type) - } - BinaryFormat::Pe { machine } => { - let pe_offset = u32_le(&bytes, 0x3c).map(|offset| offset as usize); - bytes.get(0..2) == Some(b"MZ") - && pe_offset.is_some_and(|offset| { - bytes.get(offset..offset + 4) == Some(b"PE\0\0") - && u16_le(&bytes, offset + 4) == Some(machine) - }) - } - }; - if !matches { - bail!( - "binary {} does not match target {} ({}/{})", - path.display(), - target.rust, - target.platform, - target.arch - ); - } - Ok(()) -} - -/// Read the built targets out of `root`. A run may build any non-empty -/// subset of [`TARGETS`] — the local publish path has only ever built -/// `linux-x64`, and that is a legitimate release, not a defect. What is -/// still rejected: a directory naming a target outside the catalog (a typo -/// or a stale leftover), and an empty artifacts directory (nothing built is -/// never a valid release). -fn artifact_paths(root: &Path) -> Result> { - let known_dirs: BTreeSet<_> = TARGETS - .iter() - .map(|target| target.rust.to_owned()) - .collect(); - let observed_dirs: BTreeSet<_> = fs::read_dir(root) - .with_context(|| format!("read artifacts directory {}", root.display()))? - .map(|entry| { - let entry = entry.with_context(|| format!("read entry in {}", root.display()))?; - let file_type = entry - .file_type() - .with_context(|| format!("read type for {}", entry.path().display()))?; - if !file_type.is_dir() { - bail!( - "unexpected non-directory artifact {}", - entry.path().display() - ); - } - entry - .file_name() - .into_string() - .map_err(|_| anyhow::anyhow!("artifact directory name is not UTF-8")) - }) - .collect::>()?; - let unknown: BTreeSet<_> = observed_dirs.difference(&known_dirs).cloned().collect(); - if !unknown.is_empty() { - bail!("artifact targets outside the catalog: {unknown:?} (catalog is {known_dirs:?})"); - } - if observed_dirs.is_empty() { - bail!( - "artifacts directory {} contains no built targets; at least one of {known_dirs:?} is required", - root.display() - ); - } - - TARGETS - .iter() - .copied() - .filter(|target| observed_dirs.contains(target.rust)) - .map(|target| { - let directory = root.join(target.rust); - let entries: Vec<_> = fs::read_dir(&directory) - .with_context(|| format!("read artifact target {}", directory.display()))? - .collect::>()?; - if entries.len() != 1 || entries[0].file_name() != target.binary { - let names: Vec<_> = entries - .iter() - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .collect(); - bail!( - "artifact target {} must contain only {:?}, observed {names:?}", - target.rust, - target.binary - ); - } - let path = entries[0].path(); - if !entries[0] - .file_type() - .with_context(|| format!("read artifact type {}", path.display()))? - .is_file() - { - bail!("artifact {} must be a regular file", path.display()); - } - verify_binary_format(&path, &target)?; - Ok((target, path)) - }) - .collect() -} - -fn platform_manifest(target: &DistributionTarget, version: &str) -> Value { - json!({ - "name": package_name(target), - "version": version, - "description": format!("Prebuilt quire binary for {}-{}.", target.platform, target.arch), - "homepage": "https://github.com/agent-ix/quire-cli#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/agent-ix/quire-cli.git" - }, - "license": "AGPL-3.0-or-later", - "os": [target.platform], - "cpu": [target.arch], - "files": ["bin/", "LICENSE"], - "publishConfig": { - "registry": "https://registry.npmjs.org/", - "access": "public" - } - }) -} - -#[cfg(unix)] -fn make_executable(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("set executable mode on {}", path.display())) -} - -#[cfg(not(unix))] -fn make_executable(_path: &Path) -> Result<()> { - Ok(()) -} - -fn replace_output_tree(staged: &Path, output: &Path, staging: tempfile::TempDir) -> Result<()> { - let backup = staging.path().join("previous-output"); - if output.exists() { - fs::rename(output, &backup) - .with_context(|| format!("stage existing output {}", output.display()))?; - } - if let Err(error) = fs::rename(staged, output) { - if backup.exists() { - if let Err(restore_error) = fs::rename(&backup, output) { - let recovery = staging.keep(); - bail!( - "replace output {} failed ({error}); restoring the previous output also failed ({restore_error}); recovery tree preserved at {}", - output.display(), - recovery.display() - ); - } - } - return Err(error).with_context(|| format!("replace output {}", output.display())); - } - Ok(()) -} - -/// Generate the closed npm package set from validated native artifacts. -pub fn package_npm(request: &PackageNpm<'_>) -> Result<()> { - let version = checked_version(request.version)?.to_string(); - let launcher_manifest = request.launcher_dir.join("package.json"); - verify_manifest_versions(&version, request.cargo_manifest, &launcher_manifest)?; - for relative in ["bin/quire.js", "README.md"] { - let path = request.launcher_dir.join(relative); - if !fs::symlink_metadata(&path) - .with_context(|| format!("read launcher member {}", path.display()))? - .file_type() - .is_file() - { - bail!("launcher member {} must be a regular file", path.display()); - } - } - let artifacts = artifact_paths(request.artifacts_dir)?; - let license = fs::read(request.license) - .with_context(|| format!("read license {}", request.license.display()))?; - - // Compute the rewritten launcher manifest content now, alongside the - // rest of the preflight, before any destructive write happens: its - // optionalDependencies become exactly the platforms this run built, one - // entry per artifact actually present — never the previously committed - // set, and never a hardcoded four-platform list. A platform absent here - // is simply absent from the manifest; the launcher's own "unsupported - // platform" error path handles that. - let mut launcher_json = read_json(&launcher_manifest)?; - let launcher_object = object_mut(&mut launcher_json, &launcher_manifest)?; - let dependencies: Map = artifacts - .iter() - .map(|(target, _)| (package_name(target), Value::String(version.clone()))) - .collect(); - launcher_object.insert( - "optionalDependencies".to_owned(), - Value::Object(dependencies), - ); - let launcher_bytes = json_bytes(&launcher_json)?; - - let output_parent = request - .output_dir - .parent() - .with_context(|| format!("{} has no parent", request.output_dir.display()))?; - fs::create_dir_all(output_parent) - .with_context(|| format!("create output parent {}", output_parent.display()))?; - let staging = Builder::new() - .prefix(".quire-dist-") - .tempdir_in(output_parent) - .with_context(|| format!("create staging directory in {}", output_parent.display()))?; - let staged_output = staging.path().join("dist"); - fs::create_dir(&staged_output).context("create staged npm output")?; - - for (target, source) in artifacts { - let directory = - staged_output.join(format!("quire-cli-{}-{}", target.platform, target.arch)); - let bin_directory = directory.join("bin"); - fs::create_dir_all(&bin_directory) - .with_context(|| format!("create {}", bin_directory.display()))?; - let destination = bin_directory.join(target.binary); - fs::copy(&source, &destination) - .with_context(|| format!("copy {} to {}", source.display(), destination.display()))?; - if target.platform != "win32" { - make_executable(&destination)?; - } - fs::write(directory.join("LICENSE"), &license) - .with_context(|| format!("write license in {}", directory.display()))?; - fs::write( - directory.join("package.json"), - json_bytes(&platform_manifest(&target, &version))?, - ) - .with_context(|| format!("write package manifest in {}", directory.display()))?; - } - - // Commit phase: three separate atomic writes, not one transaction — - // ordered so a failure partway through never leaves the launcher - // manifest declaring a wider platform set than what the freshly staged - // `npm/dist` tree actually contains. That specific bad state (dist - // regenerated and correct, launcher stale and wider) is the PLAT-885 - // shape reappearing via an interrupted regeneration rather than a - // hardcoded list. The launcher manifest — the file a human is most - // likely to `git diff`, commit, and publish from — is written first, so - // if the tree replace or the LICENSE refresh then fails, the launcher - // already reflects the true, minimal, just-built platform set rather - // than a stale, wider one. Either write can still fail on its own after - // this point (disk full, permissions); that failure returns `Err` and - // must not be ignored by the caller, the same as it always has been. - write_atomic(&launcher_manifest, &launcher_bytes)?; - replace_output_tree(&staged_output, request.output_dir, staging)?; - write_atomic(&request.launcher_dir.join("LICENSE"), &license)?; - Ok(()) -} diff --git a/tools/quire-dist/src/main.rs b/tools/quire-dist/src/main.rs index 28f945c..bfa7f3e 100644 --- a/tools/quire-dist/src/main.rs +++ b/tools/quire-dist/src/main.rs @@ -2,13 +2,10 @@ use std::path::PathBuf; use anyhow::Result; use clap::{Parser, Subcommand}; -use quire_dist::{ - package_npm, set_version, verify_binary_version, verify_published, verify_release, PackageNpm, - SetVersion, VerifyPublished, VerifyRelease, -}; +use quire_dist::verify_binary_version; #[derive(Debug, Parser)] -#[command(about = "Repository-local quire-cli distribution tooling")] +#[command(about = "Repository-local quire-cli release tooling")] struct Cli { #[command(subcommand)] command: Command, @@ -16,20 +13,6 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { - /// Update the Cargo and npm launcher versions in lockstep. - SetVersion { - #[arg(long)] - version: String, - #[arg(long, default_value = ".")] - root: PathBuf, - }, - /// Verify committed Cargo and npm launcher versions. - VerifyRelease { - #[arg(long)] - version: String, - #[arg(long, default_value = ".")] - root: PathBuf, - }, /// Verify one executable's `--version` output. VerifyBinary { #[arg(long)] @@ -37,63 +20,10 @@ enum Command { #[arg(long)] binary: PathBuf, }, - /// Generate one npm package per platform actually present in - /// `artifacts-dir`, and sync the launcher's optionalDependencies to - /// exactly that set. - PackageNpm { - #[arg(long)] - version: String, - #[arg(long, default_value = ".")] - root: PathBuf, - #[arg(long, default_value = "artifacts")] - artifacts_dir: PathBuf, - }, - /// Post-publish gate: fetch the launcher package as actually published - /// at `version` from `registry`, and assert every platform package its - /// published optionalDependencies declares also resolves there. - VerifyPublished { - #[arg(long)] - version: String, - #[arg(long)] - registry: String, - #[arg(long, default_value = "npm")] - npm_binary: PathBuf, - }, } fn main() -> Result<()> { match Cli::parse().command { - Command::SetVersion { version, root } => set_version(&SetVersion { - version: &version, - cargo_manifest: &root.join("Cargo.toml"), - launcher_manifest: &root.join("npm/quire-cli/package.json"), - }), - Command::VerifyRelease { version, root } => verify_release(&VerifyRelease { - version: &version, - cargo_manifest: &root.join("Cargo.toml"), - launcher_manifest: &root.join("npm/quire-cli/package.json"), - }), Command::VerifyBinary { version, binary } => verify_binary_version(&version, &binary), - Command::PackageNpm { - version, - root, - artifacts_dir, - } => package_npm(&PackageNpm { - version: &version, - cargo_manifest: &root.join("Cargo.toml"), - artifacts_dir: &artifacts_dir, - output_dir: &root.join("npm/dist"), - launcher_dir: &root.join("npm/quire-cli"), - license: &root.join("LICENSE"), - }), - Command::VerifyPublished { - version, - registry, - npm_binary, - } => verify_published(&VerifyPublished { - version: &version, - registry: ®istry, - npm_binary: npm_binary.as_os_str(), - }), } } diff --git a/tools/quire-dist/tests/distribution.rs b/tools/quire-dist/tests/distribution.rs deleted file mode 100644 index fcfd7ab..0000000 --- a/tools/quire-dist/tests/distribution.rs +++ /dev/null @@ -1,686 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::ffi::OsStr; -use std::fs; -use std::path::{Path, PathBuf}; - -use ix_trace_rs::trace; -use quire_dist::{ - package_npm, set_version, verify_binary_version, verify_published, verify_release, PackageNpm, - SetVersion, VerifyPublished, VerifyRelease, LAUNCHER_PACKAGE_NAME, TARGETS, -}; -use serde_json::{json, Value}; -use tempfile::TempDir; - -const VERSION: &str = "1.2.3"; - -struct Fixture { - _temporary: TempDir, - root: PathBuf, - cargo_manifest: PathBuf, - artifacts: PathBuf, - output: PathBuf, - launcher: PathBuf, - license: PathBuf, -} - -fn package_name(platform: &str, arch: &str) -> String { - format!("@agent-ix/quire-cli-{platform}-{arch}") -} - -fn launcher_json(version: &str) -> Value { - let dependencies = TARGETS - .iter() - .map(|target| (package_name(target.platform, target.arch), json!(version))) - .collect(); - json!({ - "name": "@agent-ix/quire-cli", - "version": version, - "description": "test launcher", - "license": "AGPL-3.0-or-later", - "type": "commonjs", - "bin": { "quire": "bin/quire.js" }, - "files": ["bin/", "LICENSE"], - "engines": { "node": ">=16" }, - "optionalDependencies": Value::Object(dependencies), - "publishConfig": { - "registry": "https://registry.npmjs.org/", - "access": "public" - } - }) -} - -fn write_json(path: &Path, value: &Value) { - let mut bytes = serde_json::to_vec_pretty(value).expect("serialize fixture JSON"); - bytes.push(b'\n'); - fs::write(path, bytes).expect("write fixture JSON"); -} - -fn binary_bytes(rust_target: &str) -> Vec { - match rust_target { - "x86_64-unknown-linux-musl" => { - let mut bytes = vec![0; 64]; - bytes[..6].copy_from_slice(b"\x7fELF\x02\x01"); - bytes[18..20].copy_from_slice(&62_u16.to_le_bytes()); - bytes - } - "aarch64-unknown-linux-musl" => { - let mut bytes = vec![0; 64]; - bytes[..6].copy_from_slice(b"\x7fELF\x02\x01"); - bytes[18..20].copy_from_slice(&183_u16.to_le_bytes()); - bytes - } - "aarch64-apple-darwin" => { - let mut bytes = vec![0; 32]; - bytes[..4].copy_from_slice(&[0xcf, 0xfa, 0xed, 0xfe]); - bytes[4..8].copy_from_slice(&0x0100_000c_u32.to_le_bytes()); - bytes - } - "x86_64-pc-windows-msvc" => { - let mut bytes = vec![0; 128]; - bytes[..2].copy_from_slice(b"MZ"); - bytes[0x3c..0x40].copy_from_slice(&64_u32.to_le_bytes()); - bytes[64..68].copy_from_slice(b"PE\0\0"); - bytes[68..70].copy_from_slice(&0x8664_u16.to_le_bytes()); - bytes - } - other => panic!("unhandled fixture target {other}"), - } -} - -fn fixture() -> Fixture { - let temporary = tempfile::tempdir().expect("temporary fixture"); - let root = temporary.path().to_path_buf(); - let cargo_manifest = root.join("Cargo.toml"); - fs::write( - &cargo_manifest, - format!("[package]\nname = \"fixture\"\nversion = \"{VERSION}\"\n"), - ) - .expect("write Cargo manifest"); - let artifacts = root.join("artifacts"); - for target in TARGETS { - let directory = artifacts.join(target.rust); - fs::create_dir_all(&directory).expect("create artifact directory"); - fs::write(directory.join(target.binary), binary_bytes(target.rust)) - .expect("write artifact binary"); - } - let launcher = root.join("npm/quire-cli"); - fs::create_dir_all(launcher.join("bin")).expect("create launcher directory"); - write_json(&launcher.join("package.json"), &launcher_json(VERSION)); - fs::write(launcher.join("bin/quire.js"), "// fixture launcher\n") - .expect("write fixture launcher"); - fs::write(launcher.join("README.md"), "fixture launcher readme\n") - .expect("write fixture README"); - let license = root.join("LICENSE"); - fs::write(&license, "fixture AGPL license\n").expect("write license"); - let output = root.join("npm/dist"); - Fixture { - _temporary: temporary, - root, - cargo_manifest, - artifacts, - output, - launcher, - license, - } -} - -fn package(fixture: &Fixture) -> anyhow::Result<()> { - package_npm(&PackageNpm { - version: VERSION, - cargo_manifest: &fixture.cargo_manifest, - artifacts_dir: &fixture.artifacts, - output_dir: &fixture.output, - launcher_dir: &fixture.launcher, - license: &fixture.license, - }) -} - -fn tree_snapshot(root: &Path) -> BTreeMap, Option)> { - fn visit( - base: &Path, - directory: &Path, - snapshot: &mut BTreeMap, Option)>, - ) { - let mut entries: Vec<_> = fs::read_dir(directory) - .expect("read snapshot directory") - .map(Result::unwrap) - .collect(); - entries.sort_by_key(|entry| entry.file_name()); - for entry in entries { - let path = entry.path(); - if entry.file_type().expect("snapshot file type").is_dir() { - visit(base, &path, snapshot); - } else { - #[cfg(unix)] - let mode = { - use std::os::unix::fs::PermissionsExt; - Some( - entry - .metadata() - .expect("snapshot metadata") - .permissions() - .mode() - & 0o777, - ) - }; - #[cfg(not(unix))] - let mode = None; - snapshot.insert( - path.strip_prefix(base) - .expect("relative snapshot") - .to_path_buf(), - (fs::read(path).expect("snapshot bytes"), mode), - ); - } - } - } - - let mut snapshot = BTreeMap::new(); - visit(root, root, &mut snapshot); - snapshot -} - -#[trace("TC-815", "FR-023-AC-1", "FR-022-AC-1", "NFR-008-AC-1")] -#[test] -fn tc815_target_catalog_is_closed_unique_and_exact() { - let observed: Vec<_> = TARGETS - .iter() - .map(|target| (target.rust, target.platform, target.arch, target.binary)) - .collect(); - assert_eq!( - observed, - vec![ - ("x86_64-unknown-linux-musl", "linux", "x64", "quire"), - ("aarch64-unknown-linux-musl", "linux", "arm64", "quire"), - ("aarch64-apple-darwin", "darwin", "arm64", "quire"), - ("x86_64-pc-windows-msvc", "win32", "x64", "quire.exe"), - ] - ); - let rust: BTreeSet<_> = TARGETS.iter().map(|target| target.rust).collect(); - let hosts: BTreeSet<_> = TARGETS - .iter() - .map(|target| (target.platform, target.arch)) - .collect(); - assert_eq!(rust.len(), TARGETS.len()); - assert_eq!(hosts.len(), TARGETS.len()); -} - -#[trace("TC-816", "FR-023-AC-4", "FR-023-AC-5")] -#[trace("FR-023-AC-6", "FR-023-AC-7", "US-007-AC-4")] -#[test] -fn tc816_package_generation_is_complete_exact_and_deterministic() { - let fixture = fixture(); - fs::create_dir_all(&fixture.output).expect("create stale output"); - fs::write(fixture.output.join("stale"), "remove me").expect("write stale output"); - package(&fixture).expect("generate packages"); - assert!(!fixture.output.join("stale").exists()); - - let expected_dirs: BTreeSet<_> = TARGETS - .iter() - .map(|target| format!("quire-cli-{}-{}", target.platform, target.arch)) - .collect(); - let actual_dirs: BTreeSet<_> = fs::read_dir(&fixture.output) - .expect("read output") - .map(|entry| { - entry - .expect("output entry") - .file_name() - .to_string_lossy() - .into_owned() - }) - .collect(); - assert_eq!(actual_dirs, expected_dirs); - - for target in TARGETS { - let directory = fixture - .output - .join(format!("quire-cli-{}-{}", target.platform, target.arch)); - let members: BTreeSet<_> = tree_snapshot(&directory).keys().cloned().collect(); - assert_eq!( - members, - BTreeSet::from([ - PathBuf::from("LICENSE"), - PathBuf::from("package.json"), - PathBuf::from("bin").join(target.binary), - ]) - ); - assert_eq!( - fs::read(directory.join("LICENSE")).expect("package license"), - fs::read(&fixture.license).expect("root license") - ); - let manifest: Value = - serde_json::from_slice(&fs::read(directory.join("package.json")).expect("manifest")) - .expect("parse manifest"); - assert_eq!(manifest["name"], package_name(target.platform, target.arch)); - assert_eq!(manifest["version"], VERSION); - assert_eq!(manifest["license"], "AGPL-3.0-or-later"); - assert_eq!(manifest["os"], json!([target.platform])); - assert_eq!(manifest["cpu"], json!([target.arch])); - assert_eq!(manifest["publishConfig"]["access"], "public"); - #[cfg(unix)] - if target.platform != "win32" { - use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(directory.join("bin").join(target.binary)) - .expect("binary metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o755); - } - } - - let first = tree_snapshot(&fixture.root.join("npm")); - let launcher = read_launcher(&fixture.launcher.join("package.json")); - assert_eq!(launcher["name"], "@agent-ix/quire-cli"); - assert_eq!(launcher["version"], VERSION); - assert_eq!(launcher["license"], "AGPL-3.0-or-later"); - assert_eq!(launcher["bin"], json!({ "quire": "bin/quire.js" })); - assert_eq!(launcher["files"], json!(["bin/", "LICENSE"])); - let dependencies = launcher["optionalDependencies"] - .as_object() - .expect("launcher optional dependencies"); - assert_eq!(dependencies.len(), TARGETS.len()); - assert!(dependencies.values().all(|value| value == VERSION)); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(fixture.launcher.join("package.json")) - .expect("launcher manifest metadata") - .permissions() - .mode() - & 0o777, - 0o644 - ); - assert_eq!( - fs::metadata(fixture.launcher.join("LICENSE")) - .expect("launcher license metadata") - .permissions() - .mode() - & 0o777, - 0o644 - ); - } - package(&fixture).expect("repeat generation"); - assert_eq!(tree_snapshot(&fixture.root.join("npm")), first); -} - -fn assert_refusal_preserves(mutator: impl FnOnce(&Fixture)) { - let fixture = fixture(); - fs::create_dir_all(&fixture.output).expect("create existing output"); - fs::write(fixture.output.join("sentinel"), "old output").expect("write sentinel"); - let before_output = tree_snapshot(&fixture.output); - let before_launcher = fs::read(fixture.launcher.join("package.json")).expect("launcher before"); - mutator(&fixture); - assert!(package(&fixture).is_err(), "mutant unexpectedly packaged"); - assert_eq!(tree_snapshot(&fixture.output), before_output); - assert_eq!( - fs::read(fixture.launcher.join("package.json")).expect("launcher after"), - before_launcher - ); -} - -#[trace("TC-817", "FR-023-AC-2", "FR-023-AC-3", "FR-023-AC-7")] -#[test] -fn tc817_artifact_inventory_and_identity_mutations_fail_before_output() { - // A build may cover any non-empty subset of the catalog (TC-822) — but - // an empty artifacts directory is never a valid release, and is rejected - // the same as any other malformed input. - assert_refusal_preserves(|fixture| { - for target in TARGETS { - fs::remove_dir_all(fixture.artifacts.join(target.rust)).expect("remove target"); - } - }); - assert_refusal_preserves(|fixture| { - fs::create_dir(fixture.artifacts.join("unexpected-target")).expect("add target"); - }); - assert_refusal_preserves(|fixture| { - let directory = fixture.artifacts.join(TARGETS[0].rust); - fs::rename(directory.join("quire"), directory.join("wrong-name")).expect("rename binary"); - }); - assert_refusal_preserves(|fixture| { - fs::write( - fixture.artifacts.join(TARGETS[0].rust).join("quire"), - b"not an executable", - ) - .expect("write wrong format"); - }); - assert_refusal_preserves(|fixture| { - let x64 = - fs::read(fixture.artifacts.join(TARGETS[0].rust).join("quire")).expect("read x64"); - fs::write(fixture.artifacts.join(TARGETS[1].rust).join("quire"), x64) - .expect("replace arm with x64"); - }); -} - -#[trace("TC-822", "FR-023-AC-2", "FR-023-AC-4", "FR-023-AC-6")] -#[test] -fn tc822_partial_artifact_set_emits_only_the_built_platforms() { - // This is the local publish path's real shape (PLAT-885): only - // linux-x64 is actually built. The generator must emit exactly one - // package and one launcher dependency for it, and nothing at all for - // the three platforms it did not build -- not a dangling pin at a - // version that was never published for them. - let fixture = fixture(); - for target in TARGETS { - if target != TARGETS[0] { - fs::remove_dir_all(fixture.artifacts.join(target.rust)).expect("prune target"); - } - } - package(&fixture).expect("partial build must package"); - - let actual_dirs: BTreeSet<_> = fs::read_dir(&fixture.output) - .expect("read output") - .map(|entry| { - entry - .expect("output entry") - .file_name() - .to_string_lossy() - .into_owned() - }) - .collect(); - assert_eq!( - actual_dirs, - BTreeSet::from([format!( - "quire-cli-{}-{}", - TARGETS[0].platform, TARGETS[0].arch - )]) - ); - - let launcher = read_launcher(&fixture.launcher.join("package.json")); - let dependencies = launcher["optionalDependencies"] - .as_object() - .expect("launcher optional dependencies"); - let built_name = package_name(TARGETS[0].platform, TARGETS[0].arch); - assert_eq!( - dependencies.keys().cloned().collect::>(), - vec![built_name.clone()] - ); - assert_eq!(dependencies[&built_name], VERSION); - for target in &TARGETS[1..] { - assert!(!dependencies.contains_key(&package_name(target.platform, target.arch))); - } -} - -#[trace("TC-818", "FR-024-AC-1", "FR-024-AC-2", "FR-024-AC-3", "FR-024-AC-4")] -#[test] -fn tc818_version_contract_is_typed_complete_and_preflighted() { - let updated = fixture(); - set_version(&SetVersion { - version: "2.3.4-beta.1+build.7", - cargo_manifest: &updated.cargo_manifest, - launcher_manifest: &updated.launcher.join("package.json"), - }) - .expect("set valid version"); - let cargo = fs::read_to_string(&updated.cargo_manifest).expect("updated Cargo"); - assert!(cargo.contains("version = \"2.3.4-beta.1+build.7\"")); - let launcher = read_launcher(&updated.launcher.join("package.json")); - assert_eq!(launcher["version"], "2.3.4-beta.1+build.7"); - assert!(launcher["optionalDependencies"] - .as_object() - .expect("optional dependencies") - .values() - .all(|value| value == "2.3.4-beta.1+build.7")); - - // A launcher declaring a non-empty proper subset of the catalog (the - // local publish path's real shape, PLAT-885) is a valid input: it is - // bumped in place, and the platform it never declared stays absent. - let partial = fixture(); - { - let manifest = partial.launcher.join("package.json"); - let mut launcher = read_launcher(&manifest); - let dependencies = launcher["optionalDependencies"] - .as_object_mut() - .expect("optional dependencies"); - dependencies.remove(&package_name(TARGETS[0].platform, TARGETS[0].arch)); - write_json(&manifest, &launcher); - } - set_version(&SetVersion { - version: "2.5.0", - cargo_manifest: &partial.cargo_manifest, - launcher_manifest: &partial.launcher.join("package.json"), - }) - .expect("set version over a non-empty subset"); - let launcher = read_launcher(&partial.launcher.join("package.json")); - let dependencies = launcher["optionalDependencies"] - .as_object() - .expect("optional dependencies"); - assert_eq!(dependencies.len(), TARGETS.len() - 1); - assert!(dependencies.values().all(|value| value == "2.5.0")); - assert!(!dependencies.contains_key(&package_name(TARGETS[0].platform, TARGETS[0].arch))); - - for mutate in ["invalid-version", "empty-dependencies", "extra-dependency"] { - let fixture = fixture(); - if mutate != "invalid-version" { - let manifest = fixture.launcher.join("package.json"); - let mut launcher = read_launcher(&manifest); - let dependencies = launcher["optionalDependencies"] - .as_object_mut() - .expect("optional dependencies"); - if mutate == "empty-dependencies" { - dependencies.clear(); - } else { - dependencies.insert("@agent-ix/quire-cli-extra".to_owned(), json!(VERSION)); - } - write_json(&manifest, &launcher); - } - let cargo_before = fs::read(&fixture.cargo_manifest).expect("Cargo before"); - let launcher_before = - fs::read(fixture.launcher.join("package.json")).expect("launcher before"); - let requested = if mutate == "invalid-version" { - "not-semver" - } else { - "2.0.0" - }; - assert!(set_version(&SetVersion { - version: requested, - cargo_manifest: &fixture.cargo_manifest, - launcher_manifest: &fixture.launcher.join("package.json"), - }) - .is_err()); - assert_eq!( - fs::read(&fixture.cargo_manifest).expect("Cargo after"), - cargo_before - ); - assert_eq!( - fs::read(fixture.launcher.join("package.json")).expect("launcher after"), - launcher_before - ); - } - - let fixture = fixture(); - assert!(verify_release(&VerifyRelease { - version: "9.9.9", - cargo_manifest: &fixture.cargo_manifest, - launcher_manifest: &fixture.launcher.join("package.json"), - }) - .is_err()); -} - -fn read_launcher(path: &Path) -> Value { - serde_json::from_slice(&fs::read(path).expect("read launcher")).expect("parse launcher") -} - -#[trace("IT-164", "FR-024-AC-4")] -#[test] -fn it164_native_binary_version_mismatch_fails() { - let probe = Path::new(env!("CARGO_BIN_EXE_quire-dist-probe")); - verify_binary_version(VERSION, probe).expect("matching version"); - let error = verify_binary_version("1.2.4", probe).expect_err("mismatch must fail"); - assert!(error.to_string().contains("reports version 1.2.3")); -} - -/// A fake `npm` that answers the two `npm view` shapes `verify_published` -/// actually issues: -/// -/// - `view version ` (one platform package) -/// - `view --json ` (the launcher package, whose -/// response is read from a sibling `published-manifest.json` fixture so -/// each sub-case below can vary it) -/// -/// `registry_arg` is asserted verbatim against `expected_registry_override` -/// and the stub fails, loudly, if it is wrong or absent — this is the fix -/// for the reviewer's finding: a stub that answers regardless of the -/// registry argument tests nothing about the registry argument. Deleting -/// `®istry_override` from either call site in `lib.rs` now fails every -/// "should succeed" case below immediately, not just a dedicated one, since -/// every call this test makes goes through the same assertion. -/// -/// A version containing `missing` is treated as a 404 (exit 1, npm-shaped -/// stderr) in both modes -- measured directly against npm on both npm.ix and -/// public npm, a genuinely absent -/// version reliably exits non-zero with `E404`, never exit 0 with empty -/// stdout, on either registry. A version containing `garbled` echoes a -/// version that disagrees with what was asked for, exit 0 -- the shape -/// `verify_package_resolves`'s `resolved != version` guard exists for. -/// -/// `#[cfg(unix)]`: this and `it165_...resolved_packages` below are the -/// only tests that exercise `verify_published`'s stub-backed hermetic path -/// through a `sh` script, so Windows carries zero coverage of it. Accepted -/// deliberately, not by omission: `tests/npm_host.rs` already takes the -/// same `#![cfg(unix)]` stance file-wide for the identical reason (a shell -/// stub host), and `make ci`/this repo's CI never run `cargo test` on -/// Windows at all -- the only Windows leg is `release.yml`'s -/// `x86_64-pc-windows-msvc` *build* matrix entry, which never runs the test -/// suite. The real-network `it165_unreachable_registry_...` case below is -/// not `#[cfg(unix)]` and does run cross-platform. -#[cfg(unix)] -fn write_stub_npm(directory: &Path, expected_registry_override: &str) -> PathBuf { - use std::os::unix::fs::PermissionsExt; - let path = directory.join("npm"); - let script = format!( - "#!/bin/sh\n\ - here=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\"\n\ - spec=\"$2\"\n\ - mode=\"$3\"\n\ - registry_arg=\"$4\"\n\ - if [ \"$registry_arg\" != {expected_registry_override:?} ]; then\n\ - \x20\x20echo \"npm error: unexpected or missing registry override '$registry_arg'\" >&2\n\ - \x20\x20exit 1\n\ - fi\n\ - version=\"${{spec##*@}}\"\n\ - case \"$mode\" in\n\ - \x20\x20--json)\n\ - \x20\x20\x20\x20case \"$version\" in\n\ - \x20\x20\x20\x20\x20\x20*missing*) echo 'npm error code E404' >&2; exit 1 ;;\n\ - \x20\x20\x20\x20\x20\x20*) cat \"$here/published-manifest.json\" ;;\n\ - \x20\x20\x20\x20esac\n\ - \x20\x20\x20\x20;;\n\ - \x20\x20version)\n\ - \x20\x20\x20\x20case \"$version\" in\n\ - \x20\x20\x20\x20\x20\x20*missing*) echo 'npm error code E404' >&2; exit 1 ;;\n\ - \x20\x20\x20\x20\x20\x20*garbled*) echo 'not-the-requested-version' ;;\n\ - \x20\x20\x20\x20\x20\x20*) echo \"$version\" ;;\n\ - \x20\x20\x20\x20esac\n\ - \x20\x20\x20\x20;;\n\ - \x20\x20*)\n\ - \x20\x20\x20\x20echo \"npm error: unknown query mode '$mode'\" >&2\n\ - \x20\x20\x20\x20exit 1\n\ - \x20\x20\x20\x20;;\n\ - esac\n" - ); - fs::write(&path, script).expect("write stub npm"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod stub npm"); - path -} - -#[cfg(unix)] -#[trace("IT-165", "FR-023-AC-9")] -#[test] -fn it165_publish_resolvability_gate_fails_closed_and_passes_on_resolved_packages() { - let registry = "http://npm.ix/"; - let expected_registry_override = "--@agent-ix:registry=http://npm.ix/"; - let stub_dir = tempfile::tempdir().expect("stub npm directory"); - let npm = write_stub_npm(stub_dir.path(), expected_registry_override); - let manifest_fixture = stub_dir.path().join("published-manifest.json"); - - let write_published_manifest = |version: &str, dependencies: Value| { - write_json( - &manifest_fixture, - &json!({ "version": version, "optionalDependencies": dependencies }), - ); - }; - let verify = |version: &str| { - verify_published(&VerifyPublished { - version, - registry, - npm_binary: npm.as_os_str(), - }) - }; - - // The launcher itself, and every declared platform package, resolves at - // its pinned version: success. This is also the mutation-sensitivity - // check for the registry argument: if `lib.rs` stopped passing - // `--@agent-ix:registry=…`, the stub above would refuse every call the - // launcher-manifest fetch makes, and this assertion would fail first. - write_published_manifest( - "0.32.3", - json!({ "@agent-ix/quire-cli-linux-x64": "0.32.3" }), - ); - verify("0.32.3").expect("launcher and platform package both resolve"); - - // The launcher package itself does not resolve at the requested version - // (its own `npm publish` failed after the platform packages already - // succeeded, or nothing was published at all) -- must fail, naming the - // launcher, never silently falling back to checking only the platform - // packages a local file happened to list. - let error = verify("0.32.3-missing").expect_err("unresolved launcher must fail"); - assert!(error - .to_string() - .contains(&format!("{LAUNCHER_PACKAGE_NAME}@0.32.3-missing"))); - - // One declared platform package does not exist at its pinned version: - // the exact PLAT-885 shape (three dangling platform pins alongside one - // real one), and it must fail, naming the unresolved package. - write_published_manifest( - "0.32.3", - json!({ - "@agent-ix/quire-cli-linux-x64": "0.32.3", - "@agent-ix/quire-cli-darwin-arm64": "0.32.3-missing", - }), - ); - let error = verify("0.32.3").expect_err("unresolved platform package must fail"); - assert!(error - .to_string() - .contains("@agent-ix/quire-cli-darwin-arm64@0.32.3-missing")); - - // `npm view` can exit 0 while printing something other than what was - // asked for -- defend against trusting a non-empty stdout as proof on - // its own. - write_published_manifest( - "0.32.3", - json!({ "@agent-ix/quire-cli-linux-x64": "0.32.3-garbled" }), - ); - let error = verify("0.32.3").expect_err("a printed version that disagrees must fail"); - assert!(error - .to_string() - .contains("did not resolve to \"0.32.3-garbled\"")); - - // Empty optionalDependencies (or the field absent entirely) must fail - // outright, not pass by iterating zero entries -- that silent success - // is the defect class this gate exists to catch (PLAT-885). - write_published_manifest("0.32.3", json!({})); - let error = verify("0.32.3").expect_err("empty dependency set must fail"); - assert!(error - .to_string() - .contains("must declare at least one platform package")); -} - -/// The unreachable-registry half of FR-023-AC-9, against the real `npm` -/// binary rather than the hermetic stub: a host that cannot even resolve -/// must fail the gate. This is precisely the scenario finding 1 measured as -/// silently passing under the original `--registry` flag (npm resolved the -/// scope against this host's own default and never consulted the flag). -/// Network-dependent, so it is `#[ignore]`d out of the default offline -/// suite rather than skipping silently or flaking under a blocked host. -#[trace("IT-165", "FR-023-AC-9")] -#[test] -#[ignore = "hits the real npm binary and a non-resolving network address; run explicitly with `cargo test -- --ignored`"] -fn it165_unreachable_registry_fails_against_real_npm() { - verify_published(&VerifyPublished { - version: "0.32.3", - registry: "http://does-not-exist.invalid/", - npm_binary: OsStr::new("npm"), - }) - .expect_err("an unreachable registry must fail the gate, not resolve against some default"); -} diff --git a/tools/quire-dist/tests/npm_host.rs b/tools/quire-dist/tests/npm_host.rs deleted file mode 100644 index 9260f4e..0000000 --- a/tools/quire-dist/tests/npm_host.rs +++ /dev/null @@ -1,390 +0,0 @@ -#![cfg(unix)] - -use std::collections::BTreeMap; -use std::fs; -use std::io::Write; -use std::os::unix::fs::symlink; -use std::os::unix::process::ExitStatusExt; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -use ix_trace_rs::trace; -use quire_dist::{package_npm, DistributionTarget, PackageNpm, TARGETS}; -use serde_json::{json, Map, Value}; - -const VERSION: &str = "1.2.3"; - -fn repo_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("quire-cli repository root") - .to_path_buf() -} - -fn host_target() -> Option { - let (platform, arch) = match (std::env::consts::OS, std::env::consts::ARCH) { - ("linux", "x86_64") => ("linux", "x64"), - ("linux", "aarch64") => ("linux", "arm64"), - ("macos", "aarch64") => ("darwin", "arm64"), - _ => return None, - }; - TARGETS - .iter() - .copied() - .find(|target| target.platform == platform && target.arch == arch) -} - -fn package_name(target: &DistributionTarget) -> String { - format!("@agent-ix/quire-cli-{}-{}", target.platform, target.arch) -} - -fn valid_binary_bytes(rust_target: &str) -> Vec { - match rust_target { - "x86_64-unknown-linux-musl" => { - let mut bytes = vec![0; 64]; - bytes[..6].copy_from_slice(b"\x7fELF\x02\x01"); - bytes[18..20].copy_from_slice(&62_u16.to_le_bytes()); - bytes - } - "aarch64-unknown-linux-musl" => { - let mut bytes = vec![0; 64]; - bytes[..6].copy_from_slice(b"\x7fELF\x02\x01"); - bytes[18..20].copy_from_slice(&183_u16.to_le_bytes()); - bytes - } - "aarch64-apple-darwin" => { - let mut bytes = vec![0; 32]; - bytes[..4].copy_from_slice(&[0xcf, 0xfa, 0xed, 0xfe]); - bytes[4..8].copy_from_slice(&0x0100_000c_u32.to_le_bytes()); - bytes - } - "x86_64-pc-windows-msvc" => { - let mut bytes = vec![0; 128]; - bytes[..2].copy_from_slice(b"MZ"); - bytes[0x3c..0x40].copy_from_slice(&64_u32.to_le_bytes()); - bytes[64..68].copy_from_slice(b"PE\0\0"); - bytes[68..70].copy_from_slice(&0x8664_u16.to_le_bytes()); - bytes - } - other => panic!("unhandled target {other}"), - } -} - -fn launcher_manifest(targets: impl IntoIterator) -> Value { - let dependencies: Map<_, _> = targets - .into_iter() - .map(|target| (package_name(&target), json!(VERSION))) - .collect(); - json!({ - "name": "@agent-ix/quire-cli", - "version": VERSION, - "description": "native launcher fixture", - "license": "AGPL-3.0-or-later", - "type": "commonjs", - "bin": { "quire": "bin/quire.js" }, - "files": ["bin/", "LICENSE"], - "engines": { "node": ">=16" }, - "optionalDependencies": dependencies, - "publishConfig": { - "registry": "https://registry.npmjs.org/", - "access": "public" - } - }) -} - -fn write_json(path: &Path, value: &Value) { - let mut bytes = serde_json::to_vec_pretty(value).expect("serialize JSON"); - bytes.push(b'\n'); - fs::write(path, bytes).expect("write JSON"); -} - -fn prepare_packages() -> (tempfile::TempDir, PathBuf, DistributionTarget) { - let host = host_target().expect("local host must be an admitted npm target"); - let temporary = tempfile::tempdir().expect("temporary package fixture"); - let root = temporary.path(); - let cargo_manifest = root.join("Cargo.toml"); - fs::write( - &cargo_manifest, - format!("[package]\nname = \"fixture\"\nversion = \"{VERSION}\"\n"), - ) - .expect("write Cargo manifest"); - let artifacts = root.join("artifacts"); - for target in TARGETS { - let directory = artifacts.join(target.rust); - fs::create_dir_all(&directory).expect("create artifact directory"); - if target == host { - fs::copy( - env!("CARGO_BIN_EXE_quire-dist-probe"), - directory.join(target.binary), - ) - .expect("copy native probe"); - } else { - fs::write( - directory.join(target.binary), - valid_binary_bytes(target.rust), - ) - .expect("write synthetic cross-target binary"); - } - } - let launcher = root.join("npm/quire-cli"); - fs::create_dir_all(launcher.join("bin")).expect("create launcher"); - fs::copy( - repo_root().join("npm/quire-cli/bin/quire.js"), - launcher.join("bin/quire.js"), - ) - .expect("copy launcher host"); - fs::copy( - repo_root().join("npm/quire-cli/README.md"), - launcher.join("README.md"), - ) - .expect("copy launcher README"); - write_json(&launcher.join("package.json"), &launcher_manifest(TARGETS)); - let license = root.join("LICENSE"); - fs::write(&license, "fixture AGPL license\n").expect("write license"); - let output = root.join("npm/dist"); - package_npm(&PackageNpm { - version: VERSION, - cargo_manifest: &cargo_manifest, - artifacts_dir: &artifacts, - output_dir: &output, - launcher_dir: &launcher, - license: &license, - }) - .expect("generate npm packages"); - (temporary, output, host) -} - -fn npm_pack(directory: &Path, destination: &Path) -> PathBuf { - let output = Command::new("npm") - .args([ - "pack", - "--offline", - "--ignore-scripts", - "--json", - "--pack-destination", - ]) - .arg(destination) - .current_dir(directory) - .output() - .expect("run npm pack"); - assert!( - output.status.success(), - "npm pack failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let report: Value = serde_json::from_slice(&output.stdout).expect("parse npm pack report"); - let filename = report[0]["filename"].as_str().expect("pack filename"); - destination.join(filename) -} - -#[trace("IT-159", "US-007-AC-1", "US-007-AC-2")] -#[trace("FR-022-AC-1", "FR-022-AC-2")] -#[trace("FR-023-AC-8", "NFR-008-AC-5")] -#[test] -fn it_159_clean_offline_install_runs_the_matching_native_binary_transparently() { - let (temporary, output, host) = prepare_packages(); - let tarballs = temporary.path().join("tarballs"); - fs::create_dir(&tarballs).expect("create tarball directory"); - let platform = TARGETS - .iter() - .map(|target| { - ( - *target, - npm_pack( - &output.join(format!("quire-cli-{}-{}", target.platform, target.arch)), - &tarballs, - ), - ) - }) - .find_map(|(target, tarball)| (target == host).then_some(tarball)) - .expect("matching host tarball"); - let launcher = npm_pack(&temporary.path().join("npm/quire-cli"), &tarballs); - let consumer = temporary.path().join("consumer"); - fs::create_dir(&consumer).expect("create consumer"); - let install = Command::new("npm") - .args([ - "install", - "--offline", - "--ignore-scripts", - "--no-audit", - "--no-fund", - "--package-lock=false", - ]) - .arg(&launcher) - .arg(&platform) - .current_dir(&consumer) - .output() - .expect("run offline npm install"); - assert!( - install.status.success(), - "offline npm install failed: {}\n{}", - String::from_utf8_lossy(&install.stdout), - String::from_utf8_lossy(&install.stderr) - ); - - let mut child = Command::new(consumer.join("node_modules/.bin/quire")) - .args(["alpha", "two words", "--exit", "23"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn installed launcher"); - child - .stdin - .take() - .expect("launcher stdin") - .write_all(b"consumer-input") - .expect("write launcher stdin"); - let output = child.wait_with_output().expect("wait for launcher"); - assert_eq!(output.status.code(), Some(23)); - let stdout = String::from_utf8(output.stdout).expect("probe stdout"); - assert!(stdout.contains("args=[\"alpha\",\"two words\",\"--exit\",\"23\"]")); - assert!(stdout.contains("stdin=consumer-input")); - assert_eq!( - String::from_utf8(output.stderr).expect("probe stderr"), - "probe-stderr\n" - ); -} - -#[trace("IT-163", "FR-023-AC-8", "US-007-AC-4", "NFR-008-AC-5", "NFR-008-AC-6")] -#[test] -fn it163_npm_pack_reports_exact_members_and_licenses_offline() { - let (temporary, output, _) = prepare_packages(); - let mut packages = vec![temporary.path().join("npm/quire-cli")]; - packages.extend( - TARGETS - .iter() - .map(|target| output.join(format!("quire-cli-{}-{}", target.platform, target.arch))), - ); - - for package in packages { - let output = Command::new("npm") - .args([ - "pack", - "--offline", - "--ignore-scripts", - "--json", - "--dry-run", - ]) - .current_dir(&package) - .output() - .expect("run npm pack dry-run"); - assert!(output.status.success(), "npm pack dry-run failed"); - let report: Value = serde_json::from_slice(&output.stdout).expect("parse pack report"); - let files: BTreeMap<_, _> = report[0]["files"] - .as_array() - .expect("pack files") - .iter() - .map(|file| { - ( - file["path"].as_str().expect("file path"), - file["size"].as_u64().expect("file size"), - ) - }) - .collect(); - assert_eq!( - files.keys().copied().collect::>(), - if package.ends_with("quire-cli") { - vec!["LICENSE", "README.md", "bin/quire.js", "package.json"] - } else if package.ends_with("quire-cli-win32-x64") { - vec!["LICENSE", "bin/quire.exe", "package.json"] - } else { - vec!["LICENSE", "bin/quire", "package.json"] - } - ); - assert_eq!( - fs::read(package.join("LICENSE")).expect("package license"), - b"fixture AGPL license\n" - ); - } -} - -fn launcher_tree(targets: impl IntoIterator) -> tempfile::TempDir { - let temporary = tempfile::tempdir().expect("temporary launcher tree"); - let launcher = temporary.path().join("node_modules/@agent-ix/quire-cli"); - fs::create_dir_all(launcher.join("bin")).expect("create launcher tree"); - fs::copy( - repo_root().join("npm/quire-cli/bin/quire.js"), - launcher.join("bin/quire.js"), - ) - .expect("copy launcher"); - write_json(&launcher.join("package.json"), &launcher_manifest(targets)); - temporary -} - -fn run_launcher(tree: &Path, arguments: &[&str]) -> std::process::Output { - Command::new("node") - .arg(tree.join("node_modules/@agent-ix/quire-cli/bin/quire.js")) - .args(arguments) - .output() - .expect("run Node launcher") -} - -#[trace("IT-160", "US-007-AC-3", "FR-022-AC-4", "FR-022-AC-5")] -#[test] -fn it160_unsupported_and_missing_packages_fail_without_substitution() { - let host = host_target().expect("admitted host"); - let unsupported = launcher_tree(TARGETS.into_iter().filter(|target| *target != host)); - let output = run_launcher(unsupported.path(), &[]); - assert_eq!(output.status.code(), Some(1)); - let stderr = String::from_utf8(output.stderr).expect("unsupported stderr"); - assert!(stderr.contains("unsupported platform")); - assert!(stderr.contains("Build from source instead")); - assert!(!stderr.contains(&format!("{}-{}.", host.platform, host.arch))); - - let missing = launcher_tree(TARGETS); - let output = run_launcher(missing.path(), &[]); - assert_eq!(output.status.code(), Some(1)); - let stderr = String::from_utf8(output.stderr).expect("missing stderr"); - assert!(stderr.contains(&package_name(&host))); - assert!(stderr.contains("optional dependency")); -} - -fn install_host_link(tree: &Path, target: &DistributionTarget, source: &Path) -> PathBuf { - let binary = tree - .join("node_modules") - .join(package_name(target)) - .join("bin") - .join(target.binary); - fs::create_dir_all(binary.parent().expect("binary parent")).expect("create host package"); - symlink(source, &binary).expect("link host binary"); - binary -} - -#[trace("IT-161", "FR-022-AC-6")] -#[test] -fn it161_chmod_refusal_is_tolerated_and_spawn_failure_is_named() { - let host = host_target().expect("admitted host"); - let success = launcher_tree(TARGETS); - // macOS ships true in /usr/bin; Linux ships it in /bin. - let executable = if cfg!(target_os = "macos") { - Path::new("/usr/bin/true") - } else { - Path::new("/bin/true") - }; - install_host_link(success.path(), &host, executable); - let output = run_launcher(success.path(), &[]); - assert!( - output.status.success(), - "root-owned executable must still launch" - ); - - let failure = launcher_tree(TARGETS); - install_host_link(failure.path(), &host, Path::new("/etc/hosts")); - let output = run_launcher(failure.path(), &[]); - assert_eq!(output.status.code(), Some(1)); - assert!(String::from_utf8(output.stderr) - .expect("launch failure stderr") - .contains("failed to launch binary")); -} - -#[trace("IT-162", "US-007-AC-2", "FR-022-AC-3")] -#[test] -fn it162_child_signal_is_mirrored_by_the_launcher() { - let host = host_target().expect("admitted host"); - let fixture = launcher_tree(TARGETS); - install_host_link(fixture.path(), &host, Path::new("/bin/sh")); - let output = run_launcher(fixture.path(), &["-c", "kill -ABRT $$"]); - assert_eq!(output.status.signal(), Some(6)); -} diff --git a/tools/quire-dist/tests/policy.rs b/tools/quire-dist/tests/policy.rs deleted file mode 100644 index a5b74ce..0000000 --- a/tools/quire-dist/tests/policy.rs +++ /dev/null @@ -1,243 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use ix_trace_rs::trace; - -fn repo_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("quire-cli repository root") - .to_path_buf() -} - -fn rust_source_paths(directory: &Path, paths: &mut Vec) { - let mut entries: Vec<_> = fs::read_dir(directory) - .expect("read Rust source directory") - .map(Result::unwrap) - .collect(); - entries.sort_by_key(|entry| entry.path()); - for entry in entries { - let path = entry.path(); - if entry.file_type().expect("source file type").is_dir() { - rust_source_paths(&path, paths); - } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { - paths.push(path); - } - } -} - -fn audit_launcher(source: &str) -> Vec { - let mut findings = Vec::new(); - for forbidden in [ - "const SUPPORTED", - "parse_document", - "extract_tree", - "clause_set", - "temporal", - "protocol", - "child_process.exec", - "eval(", - ] { - if source.contains(forbidden) { - findings.push(format!("launcher contains forbidden token {forbidden:?}")); - } - } - for required in [ - "require(\"node:child_process\")", - "require(\"node:fs\")", - "require(\"../package.json\")", - "Object.prototype.hasOwnProperty.call", - "spawnSync(bin, process.argv.slice(2), { stdio: \"inherit\" })", - "process.kill(process.pid, result.signal)", - ] { - if !source.contains(required) { - findings.push(format!( - "launcher is missing required boundary {required:?}" - )); - } - } - findings -} - -fn audit_release_workflow(workflow: &str) -> Vec { - let mut findings = Vec::new(); - let trigger = workflow - .split_once("concurrency:") - .map_or(workflow, |(prefix, _)| prefix); - if !trigger.contains("workflow_dispatch:") - || trigger.contains("\n push:") - || trigger.contains("\n pull_request:") - { - findings.push("release trigger is not manual-dispatch-only".to_owned()); - } - for required in [ - "cargo run --locked -p quire-dist -- verify-binary", - "cargo run --locked -p quire-dist -- verify-release", - "cargo run --locked -p quire-dist -- package-npm", - "cargo run --locked -p quire-dist -- verify-published", - ] { - if !workflow.contains(required) { - findings.push(format!("release workflow is missing {required:?}")); - } - } - for forbidden in [ - "node npm/build-packages.mjs", - "node -e", - "perl -", - "set_version.sh", - "npm install -g npm@latest", - ] { - if workflow.contains(forbidden) { - findings.push(format!("release workflow contains {forbidden:?}")); - } - } - // PLAT-885: publishing, and verifying what publishing actually produced, - // are both irreversible-adjacent or registry-dependent and must not run - // on a dry-run dispatch. - if workflow.matches("if: inputs.publish").count() != 3 { - findings.push("every publish-adjacent step must be guarded by inputs.publish".to_owned()); - } - findings -} - -fn javascript_paths(root: &Path) -> Vec { - fn visit(root: &Path, directory: &Path, paths: &mut Vec) { - let mut entries: Vec<_> = fs::read_dir(directory) - .expect("read npm tree") - .map(Result::unwrap) - .collect(); - entries.sort_by_key(|entry| entry.path()); - for entry in entries { - let path = entry.path(); - if entry.file_type().expect("npm file type").is_dir() { - visit(root, &path, paths); - } else if matches!( - path.extension().and_then(|value| value.to_str()), - Some("js" | "mjs" | "cjs") - ) { - paths.push( - path.strip_prefix(root) - .expect("relative npm path") - .to_path_buf(), - ); - } - } - } - - let mut paths = Vec::new(); - visit(root, &root.join("npm"), &mut paths); - paths -} - -#[trace("TC-815", "FR-022-AC-7", "NFR-008-AC-1")] -#[test] -fn tc815_launcher_is_the_single_bounded_javascript_host() { - let root = repo_root(); - assert_eq!( - javascript_paths(&root), - vec![PathBuf::from("npm/quire-cli/bin/quire.js")] - ); - let launcher = - fs::read_to_string(root.join("npm/quire-cli/bin/quire.js")).expect("read launcher"); - assert_eq!(audit_launcher(&launcher), Vec::::new()); - - let mutant = format!("const SUPPORTED = [];\n{launcher}"); - assert!( - audit_launcher(&mutant) - .iter() - .any(|finding| finding.contains("SUPPORTED")), - "a second launcher catalog escaped" - ); -} - -#[trace("TC-819", "FR-023-AC-9", "FR-024-AC-5", "FR-024-AC-6", "NFR-008-AC-1")] -#[test] -fn tc819_release_workflow_is_manual_native_and_mutation_sensitive() { - let root = repo_root(); - assert!(!root.join("npm/build-packages.mjs").exists()); - assert!(!root.join("scripts/set_version.sh").exists()); - let path = root.join(".github/workflows/release.yml"); - let production = fs::read_to_string(path).expect("read release workflow"); - assert_eq!(audit_release_workflow(&production), Vec::::new()); - - let mutants = [ - production.replacen("workflow_dispatch:", "push:", 1), - production.replacen( - "cargo run --locked -p quire-dist -- verify-release", - "true", - 1, - ), - production.replacen( - "cargo run --locked -p quire-dist -- package-npm", - "node npm/build-packages.mjs", - 1, - ), - production.replacen( - "cargo run --locked -p quire-dist -- verify-published", - "true", - 1, - ), - production.replacen("if: inputs.publish", "if: success()", 1), - ]; - for mutant in mutants { - assert!( - !audit_release_workflow(&mutant).is_empty(), - "release-policy mutant escaped" - ); - } -} - -#[trace("TC-820", "FR-022-AC-7", "NFR-008-AC-2", "NFR-008-AC-3")] -#[test] -fn tc820_distribution_tests_are_traced_and_semantically_contained() { - let root = repo_root(); - let manifest = fs::read_to_string(root.join("tools/quire-dist/Cargo.toml")) - .expect("read distribution manifest"); - assert!(!manifest.contains("quire-rs")); - - let mut sources = Vec::new(); - rust_source_paths(&root.join("tools/quire-dist/src"), &mut sources); - for path in sources { - let source = fs::read_to_string(&path).expect("read distribution source"); - for forbidden in [ - "quire_rs::", - "parse_document(", - "extract_tree(", - "evaluate_clause", - "parse_temporal", - "parse_protocol", - ] { - assert!( - !source.contains(forbidden), - "{} contains forbidden semantic dependency {forbidden:?}", - path.display() - ); - } - } - - let tests = root.join("tools/quire-dist/tests"); - let mut test_sources = Vec::new(); - rust_source_paths(&tests, &mut test_sources); - for path in test_sources { - let source = fs::read_to_string(&path).expect("read distribution test"); - let lines: Vec<_> = source.lines().collect(); - for (index, line) in lines.iter().enumerate() { - if line.trim() != "#[test]" { - continue; - } - let previous_test = lines[..index] - .iter() - .rposition(|candidate| candidate.trim() == "#[test]") - .map_or(0, |previous| previous + 1); - assert!( - lines[previous_test..index] - .iter() - .any(|candidate| candidate.contains("trace(")), - "{}:{} has an untraced distribution test", - path.display(), - index + 1 - ); - } - } -} diff --git a/tools/quire-dist/tests/verify_binary.rs b/tools/quire-dist/tests/verify_binary.rs new file mode 100644 index 0000000..4d5c402 --- /dev/null +++ b/tools/quire-dist/tests/verify_binary.rs @@ -0,0 +1,26 @@ +//! `verify-binary` accepts a matching `--version` and rejects a mismatch. +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use ix_trace_rs::trace; +use quire_dist::verify_binary_version; + +fn fake_binary(dir: &Path, reported: &str) -> std::path::PathBuf { + let path = dir.join("quire"); + fs::write(&path, format!("#!/bin/sh\necho 'quire {reported}'\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +#[trace("TC-818", "FR-022-AC-4")] +#[test] +fn verify_binary_accepts_match_and_rejects_mismatch_or_invalid_version() { + let dir = tempfile::tempdir().unwrap(); + let binary = fake_binary(dir.path(), "1.2.3"); + verify_binary_version("1.2.3", &binary).unwrap(); + assert!(verify_binary_version("1.2.4", &binary).is_err()); + assert!(verify_binary_version("not-semver", &binary).is_err()); +} diff --git a/tools/quire-qualify/tests/policy.rs b/tools/quire-qualify/tests/policy.rs index 82e419c..bef3c0e 100644 --- a/tools/quire-qualify/tests/policy.rs +++ b/tools/quire-qualify/tests/policy.rs @@ -71,9 +71,9 @@ fn make_recipes_only_orchestrate_native_qualification() { } } -#[trace("TC-839", "FR-025-AC-7", "FR-025-CON-4", "FR-025-CON-5")] +#[trace("TC-839", "FR-025-AC-7", "FR-025-CON-4")] #[test] -fn executable_script_inventory_is_empty_except_for_the_npm_host() { +fn executable_script_inventory_is_empty() { let root = repo_root(); let mut files = Vec::new(); walk_files( @@ -110,21 +110,10 @@ fn executable_script_inventory_is_empty_except_for_the_npm_host() { .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("js")) .map(|path| path.strip_prefix(&root).unwrap().to_path_buf()) .collect(); - assert_eq!(javascript, [PathBuf::from("npm/quire-cli/bin/quire.js")]); - let launcher = fs::read_to_string(root.join(&javascript[0])).unwrap(); - for forbidden in [ - "coverage", - "benchmark", - "bench-p95", - "unsafe-comment", - "thin-boundary", - "qualification", - ] { - assert!( - !launcher.contains(forbidden), - "npm distribution host acquired qualification semantic {forbidden:?}" - ); - } + assert!( + javascript.is_empty(), + "repository-owned JavaScript remains: {javascript:?}" + ); } #[trace("TC-840", "NFR-009")]