diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 050ac12b4..b5827c5e1 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -4,6 +4,26 @@ on: release: types: - published + workflow_call: + inputs: + release_tag: + description: "Release tag to publish (for example v0.1.0)" + required: true + type: string + dry_run: + description: "Run npm publish --dry-run instead of publishing" + required: false + default: false + type: boolean + prerelease: + description: "Publish with the next dist-tag instead of latest; version must include semver prerelease metadata" + required: false + default: false + type: boolean + secrets: + NPM_TOKEN: + description: "npm automation token used only by the publish step" + required: false workflow_dispatch: inputs: release_tag: @@ -28,15 +48,22 @@ concurrency: cancel-in-progress: false jobs: - publish: + resolve: runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + npm_tag: ${{ steps.resolve.outputs.npm_tag }} + dry_run: ${{ steps.resolve.outputs.dry_run }} + npm_tarball_sha256: ${{ steps.asset.outputs.npm_tarball_sha256 }} env: - DISPATCH_RELEASE_TAG: ${{ inputs.release_tag }} + INPUT_RELEASE_TAG: ${{ inputs.release_tag }} EVENT_RELEASE_TAG: ${{ github.event.release.tag_name }} - DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} - PRE_RELEASE: ${{ github.event_name == 'release' && github.event.release.prerelease || github.event_name == 'workflow_dispatch' && inputs.prerelease || false }} + DRY_RUN: ${{ github.event_name != 'release' && inputs.dry_run || false }} + PRE_RELEASE: ${{ github.event_name == 'release' && github.event.release.prerelease || inputs.prerelease || false }} GH_TOKEN: ${{ github.token }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 @@ -53,9 +80,9 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 20 - registry-url: https://registry.npmjs.org - name: Verify checked-in npm release version parity + id: resolve shell: bash run: | set -euo pipefail @@ -63,10 +90,10 @@ jobs: npm_version="$(node -p "JSON.parse(require('fs').readFileSync('npm/package.json', 'utf8')).version")" version="$(tr -d '\n' < .version)" expected_tag="v${version}" - release_tag="${DISPATCH_RELEASE_TAG:-${EVENT_RELEASE_TAG:-}}" + release_tag="${INPUT_RELEASE_TAG:-${EVENT_RELEASE_TAG:-}}" if [ -z "$release_tag" ]; then - printf 'No release tag was provided. Trigger this workflow from a published GitHub release or pass workflow_dispatch input release_tag.\n' >&2 + printf 'No release tag was provided. Trigger this workflow from a published GitHub release, workflow_call, or pass workflow_dispatch input release_tag.\n' >&2 exit 1 fi @@ -91,85 +118,240 @@ jobs: fi if [ "${PRE_RELEASE:-false}" = "true" ] || [ "$version_is_prerelease" = "true" ]; then - printf 'Publishing checked-in npm prerelease package version %s from tag %s with npm dist-tag next\n' "$version" "$release_tag" + npm_tag="next" + else + npm_tag="latest" + fi + + if [ "${DRY_RUN:-false}" = "true" ]; then + dry_run=true else - printf 'Publishing checked-in npm package version %s from tag %s with npm dist-tag latest\n' "$version" "$release_tag" + dry_run=false fi + printf 'Publishing checked-in npm package version %s from tag %s with npm dist-tag %s (dry run: %s)\n' "$version" "$release_tag" "$npm_tag" "$dry_run" + + { + echo "tag=$release_tag" + echo "version=$version" + echo "npm_tag=$npm_tag" + echo "dry_run=$dry_run" + } >> "$GITHUB_OUTPUT" + - name: Download canonical npm release asset + id: asset shell: bash + env: + RELEASE_TAG: ${{ steps.resolve.outputs.tag }} + RELEASE_VERSION: ${{ steps.resolve.outputs.version }} run: | set -euo pipefail - version="$(tr -d '\n' < .version)" - release_tag="${DISPATCH_RELEASE_TAG:-${EVENT_RELEASE_TAG:-}}" - package_file="sce-v${version}-npm.tgz" + package_file="sce-v${RELEASE_VERSION}-npm.tgz" mkdir -p dist/npm - gh release download "$release_tag" \ + gh release download "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --pattern "$package_file" \ --dir dist/npm if [ ! -f "dist/npm/$package_file" ]; then - printf 'Expected npm release asset %s for tag %s\n' "$package_file" "$release_tag" >&2 + printf 'Expected npm release asset %s for tag %s\n' "$package_file" "$RELEASE_TAG" >&2 exit 1 fi - - name: Verify downloaded package metadata parity - shell: bash - run: | - set -euo pipefail - - version="$(tr -d '\n' < .version)" - package_file="dist/npm/sce-v${version}-npm.tgz" - package_version="$(tar -xOf "$package_file" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" - package_name="$(tar -xOf "$package_file" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).name")" + package_version="$(tar -xOf "dist/npm/$package_file" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" + package_name="$(tar -xOf "dist/npm/$package_file" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).name")" if [ "$package_name" != "@crocoder-dev/sce" ]; then printf 'Downloaded package name %s is not @crocoder-dev/sce\n' "$package_name" >&2 exit 1 fi - if [ "$package_version" != "$version" ]; then - printf 'Downloaded npm package version %s does not match .version %s\n' "$package_version" "$version" >&2 + if [ "$package_version" != "$RELEASE_VERSION" ]; then + printf 'Downloaded npm package version %s does not match .version %s\n' "$package_version" "$RELEASE_VERSION" >&2 exit 1 fi - - name: npm publish dry run - if: env.DRY_RUN == 'true' + tarball_sha256="$(sha256sum "dist/npm/$package_file" | cut -d ' ' -f 1)" + printf 'GitHub Release npm tarball sha256: %s\n' "$tarball_sha256" + echo "npm_tarball_sha256=$tarball_sha256" >> "$GITHUB_OUTPUT" + + github-release-canary: + needs: resolve + if: needs.resolve.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve.outputs.tag }} + + - name: Install Nix + uses: DeterminateSystems/determinate-nix-action@527f17dd63d2d60d3e5552934bc84b9a33a14d11 # v3.22.2 + + - name: Run release-security canary on the GitHub Release + shell: bash + env: + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + RELEASE_VERSION: ${{ needs.resolve.outputs.version }} + NPM_TARBALL_SHA256: ${{ needs.resolve.outputs.npm_tarball_sha256 }} run: | - version="$(tr -d '\n' < .version)" - npm_tag="latest" - case "$version" in - *-*) npm_tag="next" ;; - esac - if [ "${PRE_RELEASE:-false}" = "true" ]; then - npm_tag="next" + set -euo pipefail + + nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/release-canary.sh \ + --github-release "$RELEASE_TAG" \ + --expected-version "$RELEASE_VERSION" \ + --expected-sha256 "$NPM_TARBALL_SHA256" + + publish: + needs: + - resolve + - github-release-canary + if: | + needs.resolve.result == 'success' && + needs.github-release-canary.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + env: + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + RELEASE_VERSION: ${{ needs.resolve.outputs.version }} + NPM_DIST_TAG: ${{ needs.resolve.outputs.npm_tag }} + DRY_RUN: ${{ needs.resolve.outputs.dry_run }} + NPM_TARBALL_SHA256: ${{ needs.resolve.outputs.npm_tarball_sha256 }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 20 + + - name: Download canary-verified npm release asset + shell: bash + run: | + set -euo pipefail + + package_file="dist/npm/sce-v${RELEASE_VERSION}-npm.tgz" + + mkdir -p dist/npm + gh release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "$(basename "$package_file")" \ + --dir dist/npm + + actual_sha256="$(sha256sum "$package_file" | cut -d ' ' -f 1)" + if [ "$actual_sha256" != "$NPM_TARBALL_SHA256" ]; then + printf 'npm tarball sha256 %s does not match canary-verified sha256 %s\n' "$actual_sha256" "$NPM_TARBALL_SHA256" >&2 + exit 1 fi - npm publish "dist/npm/sce-v${version}-npm.tgz" --access public --tag "$npm_tag" --dry-run - - name: Ensure npm token is configured - if: env.DRY_RUN != 'true' + echo "npm tarball sha256 matches the canary-verified bytes: $actual_sha256" + + - name: Check registry for an existing publication + id: registry shell: bash run: | set -euo pipefail - if [ -z "${NPM_TOKEN:-}" ]; then - printf 'NPM_TOKEN secret is required for npm publication.\n' >&2 + + package_file="dist/npm/sce-v${RELEASE_VERSION}-npm.tgz" + local_integrity="sha512-$(openssl dgst -sha512 -binary "$package_file" | openssl base64 -A)" + view_err="$(mktemp)" + + if registry_integrity="$(npm view "@crocoder-dev/sce@${RELEASE_VERSION}" dist.integrity 2>"$view_err")"; then + : + elif grep -q 'E404' "$view_err"; then + registry_integrity="" + else + cat "$view_err" >&2 + printf 'Could not query the npm registry for @crocoder-dev/sce@%s\n' "$RELEASE_VERSION" >&2 exit 1 fi + if [ -z "$registry_integrity" ]; then + printf '@crocoder-dev/sce@%s is not on the registry yet\n' "$RELEASE_VERSION" + echo "already_published=false" >> "$GITHUB_OUTPUT" + elif [ "$registry_integrity" = "$local_integrity" ]; then + printf '@crocoder-dev/sce@%s is already published with matching integrity %s; skipping npm publish\n' "$RELEASE_VERSION" "$registry_integrity" + echo "already_published=true" >> "$GITHUB_OUTPUT" + else + printf '@crocoder-dev/sce@%s is already published with integrity %s, which does not match the GitHub Release tarball integrity %s\n' "$RELEASE_VERSION" "$registry_integrity" "$local_integrity" >&2 + exit 1 + fi + + - name: npm publish dry run + if: env.DRY_RUN == 'true' && steps.registry.outputs.already_published != 'true' + run: npm publish "dist/npm/sce-v${RELEASE_VERSION}-npm.tgz" --access public --tag "$NPM_DIST_TAG" --dry-run + - name: Publish package to npm - if: env.DRY_RUN != 'true' + if: env.DRY_RUN != 'true' && steps.registry.outputs.already_published != 'true' + shell: bash env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - version="$(tr -d '\n' < .version)" - npm_tag="latest" - case "$version" in - *-*) npm_tag="next" ;; - esac - if [ "${PRE_RELEASE:-false}" = "true" ]; then - npm_tag="next" + set -euo pipefail + + if [ -z "${NODE_AUTH_TOKEN:-}" ]; then + printf 'NPM_TOKEN secret is required for npm publication.\n' >&2 + exit 1 fi - npm publish "dist/npm/sce-v${version}-npm.tgz" --access public --tag "$npm_tag" + + npmrc="$RUNNER_TEMP/publish-npmrc" + cat > "$npmrc" <<'EOF' + //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} + EOF + npm publish "dist/npm/sce-v${RELEASE_VERSION}-npm.tgz" \ + --userconfig "$npmrc" \ + --registry https://registry.npmjs.org \ + --access public \ + --tag "$NPM_DIST_TAG" + + registry-canary: + needs: + - resolve + - publish + if: | + needs.resolve.result == 'success' && + needs.publish.result == 'success' && + needs.resolve.outputs.dry_run != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve.outputs.tag }} + + - name: Install Nix + uses: DeterminateSystems/determinate-nix-action@527f17dd63d2d60d3e5552934bc84b9a33a14d11 # v3.22.2 + + - name: Run release-security canary on the published registry version + shell: bash + env: + RELEASE_VERSION: ${{ needs.resolve.outputs.version }} + NPM_TARBALL_SHA256: ${{ needs.resolve.outputs.npm_tarball_sha256 }} + run: | + set -euo pipefail + + nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/release-canary.sh \ + --registry-version "$RELEASE_VERSION" \ + --expected-version "$RELEASE_VERSION" \ + --expected-sha256 "$NPM_TARBALL_SHA256" diff --git a/.github/workflows/release-sce.yml b/.github/workflows/release-sce.yml index 085aee747..5ad1ba016 100644 --- a/.github/workflows/release-sce.yml +++ b/.github/workflows/release-sce.yml @@ -17,12 +17,14 @@ on: default: false permissions: - contents: write + contents: read jobs: create-tag: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + permissions: + contents: write outputs: tag: ${{ steps.release_meta.outputs.tag }} version: ${{ steps.release_meta.outputs.version }} @@ -196,7 +198,7 @@ jobs: release_ref: ${{ needs.resolve-release.outputs.tag }} release_version: ${{ needs.resolve-release.outputs.version }} - release: + assemble: needs: - resolve-release - build-linux @@ -208,6 +210,8 @@ jobs: needs.build-linux-arm.result == 'success' && needs.build-macos-arm.result == 'success' runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 @@ -257,6 +261,112 @@ jobs: path: dist/flatpak-bundle merge-multiple: true + - name: Upload assembled release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sce-assembled-dist + path: dist/ + if-no-files-found: error + + release-canary: + needs: + - resolve-release + - assemble + if: | + needs.resolve-release.result == 'success' && + needs.assemble.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + npm_tarball_sha256: ${{ steps.canary.outputs.npm_tarball_sha256 }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve-release.outputs.tag }} + + - name: Install Nix + uses: DeterminateSystems/determinate-nix-action@527f17dd63d2d60d3e5552934bc84b9a33a14d11 # v3.22.2 + + - name: Download assembled release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sce-assembled-dist + path: dist + + - name: Run release-security canary on the assembled artifacts + id: canary + shell: bash + env: + RELEASE_VERSION: ${{ needs.resolve-release.outputs.version }} + run: | + set -euo pipefail + + tarball="dist/npm/sce-v${RELEASE_VERSION}-npm.tgz" + if [ ! -f "$tarball" ]; then + printf 'Assembled npm tarball %s does not exist\n' "$tarball" >&2 + exit 1 + fi + + tarball_sha256="$(sha256sum "$tarball" | cut -d ' ' -f 1)" + + nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/release-canary.sh \ + --release-dir dist/cli \ + --tarball "$tarball" \ + --expected-version "$RELEASE_VERSION" \ + --expected-sha256 "$tarball_sha256" + + echo "npm_tarball_sha256=$tarball_sha256" >> "$GITHUB_OUTPUT" + + release: + needs: + - resolve-release + - assemble + - release-canary + if: | + needs.resolve-release.result == 'success' && + needs.assemble.result == 'success' && + needs.release-canary.result == 'success' && + needs.release-canary.outputs.npm_tarball_sha256 != '' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Download assembled release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sce-assembled-dist + path: dist + + - name: Verify npm tarball matches the canary-verified bytes + shell: bash + env: + RELEASE_VERSION: ${{ needs.resolve-release.outputs.version }} + CANARY_SHA256: ${{ needs.release-canary.outputs.npm_tarball_sha256 }} + run: | + set -euo pipefail + + tarball="dist/npm/sce-v${RELEASE_VERSION}-npm.tgz" + actual_sha256="$(sha256sum "$tarball" | cut -d ' ' -f 1)" + + if [ "$actual_sha256" != "$CANARY_SHA256" ]; then + printf 'npm tarball sha256 %s does not match canary-verified sha256 %s\n' "$actual_sha256" "$CANARY_SHA256" >&2 + exit 1 + fi + + echo "npm tarball sha256 matches the canary-verified bytes: $actual_sha256" + - name: Create GitHub release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: @@ -289,3 +399,20 @@ jobs: dist/flatpak-bundle/*.flatpak dist/flatpak-bundle/*.sha256 dist/flatpak-bundle/*.json + + publish-npm: + needs: + - resolve-release + - release + if: | + needs.resolve-release.result == 'success' && + needs.release.result == 'success' + permissions: + contents: read + uses: ./.github/workflows/publish-npm.yml + with: + release_tag: ${{ needs.resolve-release.outputs.tag }} + dry_run: false + prerelease: ${{ github.event_name == 'workflow_dispatch' && inputs.prerelease || false }} + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/context/architecture.md b/context/architecture.md index f9cf7fd14..759fea453 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -41,9 +41,9 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - Crates.io packaging is self-contained through ignored `cli/package-fallback/`: the preparation script delegates canonical Pkl generation and inventories to the shared producer, moves its validated payload and checksums into a temporary staged fallback, adds static hooks/schemas/migrations, and appends static checksums to the exact combined inventory. `cli/Cargo.toml` includes only that fallback and crate sources, and unpacked-crate `build.rs` validates then copies it into `OUT_DIR` without Pkl. Repository builds use `scripts/run-cli-cargo.sh`, which consumes the same producer before Cargo; `build.rs` requires the resulting `SCE_CLI_GENERATED_INPUT_DIR` handoff and copies it into `OUT_DIR` without invoking Pkl. - npm is a downstream consumer of the shared release artifact contract rather than a separate build owner. - `flake.nix` now exposes dedicated release-packaging apps for this rollout: `apps.release-artifacts` packages the current-platform `sce` archive/checksum/manifest-fragment set from the release output `packages.sce-release` (`nix build .#sce-release`), prepares/audits the staged `bin/sce` before archive creation (including macOS `libiconv` install-name rewriting plus ad-hoc re-signing when needed), and fails if `.version`, `cli/Cargo.toml`, `npm/package.json`, the built/prepared CLI version, or the native portability audit disagree; `apps.native-portability-audit` audits native binaries for forbidden `/nix/store/` runtime references using Linux ELF/string inspection or macOS `otool -L`; `apps.release-manifest` merges per-platform metadata fragments into release-level manifest/checksum outputs and signs the merged manifest with a non-repo private key provided via env/file input; `apps.release-npm-package` packs the checked-in npm launcher package into a release-ready `sce-v-npm.tgz` tarball plus npm metadata JSON while refusing mismatched checked-in version metadata and refusing to emit a tarball that fails the packed-package smoke (`npm/test/pack-smoke.sh --tarball`); and Linux-only `apps.release-flatpak-package` emits the Flatpak source-manifest tarball/checksum/JSON metadata while refusing `.version`/Cargo/npm/AppStream version drift and non-git checkouts that cannot resolve a release commit; Linux-only `apps.release-flatpak-bundle` builds a source-built `.flatpak` bundle from the checkout and emits per-architecture bundle/checksum/JSON metadata. -- CLI release automation: `.github/workflows/release-sce.yml` orchestrates `sce` GitHub release asset assembly, validates `.version`/Cargo/npm parity before tagging or releasing, calls three reusable per-platform workflow files (`release-sce-linux.yml`, `release-sce-linux-arm.yml`, `release-sce-macos-arm.yml`), assembles the signed native release manifest, builds npm and Flatpak source-manifest release packages, and uploads the CLI/npm/Flatpak asset set to the GitHub Release. Each native reusable workflow validates its generated archive before native artifact upload by extracting the target archive, smoke-running `bin/sce version --format json`, and running `nix run .#native-portability-audit` with the lane platform. Manual `workflow_dispatch` runs can set the GitHub Release-level `prerelease` flag through an explicit checkbox; tag-triggered releases keep the flag false and no tag-name prerelease inference is performed. -- Downstream registry publication is now implemented in dedicated workflows: `.github/workflows/publish-crates.yml` publishes the checked-in crate version from a published release or manual dispatch after `.version`/tag/Cargo parity checks and requires semver prerelease metadata when a publish run is marked prerelease; `.github/workflows/publish-npm.yml` publishes the checked-in npm package version from a published release or manual dispatch after `.version`/tag/npm parity checks plus verification of the canonical `sce-v-npm.tgz` GitHub release asset, using the `next` npm dist-tag instead of `latest` for prerelease versions or prerelease-marked runs. -- The npm distribution implementation lives under `npm/`: `package.json` defines the `sce` package surface, `bin/sce.js` launches the package-local native binary, `lib/install.js` resolves the current package version against the release manifest, verifies `sce-v-release-manifest.json.sig` with the bundled public key before trusting manifest contents, validates manifest identity, and then installs the checksum-verified native archive for supported macOS/Linux targets through `lib/archive.js` (bounded, entry-validating gzip+tar reader plus atomic `runtime/sce` replacement, no host `tar`), while `test/platform.test.js`, `test/install.test.js`, and `test/archive.test.js` (with the `test/tar-fixture.js` archive builder) cover platform selection, signed-manifest installer behavior, and archive/atomic-install hardening, and `test/pack-smoke.sh` (flake check `npm-pack-smoke`) installs the real packed tarball offline against a loopback fixture release. +- CLI release automation: `.github/workflows/release-sce.yml` orchestrates `sce` GitHub release asset assembly, validates `.version`/Cargo/npm parity before tagging or releasing, calls three reusable per-platform workflow files (`release-sce-linux.yml`, `release-sce-linux-arm.yml`, `release-sce-macos-arm.yml`), assembles the signed native release manifest, builds npm and Flatpak source-manifest release packages in a secret-scoped `assemble` job, gates release creation on a secret-free `release-canary` job that installs the exact assembled npm tarball with its production key (`npm/test/release-canary.sh --release-dir`), and only then uploads the CLI/npm/Flatpak asset set to the GitHub Release. Each native reusable workflow validates its generated archive before native artifact upload by extracting the target archive, smoke-running `bin/sce version --format json`, and running `nix run .#native-portability-audit` with the lane platform. Manual `workflow_dispatch` runs can set the GitHub Release-level `prerelease` flag through an explicit checkbox; tag-triggered releases keep the flag false and no tag-name prerelease inference is performed. +- Downstream registry publication is now implemented in dedicated workflows: `.github/workflows/publish-crates.yml` publishes the checked-in crate version from a published release or manual dispatch after `.version`/tag/Cargo parity checks and requires semver prerelease metadata when a publish run is marked prerelease; `.github/workflows/publish-npm.yml` publishes the checked-in npm package version when called through `workflow_call` by `release-sce.yml` after GitHub Release creation (or from a published release or manual dispatch) after `.version`/tag/npm parity checks plus verification of the canonical `sce-v-npm.tgz` GitHub release asset. It gates `npm publish` on a secret-free GitHub-release canary, skips republishing an identical version, and runs a secret-free registry canary afterwards, using the `next` npm dist-tag instead of `latest` for prerelease versions or prerelease-marked runs. +- The npm distribution implementation lives under `npm/`: `package.json` defines the `sce` package surface, `bin/sce.js` launches the package-local native binary, `lib/install.js` resolves the current package version against the release manifest, verifies `sce-v-release-manifest.json.sig` with the bundled public key before trusting manifest contents, validates manifest identity, and then installs the checksum-verified native archive for supported macOS/Linux targets through `lib/archive.js` (bounded, entry-validating gzip+tar reader plus atomic `runtime/sce` replacement, no host `tar`), while `test/platform.test.js`, `test/install.test.js`, and `test/archive.test.js` (with the `test/tar-fixture.js` archive builder) cover platform selection, signed-manifest installer behavior, and archive/atomic-install hardening, and `test/pack-smoke.sh` (flake check `npm-pack-smoke`) installs the real packed tarball offline against a loopback fixture release built by the shared `test/release-fixture.sh` harness. - `cli/src/main.rs` is the executable entrypoint (`sce`) and delegates to `app::run`. - `cli/src/cli_schema.rs` defines the clap-based CLI schema using derive macros for all top-level commands and subcommands, including the top-level `sync` command, and renders command-local help text for the `auth` command tree (`auth`, `auth login`, `auth logout`, `auth whoami`). diff --git a/context/context-map.md b/context/context-map.md index 758b74e35..55670b051 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -112,8 +112,8 @@ Feature/domain context: - `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s four dispatch arms plus fail-open `NoOp` fallthrough (including `PreToolUse(apply_patch)`, which no `sce hooks codex` registration matches), idempotent `cx_` session prefixing with required trimmed non-empty sessions and truthful reported model-ID preservation (blank models are absent), the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive with a deterministic `cx::user`/`cx::assistant` message ID, the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response, and the implemented `PostToolUse(apply_patch)` slice outer-normalizing then parsing, resolving paths from event cwd against the real Git root, normalizing, and persisting a `diff_traces` row for provable Add/Update evidence under deterministic event-scoped synthetic line identities derived from `tool_use_id`; generated hook commands also resolve the Git root at invocation time and safely reach the helper from nested cwd or spaced repository paths; invalid cwd/path mappings, invalid sessions, or identity/range failures fail open before persistence; all non-policy success/fail-open paths are silent while Bash denial retains Codex's structured response) - `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist `oc_`-prefixed session IDs plus required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current Nix/Cargo/npm/source-built Flatpak channel contract, release authority and workflow topology, Nix-owned Flatpak manifest/cargo-source generation and validation, reduced Flatpak app surface, and host-git bridge decision) -- `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) -- `context/sce/cli-npm-distribution-contract.md` (implemented `sce` npm launcher package, release-manifest/checksum-verified native binary install flow with manifest identity checks, bounded validating archive extraction, and atomic `runtime/sce` install, the supported darwin/arm64 plus linux x64+arm64 npm platform matrix, the executable launcher-mode requirement and offline packed-package smoke test (`npm/test/pack-smoke.sh`, flake check `npm-pack-smoke`, and the `release-npm-package` post-pack gate, plus the opt-in x86_64-linux NixOS VM real-install test `.#npm-nixos-vm`), the prerequisite that npm-consumed native archives are portable and free of forbidden `/nix/store/` runtime references before upload, and dedicated `.github/workflows/publish-npm.yml` downstream npm publish-stage contract including prerelease publication via npm `next` dist-tag instead of `latest`) +- `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, the `assemble`/`release-canary`/`release` job split in `.github/workflows/release-sce.yml` that gates release creation on the exact-artifact canary and scopes the manifest-signing secret to one step, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) +- `context/sce/cli-npm-distribution-contract.md` (implemented `sce` npm launcher package, release-manifest/checksum-verified native binary install flow with manifest identity checks, bounded validating archive extraction, and atomic `runtime/sce` install, the supported darwin/arm64 plus linux x64+arm64 npm platform matrix, the executable launcher-mode requirement and offline packed-package smoke test (`npm/test/pack-smoke.sh` on the shared `npm/test/release-fixture.sh` prepare/serve harness, flake check `npm-pack-smoke`, and the `release-npm-package` post-pack gate, plus the opt-in x86_64-linux NixOS VM real-install test `.#npm-nixos-vm` with its rebuild-recovery, failed-reinstall, real `sce setup --bootstrap-context`, and minimal-runtime scenario subtests, and the statement that these hermetic fixtures use a throwaway key and repack, so they do not prove the exact release artifact or production key, plus the `npm/test/release-canary.sh` release-security canary that installs the unmodified tarball with the production key in local-directory, GitHub-release, and registry modes wired as the B1 `release-canary`, B2 `github-release-canary`, and post-publish `registry-canary` gates), the prerequisite that npm-consumed native archives are portable and free of forbidden `/nix/store/` runtime references before upload, and dedicated `.github/workflows/publish-npm.yml` downstream npm publish-stage contract (`workflow_call` from `release-sce.yml`, `NPM_TOKEN` scoped to the publish step, manual reruns, idempotent republish) including prerelease publication via npm `next` dist-tag instead of `latest`) - `context/sce/cli-cargo-distribution-contract.md` (implemented `sce` Cargo publication posture plus supported crates.io and wrapper-driven local-checkout install guidance, explicit unsupported `cargo install --git` boundary, dedicated crates.io publish workflow with semver prerelease guard, canonical generated-input declaration/producer ownership consumed by the Cargo wrapper, generated-output check, package-fallback preparation, and Nix pre-Cargo derivation, plus the deterministic combined checksummed fallback used for Pkl-free downstream crate builds) - `context/sce/flake-build-performance.md` (current native-vs-release package split, release-only commit embedding, shared-producer-backed pre-Cargo generated-input derivation consumed by native/release/test/Clippy but excluded from dependency/format derivations, slim default/database devShell contract, normal-vs-expensive CI tiers, durable before/after benchmark summary, validation evidence, and remaining cold-build trade-offs) - `context/sce/config-schema-publication.md` (current embedded-only publication contract for the generated SCE config JSON Schema and the canonical SCE web application URL used in config declarations) diff --git a/context/glossary.md b/context/glossary.md index 63db3af6a..1c3a65f74 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -210,7 +210,7 @@ - `sce release-manifest app`: Root-flake app exposed as `nix run .#release-manifest`; merges per-platform `sce` metadata fragments into the release-level manifest JSON, emits detached signature `sce-v-release-manifest.json.sig`, and writes the combined `SHA256SUMS` file. - `SCE_RELEASE_MANIFEST_SIGNING_KEY`: Release-only private-key env var consumed by `nix run .#release-manifest` / `.github/workflows/release-sce.yml` to sign `sce-v-release-manifest.json`; it must never be committed or shipped in the npm package. - `sce npm launcher package`: Checked-in npm package under `npm/` named `@crocoder-dev/sce`; its `postinstall` flow downloads `sce-v-release-manifest.json` plus detached signature `sce-v-release-manifest.json.sig`, verifies that manifest with the bundled public key, validates its identity (package version, binary `sce`, exactly one canonical artifact for the target, 64-hex `checksum_sha256`), checksum-verifies the canonical GitHub release archive, extracts only `bin/sce` through the bounded in-repo reader `npm/lib/archive.js` (no host `tar`), and atomically installs it with mode `0755` into package-local runtime storage. The current supported npm install matrix is `darwin/arm64`, `linux/x64`, and `linux/arm64`, mapping to the current three release target triples. npm consumes portable native GitHub Release archives as-is; release packaging/workflow validation must remove or reject forbidden `/nix/store/` runtime references before upload. npm registry publication is a separate non-bumping downstream stage from GitHub release packaging. -- `publish-npm workflow`: Dedicated npm registry publication workflow at `.github/workflows/publish-npm.yml` that triggers from a published GitHub release or manual dispatch, validates `.version`/`npm/package.json`/release-tag parity, downloads the canonical `sce-v-npm.tgz` GitHub release asset, verifies the tarball's embedded package metadata, and publishes that already-versioned package to npm. +- `publish-npm workflow`: Dedicated npm registry publication workflow at `.github/workflows/publish-npm.yml` that is called through `workflow_call` by `release-sce.yml` after release creation (and also triggers from a published GitHub release or manual dispatch), gates publication on the GitHub-release (B2, `github-release-canary` job, before `npm publish`) and registry (`registry-canary` job, after publish) release-security canaries, which follow the B1 `release-canary` gate in `release-sce.yml`, validates `.version`/`npm/package.json`/release-tag parity, downloads the canonical `sce-v-npm.tgz` GitHub release asset, verifies the tarball's embedded package metadata, and publishes that already-versioned package to npm. - `sce release authority contract`: Approved release topology where repo-root `.version` is the canonical checked-in release version source, GitHub Releases are the canonical publication surface for signed release artifacts, and Cargo/npm registry publication are separate downstream publish stages that consume already-versioned checked-in package metadata without workflow-side version bumping. - `sce release-npm-package app`: Root-flake app exposed as `nix run .#release-npm-package`; stages the checked-in npm package without rewriting its version (the requested version must already match `.version`, `cli/Cargo.toml`, and `npm/package.json`), runs `npm pack`, and emits `sce-v-npm.tgz` plus `sce-v-npm.json` for release publication only after the produced tarball passes `npm/test/pack-smoke.sh --tarball` (executable `package/bin/sce.js` plus a smoke install). - `sce release-flatpak-package app`: Linux-only root-flake app exposed as `nix run .#release-flatpak-package -- --version --out-dir `; runs the Nix-built `flatpak-version-parity-check` script (parity across `.version`, `cli/Cargo.toml`, `npm/package.json`, and Flatpak AppStream release metadata), requires a resolvable git release commit, stages `packaging/flatpak/` manifest/support files without mutating checked-in sources, uses the Nix manifest expression's commit-pinned flavor to emit the staged manifest, and produces deterministic Flatpak source-manifest tarball/checksum/JSON metadata. diff --git a/context/plans/npm-vm-scenarios-and-release-canary.md b/context/plans/npm-vm-scenarios-and-release-canary.md new file mode 100644 index 000000000..db804e31c --- /dev/null +++ b/context/plans/npm-vm-scenarios-and-release-canary.md @@ -0,0 +1,444 @@ +# Plan: npm-vm-scenarios-and-release-canary + +## Change summary + +Extends PR #345 (branch `npm-nixos-integration-test`, predecessor plan `npm-nixos-vm-integration-test`, all tasks done) in two separable parts. + +First, the local-only NixOS VM test `nix/tests/npm-nixos-vm.nix` gains four scenario subtests on the same VM, each with its own isolated HOME/prefix/fixture directory: skipped-postinstall recovery through `npm rebuild`, a failed reinstall (bad manifest signature, then archive checksum mismatch) that must leave the previously installed binary byte-identical and executable with no abandoned staging files, real `sce setup --bootstrap-context` operation in a throwaway Git repository, and minimal-runtime execution under a restricted PATH with no tar/curl/gzip/Bash and no FHS loader. To support this without a second HTTPS fixture, the release-fixture setup now inlined in `npm/test/pack-smoke.sh` (throwaway signing key swap, canonical archive, signed manifest, loopback TLS, `fixture-release-server.mjs` start/stop, isolated install and layout assertions) is factored into one reusable harness that pack-smoke, the VM, and the new canary all consume. The VM fixture keeps substituting a throwaway public key and is documented as hermetic development coverage, not proof of the exact release artifact. + +Second, a release-security canary installs the exact, unmodified `sce-v-npm.tgz` with its bundled production public key against the production-signed manifest and real native archive, asserts the bundled key equals the checked-in repository key, and checks launcher/symlink/binary modes and the reported version. It runs at three gates: in `release-sce.yml` against the assembled artifacts served over loopback HTTPS before the GitHub Release is created (B1, in a job that receives no secrets); in `publish-npm.yml` against the published GitHub Release over the normal download URL with real TLS and no installer overrides before `npm publish` (B2); and after publication against the exact registry version, comparing registry bytes to the GitHub asset (registry canary). `publish-npm.yml` becomes a reusable `workflow_call` invoked by `release-sce.yml` after release creation, keeping `workflow_dispatch` and `release: published` for manual reruns and externally created releases, with `NPM_TOKEN` scoped to the publish step only. The npm installer (`npm/lib`, `npm/bin`) is not changed. + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. + +- [x] AC1: One release-fixture harness serves every hermetic and canary install: pack-smoke fixture and `--binary` modes behave as before, `fixture-release-server.mjs` is the only HTTPS server implementation, and no other file generates its own TLS server or duplicates the sign/serve/install steps. + - Validate: `nix build .#checks.x86_64-linux.npm-pack-smoke --print-build-logs` passes; `nix build .#sce-release && nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh --binary ./result/bin/sce` passes; `nix shell nixpkgs#ripgrep -c rg -n 'createServer|openssl req' npm nix .github` hits only the shared harness and `fixture-release-server.mjs`. +- [x] AC2: In the VM, a package installed with `--ignore-scripts` has no `runtime/sce`, its launcher exits non-zero with the missing-binary message, and `npm rebuild @crocoder-dev/sce` with the fixture server available runs postinstall to install a mode-`755` `runtime/sce` that reports `.version`; a second rebuild leaves a working, executable install. + - Validate: `nix build .#npm-nixos-vm --print-build-logs` passes and the log shows the `skipped postinstall and npm rebuild recovery` subtest with the asserted message, mode, and version for both rebuilds. +- [x] AC3: In the VM, after a successful install, a reinstall attempt against an invalid manifest signature and then against an archive checksum mismatch each fails, leaves `runtime/sce` with the recorded SHA-256 and mode `755` and still executable, and leaves no `sce-npm-install-*`/`sce-npm-text-*` temp directories or temp siblings of `runtime/sce`; restoring the valid fixture makes the next reinstall succeed. + - Validate: `nix build .#npm-nixos-vm --print-build-logs` log shows the `failed reinstall preserves existing binary` subtest with both failure messages, unchanged hashes, empty staging checks, and the final successful reinstall. +- [x] AC4: In the VM, as the non-root user, from a working directory outside any SCE checkout, `sce setup --bootstrap-context` in a fresh Git repository creates the `context/` baseline (directories `plans`, `handovers`, `decisions`, `tmp`; files `overview.md`, `architecture.md`, `patterns.md`, `glossary.md`, `context-map.md`, `tmp/.gitignore`); a second run succeeds, creates nothing new, and preserves a sentinel appended to an existing context file. + - Validate: `nix build .#npm-nixos-vm --print-build-logs` log shows the `real sce setup --bootstrap-context` subtest with the created paths and the preserved sentinel. +- [x] AC5: In the VM, the installed launcher (`--help`, `version --format json`) runs under `env -i` with a PATH containing only a directory that links `node`, and `runtime/sce` runs directly under `env -i` with an empty PATH; neither PATH contains tar, curl, gzip, or bash, the binary has no ELF interpreter, and no FHS loader exists at `/lib64/ld-linux-x86-64.so.2`. The Git-dependent scenario uses its own separate PATH, and no subtest falls back to the system PATH. + - Validate: `nix build .#npm-nixos-vm --print-build-logs` log shows the `minimal runtime dependencies` subtest assertions; inspect `nix/tests/npm-nixos-vm.nix` for the restricted environments and the absence of any fallback to the full PATH. +- [x] AC6: The VM test stays local-only, non-root, and loopback-only, and the installer is unchanged: `npm-nixos-vm` is in neither `checks.` nor `ci-checks`, every scenario runs as the non-root user with a per-scenario isolated directory, and `npm/lib` and `npm/bin` are unchanged by this plan. + - Validate: `nix eval .#checks.x86_64-linux --apply builtins.attrNames` does not list `npm-nixos-vm`; `nix build .#ci-checks --print-build-logs && ls result/` shows only `sce-release` and `release-portability-audit`; `git diff a4611a4b -- npm/lib npm/bin` is empty; inspect the VM node for `useDHCP = false`, `interfaces = { }`, `vlans = [ ]`. +- [x] AC7: The release-security canary installs the exact, unmodified tarball and the production key: its SHA-256 is identical before and after the run, the packed `package/lib/release-manifest-public-key.pem` is byte-identical to the checked-in `npm/lib/release-manifest-public-key.pem`, the production-signed manifest verifies against that key, and the installed launcher, `.bin/sce` symlink target, and `runtime/sce` modes and the reported version are asserted. Local-directory, GitHub-release, and registry modes each pass against the already published `v0.4.0-pre-alpha-v12` artifacts, and the GitHub/registry modes set none of `SCE_NPM_RELEASE_BASE_URL`, `SCE_NPM_SKIP_DOWNLOAD`, or `NODE_EXTRA_CA_CERTS`. + - Validate: run the three canary modes against `v0.4.0-pre-alpha-v12` (commands recorded in T07) and capture exit 0 with the printed hashes, key comparison, modes, and version; `nix shell nixpkgs#ripgrep -c rg -n 'SCE_NPM_RELEASE_BASE_URL|SCE_NPM_SKIP_DOWNLOAD|NODE_EXTRA_CA_CERTS' npm/test/release-canary.sh` shows them only in the local-directory mode path. +- [x] AC8: `release-sce.yml` cannot create a GitHub Release unless the B1 canary passed on the exact assembled artifacts, and the signing private key is referenced only in the manifest-signing step of a job distinct from the canary job. + - Validate: inspect `.github/workflows/release-sce.yml`: the release-creation job `needs` the canary job and gates on its success; `SCE_RELEASE_MANIFEST_SIGNING_KEY` appears only in one step's `env`; the canary job has no `secrets.` reference; `nix build .#checks.x86_64-linux.workflow-actionlint` passes. +- [x] AC9: npm publication runs from `release-sce.yml` through `workflow_call` only after release creation succeeds, the GitHub-release canary must pass before `npm publish`, the registry canary runs after publication against the exact version with bounded retries and fails the workflow on mismatch or unusable install, `NPM_TOKEN` is passed explicitly (not `secrets: inherit`) and exposed only to the publish step, no canary job references a secret, and `workflow_dispatch` with a release tag and `release: published` still start the same pipeline. + - Validate: inspect `.github/workflows/publish-npm.yml` and `.github/workflows/release-sce.yml` for those job dependencies, triggers, secret references, and the retry bound; `nix build .#checks.x86_64-linux.workflow-actionlint` passes. Live execution of B2 and the registry canary inside GitHub Actions is not provable before a real release and must be reported as such. +- [x] AC10: Each new check fails when its critical assertion is deliberately violated: recovery subtest (rebuild skipped), failed-reinstall subtest (binary replaced or temp file left behind), setup subtest (sentinel overwritten), minimal-runtime subtest (tar added to the restricted PATH, or a binary with an ELF interpreter), and the canary (tarball byte change, swapped public key, wrong expected version). + - Validate: plan task evidence records each injected fault, made only in scratch copies, with the exact failing subtest or `release-canary:` message; `git status` is clean of fault residue. +- [x] AC11: Existing JavaScript installer tests for malformed manifests, invalid archive entries, and fault permutations are retained and pass, and the VM does not re-implement those permutations. + - Validate: `nix build .#checks.x86_64-linux.npm-bun-tests --print-build-logs` passes; `git diff a4611a4b --stat -- npm/test/*.test.js` shows no removals. +- [x] AC12: Durable docs state that the hermetic VM/pack-smoke fixtures substitute a throwaway key and repack, while only the release-security canary proves the exact artifact and production key; they describe each VM scenario, the three canary gates, the `workflow_call` trigger topology and secret scoping, manual reruns, and what still needs a real release to validate. + - Validate: inspect `context/sce/cli-npm-distribution-contract.md`, `context/sce/cli-release-artifact-contract.md`, and `context/sce/cli-first-install-channels-contract.md` for those points with command and job names matching the workflows and flake. + +### Full validation + +Repository-wide checks `/validate` runs after the last task, regardless of +which criterion they map to. + +- `nix flake check` +- `nix build .#npm-nixos-vm --print-build-logs` +- `nix build .#ci-checks --print-build-logs` +- `nix build .#checks.x86_64-linux.npm-bun-tests --print-build-logs` +- `nix build .#checks.x86_64-linux.workflow-actionlint` + +### Context sync + +- `context/sce/cli-npm-distribution-contract.md` (shared fixture harness, VM scenarios, fixture-vs-exact-artifact distinction, release-security canary, registry publication topology, verification baseline) +- `context/sce/cli-release-artifact-contract.md` (B1 canary gate and job split in the release orchestrator) +- `context/sce/cli-first-install-channels-contract.md` (release-to-npm workflow topology) +- `context/context-map.md` (annotations for the entries above, if their summaries change) + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `npm/test/pack-smoke.sh`, a new shared harness under `npm/test/`, `npm/test/fixture-release-server.mjs` only if the harness strictly needs it, new `npm/test/release-canary.sh`, `nix/tests/npm-nixos-vm.nix`, `flake.nix` (VM test inputs and file sets only), `.github/workflows/release-sce.yml`, `.github/workflows/publish-npm.yml`, the context files listed under Context sync. +- **Out of scope:** `npm/lib/*.js`, `npm/bin/sce.js`, the production public key, release packaging apps (`release-npm-package`, `release-manifest`, `release-artifacts`), `pr-ci.yml`, native build workflows, aarch64 or darwin VM tests, putting the VM into `nix flake check` or `ci-checks`, crates.io/Flatpak publishing. +- **Constraints:** commit on `npm-nixos-integration-test` (the current `more-integration` checkout is at the same commit, `a4611a4b`); VM test tasks (T01–T06) and release-security workflow tasks (T07–T10) land as separate commits; VM scenarios use the real installed package and real filesystem operations, never mocked installer functions, with no external network; production signing secrets never enter Nix derivations, fixtures, logs, or artifacts; B2 and registry canaries set none of `SCE_NPM_RELEASE_BASE_URL`, `SCE_NPM_SKIP_DOWNLOAD`, `NODE_EXTRA_CA_CERTS`; registry canaries never resolve a dist-tag; all registry retries are bounded; workflow actions stay SHA-pinned with `harden-runner` as in the existing jobs; shell scripts follow `#!/usr/bin/env bash` + `set -euo pipefail` and are shellcheck-clean. +- **Non-goal:** repeating the unit-level malformed-manifest/archive permutations in the VM, a generic VM or HTTPS test framework, changing installer behavior or messages, or claiming live gate success before a real release runs. + +## Assumptions + +- PR #345 is the open PR whose head is `npm-nixos-integration-test` (confirmed with `gh pr view 345`). This is a new plan because every task in `npm-nixos-vm-integration-test` is done and this scope is distinct. Its stale validation failure (AC5 expected KVM in CI, which T05 superseded) is not repaired here. +- The harness is a sourced Bash library plus a small `prepare`/`serve` entrypoint. `prepare` writes a fixture directory (staged package with the throwaway key, packed tgz, release dir, TLS cert and key). `serve` starts `fixture-release-server.mjs` against that directory. The server reads files on each request, so VM scenarios can tamper with `release/` in place and restore from saved copies. +- Reinstall attempts in the VM use `npm rebuild @crocoder-dev/sce --prefix --foreground-scripts`, which reruns postinstall in place. A failed `npm install` of a tarball may let npm roll back or remove the package directory, which would hide whether the installer preserved the old binary. +- The missing-binary assertion matches the launcher's current text, `The native sce binary is not installed.`. The launcher is not edited (see Open questions). +- "Expected repository key" means byte equality with `npm/lib/release-manifest-public-key.pem` at the checked-out release ref, plus an independent `openssl dgst -verify` of the production-signed manifest with that key. +- The canary's local-directory mode (B1) serves the real `dist/cli` artifacts over loopback HTTPS with the shared harness's ephemeral self-signed certificate. It therefore needs `SCE_NPM_RELEASE_BASE_URL` and `NODE_EXTRA_CA_CERTS`, which the request permits only for B1. +- `release-sce.yml` splits its current `release` job into three jobs. `assemble` is the only job holding the signing secret, and only in its signing step; it uploads `dist/` as a workflow artifact. `release-canary` receives no secrets. `release` needs `release-canary`, re-checks the canary-recorded tarball SHA-256, and creates the GitHub Release. +- The registry stores the published tarball verbatim, so `dist.integrity` (sha512) and the bytes from `npm pack @crocoder-dev/sce@` must equal the GitHub Release asset. A mismatch fails the workflow. +- For reruns, the publish step checks the registry first. When the exact version already exists with matching integrity, it skips `npm publish` and proceeds to the registry canary. When it exists with different integrity, the workflow fails. +- `release: published` stays as a trigger. Releases created with `GITHUB_TOKEN` do not fire it, so it starts publication only for externally created releases, and the existing per-tag concurrency group serializes any overlap. + +## Task stack + +- [x] T01: `Factor release-fixture setup out of pack-smoke.sh into a shared harness` (status:done) + - Task ID: T01 + - Scope: In — new shared harness under `npm/test/` (TLS cert generation, `fixture-release-server.mjs` start/wait/stop, throwaway-key staging, canonical archive + signed manifest build, isolated npm install, layout/mode/version assertions, `prepare`/`serve` entrypoint); `npm/test/pack-smoke.sh` rewritten as a consumer with unchanged CLI flags and `pack-smoke:` messages; `flake.nix` file sets if the new file needs inclusion. Out — new VM scenarios, canary script, installer code, server behavior changes. + - Dependencies: none + - Done when: pack-smoke fixture and `--binary` modes produce the same assertions and messages as before; the existing VM test still passes; the harness's `prepare` output can be served and installed by a separate process; shellcheck clean. + - Verify: `nix build .#checks.x86_64-linux.npm-pack-smoke --print-build-logs`; host `--binary` smoke with `./result/bin/sce`; `nix build .#npm-nixos-vm --print-build-logs`; `nix shell nixpkgs#shellcheck -c shellcheck npm/test/*.sh`; re-run predecessor faults (non-executable launcher, garbage `--binary`) from a scratch cwd and confirm identical messages. + - Completed: 2026-11-04 + - Files changed: `npm/test/release-fixture.sh` (new, Git mode 100755, added intent-to-add so the flake sees it), `npm/test/pack-smoke.sh`, `flake.nix` (`npmSrc` file set gains `./npm/test/release-fixture.sh`). + - Result: `npm/test/release-fixture.sh` is a sourced Bash library (`fixture_require_tools`, `fixture_assert_launcher_mode`, `fixture_stage_package`, `fixture_resolve_platform`, `fixture_generate_keys`, `fixture_build_release`, `fixture_pack`, `fixture_serve_start`/`fixture_serve_stop`, `fixture_npm_install`, `fixture_assert_install_layout`; messages use the caller-set `FIXTURE_LOG_PREFIX`) and a CLI entrypoint. `prepare [--package-dir|--tarball] [--binary]` writes `stage/`, `pack/`, `release/`, `keys/{signing,tls-cert,tls-key}.pem`, and a shell-sourceable `fixture.env` (`FIXTURE_VERSION`, `FIXTURE_TARGET_TRIPLE`, `FIXTURE_TARBALL`, `FIXTURE_RELEASE_DIR`, `FIXTURE_TLS_CERT`, `FIXTURE_TLS_KEY`), and refuses to run on an already prepared dir. `serve [--port-file f]` execs `fixture-release-server.mjs` in the foreground (default port file `/server-port`). `pack-smoke.sh` sources the harness with prefix `pack-smoke`, and its flags, usage text (plus one note that the fixture uses a throwaway key), step order, and messages are unchanged. `fixture-release-server.mjs` is unchanged. Deviation from the gate: `prepare` takes no `--expected-version`, because version assertions stay with the consumer. + - Verify outcomes: + - `nix build .#checks.x86_64-linux.npm-pack-smoke --print-build-logs`: passed (`pack-smoke: OK (sce --help, sce version)`). + - Host `--binary` smoke with a Nix-built `sce-release` (`nix build .#sce-release` into a scratch out-link, then `nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh --binary /bin/sce`): passed (`real binary reported version 0.4.0-pre-alpha-v12`, `OK (real binary: ...)`). + - `nix build .#npm-nixos-vm --print-build-logs`: passed on this host under TCG (no `/dev/kvm`). Every existing subtest finished, and the log shows the unchanged `pack-smoke:` lines and `parsed version: 0.4.0-pre-alpha-v12`. + - `nix shell nixpkgs#shellcheck -c shellcheck npm/test/*.sh` and with `-x`: passed, no findings. + - Predecessor faults from a scratch cwd, with output diffed against the pre-refactor script after normalizing temp paths: identical. Non-executable launcher: `pack-smoke: Packed package/bin/sce.js has mode -rw-r--r--; expected an executable mode ...`, exit 1. Garbage `--binary`: `pack-smoke: sce --help exited non-zero.`, exit 1. Clean fixture run: identical, exit 0. + - Separate-process reuse: `release-fixture.sh prepare `, then `release-fixture.sh serve --port-file f` in a background process, then sourcing `fixture.env` and running `npm install --offline` of `$FIXTURE_TARBALL` against it from another process. Passed: `sce fixture help`, `sce 0.4.0-pre-alpha-v12`, `runtime/sce` mode `755`. Re-running `prepare` on the same dir fails with `Fixture directory ... is already prepared.` + - Context impact: local to npm test tooling. The harness is a new reusable test surface. Durable docs for it are T06's scope (`context/sce/cli-npm-distribution-contract.md`), and no root context file changes. + - Context synchronization: synced + +- [x] T02: `Add VM subtest for skipped postinstall and npm rebuild recovery` (status:done) + - Task ID: T02 + - Scope: In — `nix/tests/npm-nixos-vm.nix`: per-scenario directory, harness `prepare` + background `serve` as the test user, `npm install --ignore-scripts` of the packed tgz, assert absent `runtime/sce` and the launcher's non-zero exit with the missing-binary message, `npm rebuild @crocoder-dev/sce` with fixture env, assert mode `755`, parsed version, then a second rebuild and re-assert. Out — failure-injection scenarios, setup scenario, restricted PATH. + - Dependencies: T01 + - Done when: the subtest passes in the VM and its log shows each assertion. + - Verify: `nix build .#npm-nixos-vm --print-build-logs`; fault in a scratch copy: skip the rebuild step and record the failing assertion. + - Completed: 2026-11-04 + - Files changed: `nix/tests/npm-nixos-vm.nix`. + - Result: New let bindings for the scenario paths under `/home/sce-tester/scenarios/rebuild` and the missing-binary message, plus an `asScenarioUser` wrapper over `asUser` that isolates HOME, TMPDIR, npm cache, and npm userconfig per scenario. New subtest `skipped postinstall and npm rebuild recovery` runs as the test user. It runs `release-fixture.sh prepare` with `--package-dir ~/npm --binary /bin/sce` and starts `release-fixture.sh serve` detached through `setsid`, recording its PID and waiting for the port file. Next, `npm install --offline --ignore-scripts` of `$FIXTURE_TARBALL`. It asserts `runtime/sce` is absent and the launcher exits non-zero with `The native sce binary is not installed.`. Two passes of the `rebuild_and_assert` helper follow, each running `npm rebuild @crocoder-dev/sce --prefix … --offline --foreground-scripts` with `SCE_NPM_RELEASE_BASE_URL`/`NODE_EXTRA_CA_CERTS`, then asserting `runtime/sce` mode `755`, `--help`, and the parsed `version --format json` `.version`. The server is killed in a `finally`. Deviation: the shell snippets avoid an explicit `exit`, because in the `su -` login shell it sources `/etc/bash_logout`, which fails under `set -u` (`__ETC_BASHLOGOUT_SOURCED: unbound variable`) and turned `exit 0` into exit 1 on the first run. + - Verify outcomes: + - `nix build .#npm-nixos-vm --print-build-logs`: passed under TCG (no `/dev/kvm`). Every earlier subtest is unchanged. The new subtest log shows `ignore-scripts install: runtime/sce is absent` and ``launcher without binary exited 1: The native sce binary is not installed. Try reinstalling the package or run `npm rebuild sce` to retry the download.``. For each rebuild it shows postinstall `node ./lib/install.js` running and `rebuilt dependencies successfully`, then `first rebuild: runtime/sce mode: 755` and `first rebuild: parsed version: 0.4.0-pre-alpha-v12`. The same lines appear for `second rebuild`. + - Fault (scratch `git worktree` at HEAD plus the new test file, with `npm rebuild` replaced by `true`): build failed with `Test "skipped postinstall and npm rebuild recovery" failed with error: "command \`stat -c %a /home/sce-tester/scenarios/rebuild/prefix/node_modules/@crocoder-dev/sce/runtime/sce\` failed (exit code 1)"` (`stat: cannot statx … No such file or directory`). The worktree was then removed and the main worktree has no fault residue. + - `nix shell nixpkgs#nixfmt -c nixfmt --check nix/tests/npm-nixos-vm.nix`: passed. + - Context impact: local to the npm VM test. The new subtest is documented as part of T06 (`context/sce/cli-npm-distribution-contract.md`), and no root context file changes. + - Context synchronization: synced + +- [x] T03: `Add VM subtest proving a failed reinstall preserves the existing binary` (status:done) + - Task ID: T03 + - Scope: In — `nix/tests/npm-nixos-vm.nix`: successful install, record `sha256sum` and mode of `runtime/sce`; replace the manifest `.sig` with an invalid signature, `npm rebuild` must fail with the authenticity error; restore it, then corrupt the served archive so its checksum mismatches, `npm rebuild` must fail with the checksum error; after each failure assert unchanged hash, mode `755`, successful execution, and no `sce-npm-install-*`/`sce-npm-text-*` directories in the user's TMPDIR or temp siblings in `runtime/`; restore the valid fixture and assert a successful reinstall. Out — the JS unit-test permutations (malformed JSON, identity fields, archive entry types). + - Dependencies: T02 + - Done when: the subtest passes and its log shows both failure messages and the unchanged-binary checks. + - Verify: `nix build .#npm-nixos-vm --print-build-logs`; faults in a scratch copy: (a) overwrite `runtime/sce` between attempts, (b) leave a temp file beside `runtime/sce`; record each failing assertion. + - Completed: 2026-11-04 + - Files changed: `nix/tests/npm-nixos-vm.nix`. + - Result: The per-scenario paths are now built by one `mkScenario name` attrset (`dir`, `prefixDir`, `fixtureDir`, `tmpDir`, `portFile`, `pidFile`, `serverLog`, `installedDir`, `launcher`). The T02 fixture shell steps moved into Nix helpers `prepareScenarioFixture`, `startScenarioServer`, `stopScenarioServer`, and `rebuildScenarioPackage`. The rebuild subtest uses those helpers and behaves as before. New subtest `failed reinstall preserves existing binary` runs under `~/scenarios/reinstall`: + - It prepares and serves its own fixture, saves pristine copies of `release/` under `saved-release/`, and runs `npm install --foreground-scripts` with postinstall against the fixture server. It records the `runtime/sce` SHA-256. + - It replaces the manifest `.sig` with a well-formed signature, made with the fixture key, over different bytes. `npm rebuild` must then exit non-zero with `Release manifest authenticity check failed`. + - It restores the `.sig` and appends bytes to the served archive. `npm rebuild` must then exit non-zero with `Downloaded sce archive checksum mismatch`. + - It restores the archive and requires a successful `npm rebuild`. + - `assert_binary_preserved` runs after the initial install, after each failure, and after the restored reinstall. It asserts the recorded SHA-256, mode `755`, `--help`, and the parsed `version --format json` `.version`. It also asserts that the scenario TMPDIR has no `sce-npm-install-*`/`sce-npm-text-*` entries and that `runtime/` holds only `sce`. + - Installer code and the fixture server are unchanged. + - Verify outcomes: + - `nix build .#npm-nixos-vm --print-build-logs`: passed under TCG (no `/dev/kvm`). Every earlier subtest is unchanged, and the rebuild subtest still logs `first rebuild`/`second rebuild` mode `755` and version `0.4.0-pre-alpha-v12`. The new subtest logs the following: + - `Failed to install sce via npm: Release manifest authenticity check failed: signature verification did not succeed.` and `invalid manifest signature: npm rebuild exited 1`. + - `Failed to install sce via npm: Downloaded sce archive checksum mismatch for sce-v0.4.0-pre-alpha-v12-x86_64-unknown-linux-musl.tar.gz: expected 0d1aecdc…, received f262e7f9…` and `archive checksum mismatch: npm rebuild exited 1`. + - For `initial install`, `after invalid manifest signature`, `after archive checksum mismatch`, and `restored fixture reinstall`: sha256 `ba3565750ad5030412d8ca238ffb555367ecd6a9ae91f004399c444a6cdff484`, mode `755`, version `0.4.0-pre-alpha-v12`, `installer temp dirs in TMPDIR: none`, and `runtime/ entries: ['sce']`. + - Fault (a), in a scratch `git worktree` at HEAD plus the new test file, appended bytes to `runtime/sce` after the signature failure. The build failed with `Test "failed reinstall preserves existing binary" failed with error: "after invalid manifest signature: runtime/sce sha256 d86b3109…, expected ba356575…"`. + - Fault (b), in a separate scratch worktree, touched `runtime/.sce.4242.1.tmp` after the checksum failure. The build failed with `Test "failed reinstall preserves existing binary" failed with error: "after archive checksum mismatch: runtime/ holds ['sce', '.sce.4242.1.tmp'], expected only sce"`. + - Rerun with KVM after `/dev/kvm` became available: `nix build .#npm-nixos-vm --rebuild --print-build-logs` passed, and the guest logged `Hypervisor detected: KVM`. Every subtest passed, and the test script finished in 40.81s, against 49.79s under TCG. + - Both scratch worktrees were removed (`git worktree list` shows none), and the main worktree has no fault residue. + - `nix shell nixpkgs#nixfmt -c nixfmt --check nix/tests/npm-nixos-vm.nix`: passed. + - Context impact: local to the npm VM test. The new subtest and the shared scenario helpers are documented as part of T06 (`context/sce/cli-npm-distribution-contract.md`), and no root context file changes. + - Context synchronization: synced + +- [x] T04: `Add VM subtest running sce setup --bootstrap-context in an isolated Git repository` (status:done) + - Task ID: T04 + - Scope: In — `nix/tests/npm-nixos-vm.nix`: add `git` to the node; as the test user with a dedicated Git-enabled PATH (node, git, coreutils) and isolated HOME/XDG dirs, `git init` a temp repo, run the installed `sce setup --bootstrap-context` from that repo and once with `--repo ` from an unrelated cwd, assert the baseline directories and files, append a sentinel to `context/overview.md`, rerun, and assert success, an identical file list, and the preserved sentinel. Out — target installs (`--claude` etc.), hooks, auth. + - Dependencies: T03 + - Done when: the subtest passes, and no SCE source path appears in the cwd or the repo. + - Verify: `nix build .#npm-nixos-vm --print-build-logs`; fault in a scratch copy: overwrite the sentinel before the second run and record the failing assertion. + - Completed: 2026-11-04 + - Files changed: `nix/tests/npm-nixos-vm.nix`. + - Result: New `setupScenario = mkScenario "setup"` plus let bindings for the repo dir, a nested cwd (`repo/nested/cwd`), the sentinel, the expected baseline dirs/files, and `setupToolsPath = lib.makeBinPath [ nodejs git coreutils ]`. New helpers: + - `installScenarioPackage`: npm install with postinstall against the scenario fixture server. The reinstall subtest now uses it in place of its inline copy, with the same behavior. + - `asSetupUser`: wraps `asScenarioUser` and exports `PATH` to only `setupToolsPath`, isolated `XDG_CONFIG_HOME`/`XDG_STATE_HOME`/`XDG_DATA_HOME`/`XDG_CACHE_HOME` under the scenario HOME, and `GIT_CONFIG_NOSYSTEM=1`. + - The new subtest `real sce setup --bootstrap-context` prepares and serves its own fixture, installs the package, and stops the server. Under the setup PATH it asserts that `tar`/`curl`/`gzip`/`bash`/`jq` do not resolve. It then runs `git init` on `~/scenarios/setup/repo` and adds a placeholder `origin` remote, because setup requires the configured repository remote. + - First run: `sce setup --bootstrap-context` from the repo root. It asserts the four dirs and six files, an exact sorted `context/` tree, and the `tmp/.gitignore` contents. + - It appends the sentinel to `context/overview.md`. Second run: from the nested cwd, it asserts the same tree, a byte-identical `overview.md`, and the preserved sentinel. + - Finally it asserts that the nested cwd resolves to the repo, the repo holds only `.git`, `context`, and `nested`, no ancestor of the repo is a Git checkout or holds `cli/Cargo.toml`, and no bootstrapped file mentions `~/npm` or `/nix/store`. + - Deviation: the planned `--repo ` run was replaced by a run from a nested subdirectory of the repo. The CLI rejects `--bootstrap-context` combined with `--repo` (`Option '--bootstrap-context' must be used alone`, `cli/src/services/setup/mod.rs`), and the CLI is out of scope. Per the user's mid-task instruction, the new code carries no explanatory comments. `git` was not added to `environment.systemPackages`: the scenario reaches it only through its dedicated PATH. + - Verify outcomes: + - `nix build .#npm-nixos-vm --print-build-logs`: passed. Every earlier subtest still passes. The new subtest logs `first run cwd: …/scenarios/setup/repo`, `Context baseline ensured.`, `first run: context tree: ['context/architecture.md', 'context/context-map.md', 'context/decisions', 'context/glossary.md', 'context/handovers', 'context/overview.md', 'context/patterns.md', 'context/plans', 'context/tmp', 'context/tmp/.gitignore']`, then `second run cwd: …/repo/nested/cwd`, `Context baseline ensured.`, `second run: context tree unchanged`, `second run: sentinel preserved: 'sce-tester sentinel: keep this line'`, `repo entries: ['context', '.git', 'nested']`, and `no SCE source path in the cwd ancestry or the bootstrapped context`. + - Fault (a scratch `git worktree` at HEAD plus the new test file, overwriting `context/overview.md` with `# Overview\n` before the second run): the build failed with `Test "real sce setup --bootstrap-context" failed with error: "sentinel lost from context/overview.md: '# Overview\n'"`. The worktree was removed, and the main worktree has no fault residue. + - `nix shell nixpkgs#nixfmt -c nixfmt --check nix/tests/npm-nixos-vm.nix`: passed. + - Context impact: local to the npm VM test. The new subtest is documented in T06 (`context/sce/cli-npm-distribution-contract.md`), and no root context file changes. + - Context synchronization: synced + +- [x] T05: `Add VM subtest for minimal runtime dependencies` (status:done) + - Task ID: T05 + - Scope: In — `nix/tests/npm-nixos-vm.nix`: a restricted bin dir containing only `node`; run `.bin/sce --help` and `version --format json` under `env -i PATH= HOME=…`; run `runtime/sce --help` and `version --format json` under `env -i PATH=/var/empty`; parse JSON in the Python test script; assert that `tar`, `curl`, `gzip`, and `bash` do not resolve on the restricted PATH, that `runtime/sce` has no ELF `INTERP` header (via `binutils` `readelf -l`, used only by the test driver), and that `/lib64/ld-linux-x86-64.so.2` does not exist. Out — the Git scenario's environment (T04). + - Dependencies: T04 + - Done when: the subtest passes without any fallback to the system PATH. + - Verify: `nix build .#npm-nixos-vm --print-build-logs`; faults in a scratch copy: (a) add `tar` to the restricted dir, (b) feed a dynamically linked binary with an interpreter as `--binary`; record each failing assertion. + - Completed: 2026-11-04 + - Files changed: `nix/tests/npm-nixos-vm.nix`. + - Result: New `minimalScenario = mkScenario "minimal"` plus let bindings: `minimalNodeBinDir`, `minimalHomeDir`, `absentRuntimeTools` (`tar curl gzip bash`), `fhsLoader`, `readelf` (`binutils-unwrapped`, used only by the test driver), `minimalRuntimeBinary`, and the `env -i` prefixes `minimalLauncherEnv` (`PATH=`) and `minimalBinaryEnv` (`PATH=/var/empty`). The new `assertToolsAbsent label path` helper fails if any of those tools resolves on the given PATH. + - The VM node now sets `environment.stub-ld.enable = false`. The locked nixpkgs installs a stub loader at `/lib64/ld-linux-x86-64.so.2` by default, so without this the absence assertion can never hold. Earlier subtests are unaffected. + - New subtest `minimal runtime dependencies`: + - It prepares, serves, installs, and stops through the shared scenario helpers. + - It builds `node-bin/` with only a `node` symlink and asserts the listing is exactly `['node']`. + - It asserts that the four tools resolve on neither the launcher PATH nor `/var/empty`. + - Static checks: `readelf -h` confirms an ELF, `readelf -lW` shows no `INTERP`/`program interpreter`, and `/lib64/ld-linux-x86-64.so.2` is absent. + - It runs `.bin/sce --help` and `version --format json` under `env -i PATH= HOME=…`, then `runtime/sce --help` and `version --format json` under `env -i PATH=/var/empty HOME=…`, with absolute `env` and binary paths. Each JSON is parsed with `json.loads`, and `.version` is asserted. Nothing falls back to the system PATH. + - Verify outcomes: + - `nix build .#npm-nixos-vm --print-build-logs`: passed with KVM (`Hypervisor detected: KVM`). Every earlier subtest passed. The new subtest logs: + - `launcher PATH entries: ['node']` + - `launcher PATH: tar curl gzip bash do not resolve on PATH=…/minimal/node-bin` and `binary PATH: tar curl gzip bash do not resolve on PATH=/var/empty` + - `runtime/sce: no ELF INTERP program header`, `no FHS loader at /lib64/ld-linux-x86-64.so.2` + - `launcher: --help under env -i with only node on PATH`, `launcher: parsed version: 0.4.0-pre-alpha-v12` + - `runtime/sce: --help under env -i with PATH=/var/empty`, `runtime/sce: parsed version: 0.4.0-pre-alpha-v12` + - The subtest finished in 8.16s. + - Fault (a), a scratch worktree that symlinks `tar` into `node-bin/`, failed with `Test "minimal runtime dependencies" failed with error: "launcher PATH holds ['node', 'tar'], expected only node"`. + - Fault (a2), the same worktree change with the listing assertion removed to exercise the tool check, failed with `launcher PATH: tar resolves on PATH=/home/sce-tester/scenarios/minimal/node-bin` and a `Test "minimal runtime dependencies" failed` error. + - Fault (b), a scratch worktree where the minimal scenario's fixture uses `--binary ${pkgs.hello}/bin/hello`, failed with `Test "minimal runtime dependencies" failed with error: "runtime/sce requests an ELF interpreter: … [Requesting program interpreter: /nix/store/…-glibc-2.42-67/lib/ld-linux-x86-64.so.2]"`. + - All scratch worktrees were removed (`git worktree list` shows none for this repo), and the main worktree has no fault residue. + - `nix shell nixpkgs#nixfmt -c nixfmt --check nix/tests/npm-nixos-vm.nix`: passed. + - Observation outside T05's scope, unchanged: in the passing run, the T03 `archive checksum mismatch` `npm rebuild` printed `Downloaded sce archive checksum mismatch … received 0`. It then hung until the NixOS test driver's 900s `execute` timeout killed it (`npm rebuild exited 124`; guest clock 40s → 940s). The T03 assertion accepts any non-zero exit plus the message, so the subtest passed. The three fault runs completed that step normally (subtest 25–27s), and T03's own runs exited 1. The hang is intermittent. It points at a possible installer or fixture-server hang after a checksum mismatch, and at T03 accepting exit 124. Both are outside T05's scope and are left for a follow-up decision. + - Context impact: local to the npm VM test. The new subtest is documented in T06 (`context/sce/cli-npm-distribution-contract.md`), and no root context file changes. + - Context synchronization: synced + +- [x] T06: `Document the VM scenarios and the fixture-versus-exact-artifact distinction` (status:done) + - Task ID: T06 + - Scope: In — `context/sce/cli-npm-distribution-contract.md` (shared harness, each VM subtest and what it proves, explicit statement that pack-smoke and the VM repack with a throwaway key and do not prove the exact release artifact or production key, verification baseline); `context/context-map.md` annotation if needed. Out — canary and workflow documentation (T10). + - Dependencies: T05 + - Done when: the docs cover the VM half of AC12, with names matching the test file. + - Verify: inspect the contract; `grep -n 'npm-nixos-vm\|subtest' context/sce/cli-npm-distribution-contract.md`. + - Completed: 2026-11-04 + - Files changed: `context/sce/cli-npm-distribution-contract.md`, `context/context-map.md`. + - Result: The VM documentation was one oversized bullet under `Packed-package smoke test`. It now lives in a new `## NixOS VM real-install test` section, and a new `## Hermetic fixtures versus the exact release artifact` section follows it. + - The VM section covers the following: + - Local-only status: not in `checks.` or `.#ci-checks`. + - The loopback-only node (`useDHCP = false`, no interfaces, no VLANs), the non-root `sce-tester`, and `environment.stub-ld.enable = false`. + - Per-scenario isolation under `/home/sce-tester/scenarios/`, and the shared helpers (`mkScenario`, `asScenarioUser`, `prepareScenarioFixture`, `startScenarioServer`, `stopScenarioServer`, `installScenarioPackage`, `rebuildScenarioPackage`, `asSetupUser`). + - Every subtest in test-script order, under its exact name, with what it proves. + - The fixture-mode-versus-VM comparison, the linux/x64-only coverage, and how to run it locally. + - The new section has three points: + - Every `release-fixture.sh` consumer swaps in a throwaway key, re-signs, repacks, and serves over loopback HTTPS with an ephemeral certificate. That covers `npm-pack-smoke`, host pack-smoke, the `release-npm-package` gate, and all VM subtests. + - These consumers are hermetic development coverage only. They do not prove the exact published tarball bytes, the production public key, the production-signed manifest, or real GitHub Releases TLS and URLs. + - Only the planned release-security canary proves those. + - The verification baseline already listed the harness and the VM commands and is unchanged. The `context/context-map.md` annotation for the contract now names the four scenario subtests and the fixture-versus-exact-artifact statement. No content was dropped. The VM paragraph was restructured, not rewritten. + - Verify outcomes: + - Contract inspected: shared harness (existing bullet), every VM subtest, the throwaway-key/repack statement, and the verification baseline are present. + - `grep -n 'npm-nixos-vm\|subtest' context/sce/cli-npm-distribution-contract.md`: hits at lines 71, 73, 86, 90, and 101. + - Name parity: every `subtest("…")` name in `nix/tests/npm-nixos-vm.nix` (9 of 9) appears backticked in the contract. The `useDHCP`/`interfaces`/`vlans`/`stub-ld`/`sce-tester`/`scenarios/` facts were checked against the test file, and the `--tarball` repack against `npm/test/release-fixture.sh`. + - Context impact: durable context docs only (`context/sce/cli-npm-distribution-contract.md` and its `context/context-map.md` entry). No code, and no root context file content changes. + - Context synchronization: synced + +- [x] T07: `Add the release-security canary script for exact release artifacts` (status:done) + - Task ID: T07 + - Scope: In — new `npm/test/release-canary.sh` reusing the T01 harness for install/assertions. Modes: `--release-dir --tarball ` serves the real manifest, signature, and archives over loopback HTTPS (B1). `--github-release ` downloads the tgz with `gh release download` or the public URL and installs it with no installer env overrides (B2). `--registry-version ` downloads the exact `@crocoder-dev/sce@`, compares bytes and `dist.integrity` against a given GitHub tgz, and installs it, with bounded retries for registry availability. Common checks in every mode: SHA-256 unchanged before and after, bundled key byte-equal to the checked-in key, `openssl dgst -verify` of the production manifest, launcher/`.bin` symlink/`runtime/sce` modes, and reported version equal to `--expected-version`. Out — workflow wiring, any key substitution or repacking. + - Dependencies: T06 + - Done when: all three modes pass locally against the published `v0.4.0-pre-alpha-v12` artifacts; shellcheck clean. + - Verify: download the v12 assets into a scratch dir and run `--release-dir`; run `--github-release v0.4.0-pre-alpha-v12` and `--registry-version 0.4.0-pre-alpha-v12` from a scratch cwd; faults: a byte-modified tgz, a tgz with a swapped key (scratch copy), and a wrong `--expected-version`, each failing with a distinct `release-canary:` message; `nix shell nixpkgs#shellcheck -c shellcheck npm/test/release-canary.sh`. + - Completed: 2026-11-04 + - Files changed: `npm/test/release-canary.sh` (new, file mode 755, untracked), `npm/test/release-fixture.sh`. + - Result: `npm/test/release-canary.sh` sources the harness with prefix `release-canary`. It requires `--expected-version` and exactly one mode: + - `--release-dir --tarball ` (B1): serves `` in place through `fixture_serve_start` and installs with `fixture_npm_install`. This is the only path that sets `SCE_NPM_RELEASE_BASE_URL`/`NODE_EXTRA_CA_CERTS`, and it sets them inside the harness. + - `--github-release ` (B2): the tag must equal `v`. It downloads `sce-v-npm.tgz`, the manifest, and the `.sig` from the public `releases/download` URL through Node `fetch`, then runs `npm install` of the downloaded tgz. + - `--registry-version ` (optional `--tarball`, default the GitHub asset): it retries `npm view @crocoder-dev/sce@ dist.integrity` plus `npm pack` of that exact version, bounded at 10 attempts × 30s. It fails unless `dist.integrity` and the packed registry bytes both equal the sha512 of the GitHub tgz. It then runs `npm install @crocoder-dev/sce@` and asserts that the lockfile integrity equals `dist.integrity`. + - Every network, install, and `sce` invocation in the remote modes runs under `env -i` with only `PATH`, an isolated `HOME`/`TMPDIR`, and the npm cache/userconfig. No installer override can therefore leak in, and none is named in the script. + - Versions and tags must match a strict semver pattern, so a dist-tag or range is never resolved. + - Checks in every mode: + - The tarball SHA-256 is recorded before the run and must be unchanged after it. It must also equal the optional `--expected-sha256`. + - The packed `package.json` name is `@crocoder-dev/sce` and its version equals the expected version. + - The packed `package/lib/release-manifest-public-key.pem` is byte-equal (SHA-256) to the checked-in key. + - `openssl dgst -sha256 -verify` of the manifest `.sig` against the checked-in key passes, and the manifest `.version` equals the expected version. + - `fixture_assert_install_layout` passes (`runtime/sce` mode `755`, `.bin/sce` → `bin/sce.js`), and the installed `bin/sce.js` is executable. + - `--help` runs, and `version --format json` `.version` equals the expected version. + - The run ends with `release-canary: OK ( mode: …, tarball sha256 …)`. + - In the harness, `fixture_generate_tls_cert` is split out of `fixture_generate_keys`, which now calls it, so `openssl req` stays only in the harness. pack-smoke behavior is unchanged. + - Deviations: + - The packed launcher mode is logged, not enforced. The published v12 tarball packs `bin/sce.js` as `-rw-r--r--`, so enforcing it would fail every mode against v12. The installed launcher mode, which npm sets, is asserted instead. + - Downloads use the public URL, not `gh`, so no token is needed. + - `--expected-sha256` was added so that a byte-modified tarball fails with a distinct message in B1, where no published hash exists. + - Per the user's instruction, the new code carries no comments at all, including the `# shellcheck source=` directive that `pack-smoke.sh` uses. Shellcheck therefore needs `-x -P SCRIPTDIR` to follow the sourced harness. + - Verify outcomes: + - v12 assets downloaded into a scratch dir (npm tgz/json, manifest, `.sig`, x86_64 musl archive, `.sha256`, `SHA256SUMS`). `--release-dir --tarball /sce-v0.4.0-pre-alpha-v12-npm.tgz --expected-version 0.4.0-pre-alpha-v12` from a scratch cwd: exit 0. It logged: + - tarball sha256 `ec6bf434…edf57` before and after + - bundled key byte-identical (sha256 `bfc3c81e…f212`) + - manifest verified (sha256 `071b460c…e03a`) + - packed launcher `-rw-r--r--`; installed `runtime/sce` mode `755`, `bin/sce.js` mode `755`, `.bin/sce` → `…/bin/sce.js` + - version `0.4.0-pre-alpha-v12`, and `release-canary: OK (local mode: …)` + - `--github-release v0.4.0-pre-alpha-v12` and `--registry-version 0.4.0-pre-alpha-v12` from a scratch cwd, with `SCE_NPM_SKIP_DOWNLOAD=1`, `SCE_NPM_RELEASE_BASE_URL=https://127.0.0.1:9`, and `NODE_EXTRA_CA_CERTS=/nonexistent` exported by the caller: both exit 0 with the same hash, key, manifest, mode, and version lines. Registry mode also logged `registry tarball byte-identical to the GitHub tarball (sha512-Dt3odIlX…UKA==)` and the matching installed integrity. The poisoned caller env had no effect. + - Faults, all on scratch copies (removed afterwards; `git status` shows only the two task files): + - Byte-appended tgz in B1 with `--expected-sha256 ec6bf434…`: `release-canary: Tarball sha256 855605eb… does not match --expected-sha256 ec6bf434….` + - The same tgz in registry mode: `release-canary: Tarball … has no readable package/package.json.` + - A validly repacked tgz with identical contents in registry mode: `release-canary: Registry dist.integrity sha512-Dt3o… does not match the GitHub tarball integrity sha512-4z6W….` + - Swapped public key: ``release-canary: Bundled package/lib/release-manifest-public-key.pem (sha256 0d45b4ec…) is not byte-identical to the checked-in npm/lib/release-manifest-public-key.pem (sha256 bfc3c81e…).`` + - Wrong `--expected-version 0.4.0-pre-alpha-v11`: `release-canary: Packed package version '0.4.0-pre-alpha-v12' does not match --expected-version '0.4.0-pre-alpha-v11'.` + - `--registry-version latest`: `release-canary: --registry-version 'latest' is not an exact version; dist-tags and ranges are not resolved.` + - Each fault exits 1. No `sce-npm-release-canary.*` temp dirs remain. + - Shellcheck: + - `nix shell nixpkgs#shellcheck -c shellcheck -x -P SCRIPTDIR npm/test/release-canary.sh` and `... -x -P SCRIPTDIR npm/test/*.sh`: passed. + - Without `-x -P SCRIPTDIR`, shellcheck reports SC1091 (info) and SC2034 for the sourced harness. The same applies to the planned single-file `shellcheck npm/test/release-canary.sh` form, and `pack-smoke.sh` also fails in that form. + - Regression and grep checks: + - `nix build .#checks.x86_64-linux.npm-pack-smoke --print-build-logs`: passed (`pack-smoke: OK (sce --help, sce version)`). + - `rg -n 'createServer|openssl req' npm nix .github`: hits only `fixture-release-server.mjs` and `release-fixture.sh`. + - `rg -n 'SCE_NPM_RELEASE_BASE_URL|SCE_NPM_SKIP_DOWNLOAD|NODE_EXTRA_CA_CERTS' npm/test/release-canary.sh`: no hits. + - Context impact: local to npm test tooling. The canary script and its modes are a new release-verification surface. Its durable documentation, with the B1/B2/registry gates, is T10's scope (`context/sce/cli-npm-distribution-contract.md`), and no root context file changes. + - Context synchronization: synced + +- [x] T08: `Gate GitHub Release creation on the B1 canary in release-sce.yml` (status:done) + - Task ID: T08 + - Scope: In — `.github/workflows/release-sce.yml`: split `release` into `assemble` (signing secret only in the manifest step; uploads `dist/` with a SHA-pinned upload action), `release-canary` (no secrets, `contents: read`; downloads `dist/`, runs `release-canary.sh --release-dir` via Nix, outputs the tarball SHA-256), and `release` (needs both; re-checks the SHA-256 and creates the GitHub Release from the downloaded `dist/`). Out — the npm publish call (T09) and native build workflows. + - Dependencies: T07 + - Done when: no path creates a release without a successful canary, and the secret appears in exactly one step. + - Verify: `nix build .#checks.x86_64-linux.workflow-actionlint`; `grep -n 'SCE_RELEASE_MANIFEST_SIGNING_KEY\|secrets\.' .github/workflows/release-sce.yml`; inspect the job `needs`/`if` chain. + - Completed: 2026-11-04 + - Files changed: `.github/workflows/release-sce.yml`. + - Result: The former `release` job is now three jobs: + - `assemble` (`contents: read`): the same needs/`if` as before and the same checkout, Nix, artifact download, manifest, npm, and Flatpak steps. `SCE_RELEASE_MANIFEST_SIGNING_KEY` appears only in the `Assemble CLI release manifest` step's `env`. It uploads `dist/` as the `sce-assembled-dist` workflow artifact with the already pinned `actions/upload-artifact@043fb46d… # v7.0.1` (`if-no-files-found: error`). + - `release-canary` (`contents: read`, no `secrets.` reference): needs `resolve-release` and `assemble`, with `if` requiring both to succeed. It hardens the runner, checks out the tag, installs Nix, downloads `sce-assembled-dist` into `dist/`, and records the SHA-256 of `dist/npm/sce-v-npm.tgz`. It then runs `nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/release-canary.sh --release-dir dist/cli --tarball --expected-version --expected-sha256 `. Only after the canary exits 0 does it write the `npm_tarball_sha256` job output. + - `release` (`contents: write`): needs `resolve-release`, `assemble`, and `release-canary`. Its `if` requires all three to be `success` and a non-empty `npm_tarball_sha256`. It downloads `sce-assembled-dist`, fails unless the tarball's `sha256sum` equals the canary output, then runs the unchanged `softprops/action-gh-release` step with the existing `files` globs. It no longer checks out the repository or installs Nix, because it only consumes `dist/`. + - The workflow-level permission dropped from `contents: write` to `contents: read`. `create-tag` (tag push) and `release` (release creation) get `contents: write` at job level. The called build workflows already declare `contents: read`. + - Deviation: the artifact is named `sce-assembled-dist`, not `sce-release-dist`, so it cannot match `assemble`'s own `sce-release-*` download pattern on a job re-run. + - Verify outcomes: + - `nix build .#checks.x86_64-linux.workflow-actionlint --print-build-logs`: passed (exit 0). `nix shell nixpkgs#actionlint nixpkgs#shellcheck -c actionlint .github/workflows/release-sce.yml`: passed with no findings. + - `grep -n 'SCE_RELEASE_MANIFEST_SIGNING_KEY\|secrets\.' .github/workflows/release-sce.yml`: one hit, line 238, the `Assemble CLI release manifest` step `env` in `assemble`. `release-canary` and `release` hold no secret reference. + - The `needs`/`if` chain was inspected. `release` is reachable only through `release-canary.result == 'success'` with a non-empty SHA output. The only release-creation step runs after the SHA re-check, and no other job creates a release. + - Local behavior with the published v12 assets laid out as `dist/cli` (manifest, `.sig`, x86_64 musl archive, `.sha256`, `SHA256SUMS`) and `dist/npm/sce-v0.4.0-pre-alpha-v12-npm.tgz`, with the step scripts extracted from the workflow by `yq`: + - Canary step: exit 0, `release-canary: OK (local mode: @crocoder-dev/sce@0.4.0-pre-alpha-v12, tarball sha256 ec6bf434…edf57)`, and `GITHUB_OUTPUT` holds `npm_tarball_sha256=ec6bf434…edf57`. + - Release verify step with that output: exit 0, `npm tarball sha256 matches the canary-verified bytes`. With a wrong SHA: exit 1, `npm tarball sha256 ec6bf434… does not match canary-verified sha256 0000…`. + - Fault, a byte-appended tarball in the scratch copy: the canary step exits 1 with `release-canary: Tarball … has no readable package/package.json.` and writes no output, so `release` would be skipped. + - The scratch directory was removed, and `git status` shows only the workflow change. + - Live execution inside GitHub Actions needs a real release run and is not proven here. + - Context impact: release workflow topology. The `assemble`/`release-canary`/`release` split and the secret scoping are documented in T10 (`context/sce/cli-release-artifact-contract.md`, `context/sce/cli-npm-distribution-contract.md`). No root context file content is expected to change. + - Context synchronization: synced + +- [x] T09: `Make publish-npm.yml a reusable workflow with pre- and post-publish canaries` (status:done) + - Task ID: T09 + - Scope: In — `.github/workflows/publish-npm.yml`: add a `workflow_call` trigger with `release_tag`/`dry_run`/`prerelease` inputs and an explicit `NPM_TOKEN` secret, keeping `workflow_dispatch` and `release: published`. Jobs: `resolve` (parity and tag checks), `github-release-canary` (no secrets; downloads the asset and runs `release-canary.sh --github-release`), `publish` (needs the canary; `NPM_TOKEN` only in the publish step's env; skips when the exact version already exists with matching integrity and fails on differing integrity; supports dry-run), and `registry-canary` (no secrets; `--registry-version` with bounded retries; skipped on dry-run). `.github/workflows/release-sce.yml`: a `publish-npm` job that `needs: release` and `uses: ./.github/workflows/publish-npm.yml`, passing the tag, the prerelease flag, `dry_run: false`, and only `NPM_TOKEN`. Out — crates.io publishing, npm provenance changes. + - Dependencies: T08 + - Done when: the trigger and dependency chain meets AC9, and actionlint passes. + - Verify: `nix build .#checks.x86_64-linux.workflow-actionlint`; `grep -n 'NPM_TOKEN\|secrets' .github/workflows/publish-npm.yml .github/workflows/release-sce.yml`; inspect for no `secrets: inherit` and no `latest` resolution in canary commands. + - Completed: 2026-11-04 + - Files changed: `.github/workflows/publish-npm.yml`, `.github/workflows/release-sce.yml`. + - Result: + - `publish-npm.yml` triggers: `release: published` (kept), `workflow_dispatch` (kept; `dry_run` still defaults to `true`), and a new `workflow_call` with `release_tag` (required string), `dry_run` (boolean, default `false`), `prerelease` (boolean, default `false`), and an explicitly declared optional `NPM_TOKEN` secret. The workflow-level `contents: read` and the per-tag concurrency group are unchanged. + - Trigger mapping: the tag is `inputs.release_tag || github.event.release.tag_name`. `DRY_RUN` is `github.event_name != 'release' && inputs.dry_run || false`, and `PRE_RELEASE` reads the release payload for `release` events or `inputs.prerelease` otherwise. Because a called workflow sees the caller's `event_name` (`push` or `workflow_dispatch` from `release-sce.yml`), a call always maps to the call inputs. + - The single `publish` job is now four jobs, each with `harden-runner` and job-level `contents: read`: + - `resolve`: checks out the tag and runs the existing `.version`/`npm/package.json`/tag/prerelease parity checks. It derives the `next`/`latest` dist-tag and the normalized `dry_run` value, downloads `sce-v-npm.tgz` with `gh release download` (`github.token`), and verifies the packed name and version. It outputs `tag`, `version`, `npm_tag`, `dry_run`, and `npm_tarball_sha256`. + - `github-release-canary` (needs `resolve`, no secret reference): installs Nix, checks out the tag, and runs `nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/release-canary.sh --github-release --expected-version --expected-sha256 ` (B2). + - `publish` (needs `resolve` and `github-release-canary`; `if` requires both `success`): + - It downloads the asset again and fails unless its SHA-256 equals the canary-verified `resolve` hash. + - `Check registry for an existing publication` compares `npm view @crocoder-dev/sce@ dist.integrity` with the local `sha512-` integrity. An empty result or `E404` means unpublished. A matching value sets `already_published=true`. A differing value or any other registry error fails the job. + - The dry-run step runs `npm publish --dry-run`. + - The publish step is the only place `secrets.NPM_TOKEN` appears in this file, as `NODE_AUTH_TOKEN` in that step's `env`. The former separate token-check step is folded into it. It writes a temporary `--userconfig` containing the literal `${NODE_AUTH_TOKEN}` reference, so the token never touches disk. `setup-node` no longer sets `registry-url`, so no placeholder auth token is exported to other steps. + - Both publish steps are skipped when `already_published` is `true`, which makes reruns idempotent. + - `registry-canary` (needs `resolve` and `publish`; `if` requires both `success` and `dry_run != 'true'`; no secret reference): runs `release-canary.sh --registry-version --expected-version --expected-sha256 `. The script's bounded 10 × 30s registry retries, its byte/`dist.integrity` comparison with the GitHub tgz, and its exact-semver guard apply. + - `release-sce.yml` has a new `publish-npm` job: `needs: [resolve-release, release]`, an `if` requiring both `success`, `permissions: contents: read`, and `uses: ./.github/workflows/publish-npm.yml`. It passes `release_tag: needs.resolve-release.outputs.tag`, `dry_run: false`, `prerelease: github.event_name == 'workflow_dispatch' && inputs.prerelease || false` (the same expression as the release step), and `secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }}` only. There is no `secrets: inherit`. + - Deviations and assumptions: + - The handoff hash is computed in `resolve` from the GitHub Release asset. It is not passed in from `release-sce.yml`'s B1 canary output, which keeps the `workflow_call` interface to the three planned inputs. + - `github.token` is used for `gh release download` in `resolve` and `publish`. It is not a `secrets.` reference. The canary jobs use the public release URL, as in T07. + - Verify outcomes: + - Actionlint: + - `nix build .#checks.x86_64-linux.workflow-actionlint --print-build-logs`: passed (exit 0). + - `nix shell nixpkgs#actionlint nixpkgs#shellcheck -c actionlint .github/workflows/publish-npm.yml .github/workflows/release-sce.yml`: passed with no findings. + - `grep -n 'NPM_TOKEN\|secrets' .github/workflows/publish-npm.yml .github/workflows/release-sce.yml`: + - `publish-npm.yml`: the `workflow_call` `secrets:`/`NPM_TOKEN:` declaration (lines 23–24), `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` in the `Publish package to npm` step `env` (line 302), and that step's error text (line 307). + - `release-sce.yml`: the signing key in `assemble`'s manifest step (line 238, unchanged), and the `publish-npm` job's explicit `secrets: NPM_TOKEN` (lines 417–418). + - No canary job references a secret. + - `grep -n 'inherit\|latest'`: no `secrets: inherit`. The only `latest` hits are the `next`/`latest` publish dist-tag selection and the input descriptions. Both canary commands pass an exact `.version`-derived semver, never a dist-tag. + - The `needs`/`if` chain was inspected: + - `release` → `publish-npm` (`release-sce.yml`). + - `resolve` → `github-release-canary` → `publish` → `registry-canary` (`publish-npm.yml`). + - `npm publish` is reachable only after a successful B2 canary. The registry canary runs only after a successful, non-dry-run `publish`, and a failure there fails the workflow. + - Local behavior check: the `Check registry for an existing publication` step script was extracted with `yq` and run against the published `sce-v0.4.0-pre-alpha-v12-npm.tgz`: + - Unmodified tgz at version `0.4.0-pre-alpha-v12`: exit 0 with `already_published=true` (matching `sha512-Dt3odIlX…UKA==`). + - The same bytes under an unpublished `0.4.0-pre-alpha-v999`: exit 0 with `already_published=false`. + - A byte-appended tgz: exit 1 with `… already published with integrity sha512-Dt3o…, which does not match the GitHub Release tarball integrity sha512-Q6fp…`. + - The extracted publish step shows the heredoc writes the literal `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` line. The scratch directory was removed. + - Not proven here: live `workflow_call` handoff, B2 and registry canaries inside Actions, and real `npm publish`/rerun behavior. These need a real release run. + - Context impact: release and npm publication workflow topology (the `workflow_call` trigger, the four-job publish chain, `NPM_TOKEN` scoping, idempotent reruns). Durable documentation is T10's scope (`context/sce/cli-npm-distribution-contract.md`, `context/sce/cli-first-install-channels-contract.md`, `context/sce/cli-release-artifact-contract.md`). No root context file content is expected to change. + - Context synchronization: synced + +- [x] T10: `Document release-security canary gates and npm publication topology` (status:done) + - Task ID: T10 + - Scope: In — `context/sce/cli-npm-distribution-contract.md` (release-security canary, the B1/B2/registry gates, updated Registry publication topology covering the `workflow_call` trigger, secret scoping, reruns, and idempotent republish), `context/sce/cli-release-artifact-contract.md` (assemble/canary/release job split), `context/sce/cli-first-install-channels-contract.md` (release-to-npm topology), and `context/context-map.md` annotations if needed. Each doc states what can only be validated by a real release. Out — VM docs (T06). + - Dependencies: T09 + - Done when: the docs cover the release half of AC12, with job and script names matching the workflows. + - Verify: inspect the three contracts; `grep -n 'release-canary' context/sce/*.md .github/workflows/*.yml`. + - Completed: 2026-11-04 + - Files changed: `context/sce/cli-npm-distribution-contract.md`, `context/sce/cli-release-artifact-contract.md`, `context/sce/cli-first-install-channels-contract.md`, `context/context-map.md`. + - Result: + - `cli-npm-distribution-contract.md`: + - Replaced the stale "remote modes are not yet wired" line with a "Release gates" list: B1 is the `release-canary` job in `release-sce.yml`, B2 is the `github-release-canary` job in `publish-npm.yml`, and the post-publish gate is `registry-canary`. + - Added the rule that only B1 sets installer overrides. + - Added the caveat that local `v0.4.0-pre-alpha-v12` runs exercise the script only, not the new installer code or the gates inside Actions. + - Added "Manual reruns": re-running failed jobs, a `workflow_dispatch` of `publish-npm.yml` with `release_tag` and `dry_run: false` (dispatch defaults to dry-run), and idempotent republish on matching `dist.integrity` (fails on a mismatch). + - The existing T09-synced topology bullets (`workflow_call`, `NPM_TOKEN` scoping, real-release-only proof) are kept. + - `cli-release-artifact-contract.md`: labels `release-canary` as the B1 gate. Adds rerun guidance (reuses `sce-assembled-dist`, still requires the canary-recorded SHA-256) and a pointer to the downstream B2 and registry gates. + - `cli-first-install-channels-contract.md`: new "Release-to-npm topology" bullet covering the job chain across both workflows, secret confinement, triggers, and real-release-only proof. + - `context-map.md`: the npm-contract annotation now names the three gates and the `workflow_call`/`NPM_TOKEN`/rerun/idempotent-republish topology. + - Verify outcomes: + - `grep -n 'release-canary' context/sce/*.md .github/workflows/*.yml`: passed. + - Doc job names `release-canary`, `github-release-canary`, and `registry-canary` match `release-sce.yml:271` and `publish-npm.yml:180`/`:321`. + - Step names `Assemble CLI release manifest` (`release-sce.yml:236`) and `Publish package to npm` (`publish-npm.yml:298`) and the artifact `sce-assembled-dist` match the workflows. + - Inspected the three contracts against the workflow triggers, `needs`/`if` chains, secret references, and `workflow_dispatch` `dry_run: true` default: consistent. + - Context impact: durable docs only (`context/sce/` contracts and `context/context-map.md`); no code or root context file changes. + - Context synchronization: synced + +## Open questions + +- The launcher's missing-binary message tells users to run `npm rebuild sce`, but the package is `@crocoder-dev/sce`, and `npm rebuild sce` does not rebuild it. The plan asserts the current text and leaves `npm/bin/sce.js` alone. Correcting the message is a one-line launcher change, which would also update the VM assertion. Should it be included? +- B2 inside GitHub Actions, the registry canary, the `workflow_call` handoff, and the idempotent-rerun path can only be proven by a real release run. Before that, the evidence will be actionlint, workflow inspection, and local canary runs against the already published `v0.4.0-pre-alpha-v12` artifacts. That release predates the hardened installer, so a pass exercises the canary script, not the new installer code. + +## Validation Report + +**Status:** validated +**Date:** 2026-11-04 + +### Commands run + +- `nix flake check` -> exit 0 (all `checks.x86_64-linux` derivations passed) +- `nix build .#npm-nixos-vm --print-build-logs` -> exit 0 (all nine subtests passed; test script finished in 54.86s; no `exited 124` in the log) +- `nix build .#ci-checks --print-build-logs` -> exit 0 (`result/` holds only `release-portability-audit` and `sce-release`) +- `nix build .#checks.x86_64-linux.npm-bun-tests --print-build-logs` -> exit 0 +- `nix build .#checks.x86_64-linux.workflow-actionlint` -> exit 0 +- `nix build .#checks.x86_64-linux.npm-pack-smoke --print-build-logs` -> exit 0 +- `nix build .#sce-release && nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh --binary ./result/bin/sce` -> exit 0 (`pack-smoke: OK (real binary: sce --help, sce version --format json)`, version `0.4.0-pre-alpha-v12`) +- `nix shell nixpkgs#ripgrep -c rg -n 'createServer|openssl req' npm nix .github` -> exit 0 (hits only `npm/test/fixture-release-server.mjs` and `npm/test/release-fixture.sh`) +- `nix shell nixpkgs#ripgrep -c rg -n 'SCE_NPM_RELEASE_BASE_URL|SCE_NPM_SKIP_DOWNLOAD|NODE_EXTRA_CA_CERTS' npm/test/release-canary.sh` -> exit 1 (no hits; the overrides are set only in `fixture_npm_install` in `release-fixture.sh`, which only the `--release-dir` path calls) +- `bash npm/test/release-canary.sh --release-dir --tarball /sce-v0.4.0-pre-alpha-v12-npm.tgz --expected-version 0.4.0-pre-alpha-v12` -> exit 0 (`release-canary: OK (local mode …)`) +- `bash npm/test/release-canary.sh --github-release v0.4.0-pre-alpha-v12 --expected-version 0.4.0-pre-alpha-v12` -> exit 0 (`release-canary: OK (github mode …)`) +- `bash npm/test/release-canary.sh --registry-version 0.4.0-pre-alpha-v12 --expected-version 0.4.0-pre-alpha-v12` -> exit 0 (registry tarball byte-identical; `release-canary: OK (registry mode …)`) +- `nix eval .#checks.x86_64-linux --apply builtins.attrNames` -> exit 0 (`npm-nixos-vm` not listed) +- `git diff a4611a4b --stat -- npm/lib npm/bin` -> exit 0 (empty) +- `git diff a4611a4b --stat -- 'npm/test/*.test.js'` -> exit 0 (empty; no removals) + +### Success-criteria verification + +- [x] AC1: One shared release-fixture harness -> the `npm-pack-smoke` check and the host `--binary` smoke passed; `createServer|openssl req` hits only `fixture-release-server.mjs` and `release-fixture.sh`. +- [x] AC2: Skipped postinstall and `npm rebuild` recovery -> VM log: `launcher without binary exited 1: The native sce binary is not installed. …`; `runtime/sce` mode 755 and `parsed version: 0.4.0-pre-alpha-v12` after the first and the second rebuild. +- [x] AC3: Failed reinstall preserves the binary -> VM log: `Release manifest authenticity check failed` and `Downloaded sce archive checksum mismatch` (each `npm rebuild exited 1`); `runtime/sce` sha256 `97fb0fd6…fe5a` and mode 755 unchanged; `installer temp dirs in TMPDIR: none` and `runtime/ entries: ['sce']` after each failure; the restored-fixture reinstall succeeded. +- [x] AC4: `sce setup --bootstrap-context` -> VM log as `sce-tester` (uid 1000) in a fresh Git repository: `Context baseline ensured.`, the full context tree listed, `second run: context tree unchanged`, `sentinel preserved`. +- [x] AC5: Minimal runtime -> VM log: `launcher PATH entries: ['node']`; `tar curl gzip bash do not resolve` on the launcher PATH and on `PATH=/var/empty`; `no ELF INTERP program header`; `no FHS loader at /lib64/ld-linux-x86-64.so.2`. Inspected `nix/tests/npm-nixos-vm.nix`: `env -i` environments at lines 49–50; the setup scenario exports its own `setupToolsPath`. +- [x] AC6: Local-only, non-root, loopback-only, installer unchanged -> `npm-nixos-vm` absent from `checks.x86_64-linux`; `ci-checks` holds only `sce-release` and `release-portability-audit`; the `npm/lib` and `npm/bin` diff is empty; the VM node sets `useDHCP = false`, `interfaces = { }`, `vlans = [ ]`; scenarios run under `/home/sce-tester/scenarios/`. +- [x] AC7: Exact-artifact canary -> all three modes exit 0 against `v0.4.0-pre-alpha-v12`: tarball sha256 `ec6bf434…edf57` unchanged, bundled key byte-identical (`bfc3c81e…f212`), manifest verified, `runtime/sce` and `bin/sce.js` mode 755, `.bin/sce` → `bin/sce.js`, version `0.4.0-pre-alpha-v12`; the override variables are absent from `release-canary.sh`. +- [x] AC8: B1 gate -> inspected `release-sce.yml`: `release` needs `release-canary` and gates on its success plus a non-empty `npm_tarball_sha256`; `SCE_RELEASE_MANIFEST_SIGNING_KEY` appears only in the `env` of the `Assemble CLI release manifest` step in `assemble`; `release-canary` has no `secrets.` reference; actionlint passed. +- [x] AC9: npm publication topology -> `publish-npm` in `release-sce.yml` needs `release` and calls `publish-npm.yml` through `uses:` with `NPM_TOKEN` passed explicitly (no `secrets: inherit`); `publish` needs `github-release-canary`; `registry-canary` needs `publish` and runs `--registry-version`, bounded at 10 × 30s in `release-canary.sh`; `secrets.NPM_TOKEN` appears only in the `Publish package to npm` step; the `release: published`, `workflow_call`, and `workflow_dispatch` triggers are present; actionlint passed. Live B2 and registry execution in GitHub Actions is not provable before a real release. +- [x] AC10: Fault injection -> task evidence records each fault in scratch worktrees or copies with its failing message (T02 rebuild skipped; T03 binary replaced and temp sibling left; T04 sentinel overwritten; T05 tar on the PATH and an ELF-interpreter binary; T07 tarball byte change, swapped key, wrong expected version); `git worktree list` shows no scratch worktrees, and `git status` shows no fault residue. +- [x] AC11: Existing JS installer tests -> `npm-bun-tests` passed; the `npm/test/*.test.js` diff against `a4611a4b` is empty. +- [x] AC12: Durable docs -> inspected `cli-npm-distribution-contract.md`, `cli-release-artifact-contract.md`, and `cli-first-install-channels-contract.md`: throwaway-key repack versus the production-key canary, each VM scenario, the B1/B2/registry gates, the `workflow_call` topology, `NPM_TOKEN` scoping, manual reruns, and real-release-only proof; job and step names match the workflows. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- B2 (`github-release-canary`), `registry-canary`, the `workflow_call` handoff, and the idempotent-republish path are proven only by inspection and local canary runs. They need a real release to confirm. +- The v12 canary runs exercise the canary script, not the hardened installer, because v12 predates it. +- T05 recorded an intermittent hang after the archive-checksum-mismatch `npm rebuild` (exit 124, which the T03 assertion accepts). It did not recur in this run (`npm rebuild exited 1`), but the root cause and the permissive T03 assertion remain open follow-ups. diff --git a/context/sce/cli-first-install-channels-contract.md b/context/sce/cli-first-install-channels-contract.md index 4fc39e13f..ec55046ae 100644 --- a/context/sce/cli-first-install-channels-contract.md +++ b/context/sce/cli-first-install-channels-contract.md @@ -30,6 +30,13 @@ No other install channels are in scope for the current implementation stage. - `npm` consumes release artifacts produced by Nix-managed build/release flows; npm-supported native archives must already be free of forbidden `/nix/store/` runtime references and smoke-validated before upload because the npm installer does not patch native dynamic-link metadata. - `Cargo` is a first-class supported install path and its publish metadata should stay aligned to `.version` without workflow-side version bumping. - npm registry publication should also consume the checked-in package version aligned to `.version` without workflow-side version bumping. +- Release-to-npm topology: + - `.github/workflows/release-sce.yml` runs `assemble` → `release-canary` (B1 exact-artifact canary, `npm/test/release-canary.sh --release-dir`) → `release` (GitHub Release creation) → `publish-npm`. + - `publish-npm` calls `.github/workflows/publish-npm.yml` through `workflow_call` and passes only `NPM_TOKEN`. + - `publish-npm.yml` then runs `resolve` → `github-release-canary` (B2) → `publish` → `registry-canary`. + - The signing key is confined to one `assemble` step and `NPM_TOKEN` to the `Publish package to npm` step. No canary job references a secret. + - `release: published` and `workflow_dispatch` start the same publish pipeline. + - Details and rerun guidance live in `cli-release-artifact-contract.md` and `cli-npm-distribution-contract.md`. The end-to-end chain can only be proven by a real release run. - Flatpak is source-built and must not consume `nix build .#sce`, native GitHub Release binary archives, npm native binaries, or any other prebuilt `sce` artifact. - GitHub Release Flatpak assets include source-manifest package assets and source-built `.flatpak` bundle assets for the source-built `dev.crocoder.sce` Flatpak channel. Automatic Flathub submission, prebuilt (non-source-built) Flatpak binaries/bundles, OSTree repositories, release-version bumping, and Flatpak publication beyond the approved GitHub Release asset upload remain out of scope. - `Homebrew` can return in a later plan stage, but it is not part of current code truth. diff --git a/context/sce/cli-npm-distribution-contract.md b/context/sce/cli-npm-distribution-contract.md index 642a724fe..2f79e249f 100644 --- a/context/sce/cli-npm-distribution-contract.md +++ b/context/sce/cli-npm-distribution-contract.md @@ -45,29 +45,85 @@ This file captures the implemented npm distribution slice of the `sce` CLI insta - npm registry publication is a separate downstream publish stage from GitHub release packaging. - `.github/workflows/publish-npm.yml` is the dedicated npm publish workflow. -- It triggers from a published GitHub release or manual dispatch. +- It runs through `workflow_call` from the `publish-npm` job of `.github/workflows/release-sce.yml`, which `needs: release` and passes `release_tag`, `prerelease`, `dry_run: false`, and only the `NPM_TOKEN` secret explicitly (no `secrets: inherit`). `release: published` (for externally created releases, because releases created with `GITHUB_TOKEN` do not fire it) and `workflow_dispatch` (defaulting to dry-run) start the same pipeline. The per-tag concurrency group serializes overlapping runs. +- Its jobs run in order `resolve` → `github-release-canary` → `publish` → `registry-canary`: + - `resolve` performs the parity checks below, downloads the GitHub Release tarball, and records its SHA-256. + - `github-release-canary` (no secrets) runs `npm/test/release-canary.sh --github-release --expected-version --expected-sha256 `. `npm publish` cannot run unless it passes. + - `publish` downloads the tarball again and requires the same SHA-256. It compares the registry `dist.integrity` for the exact version with the tarball's sha512: a match skips `npm publish` (an idempotent rerun), a different value fails, and an absent version is published. `NPM_TOKEN` is exposed only to the `Publish package to npm` step. + - `registry-canary` (no secrets, skipped on dry-run) runs `npm/test/release-canary.sh --registry-version --expected-version --expected-sha256 `. The script retries registry availability a bounded number of times and fails the workflow on any byte, integrity, or install mismatch. + - The live `workflow_call` handoff, both remote canaries, and rerun behavior can only be confirmed by a real release run. +- Manual reruns: + - Use "Re-run failed jobs" on the original `release-sce.yml` run to resume after a transient failure. A rerun of `publish-npm` reuses the same tag and the GitHub Release that already exists. + - Dispatching `publish-npm.yml` through `workflow_dispatch` with `release_tag: v` and `dry_run: false` reruns publication against an existing GitHub Release. Dispatch defaults to `dry_run: true`, and a dry run skips `registry-canary`. + - Republishing is idempotent. When the exact version is already on the registry with matching `dist.integrity`, the `publish` job skips `npm publish` and `registry-canary` still runs. A different integrity value fails the run and is never overwritten. - It validates parity across repo-root `.version`, checked-in `npm/package.json`, and the target release tag before attempting publication. - It downloads the canonical `sce-v-npm.tgz` asset from the corresponding GitHub release rather than rebuilding or mutating package metadata during publish. - It verifies the downloaded tarball still declares package name `@crocoder-dev/sce` and version `<.version>` before `npm publish`. - For GitHub prerelease events, manual dispatches marked `prerelease: true`, or checked-in semver prerelease versions containing `-`, the workflow publishes with npm dist-tag `next` so the package does not become `latest`. - When a publish run is explicitly marked prerelease, the workflow requires `.version` to be a semver prerelease version and fails before publishing if it is not. -- Real publication requires `NPM_TOKEN`; manual dispatch can remain on a dry-run path via `npm publish --dry-run`. +- Real publication requires `NPM_TOKEN`, supplied to the publish step through a temporary `--userconfig` that references `${NODE_AUTH_TOKEN}`. Dry-run runs use `npm publish --dry-run` and skip the registry canary. - The npm publish workflow publishes the already-versioned checked-in `npm/` package and does not invent or bump a release version during workflow execution. - The npm installer continues to trust GitHub Releases as the canonical host for signed manifest and native binary artifacts. - Native binary portability hygiene is owned by the shared release artifact surface before archive publication, including macOS `libiconv` install-name sanitization, Linux/macOS forbidden `/nix/store/` audits, and reusable-workflow extracted-binary smoke validation; npm continues to consume the signed release manifest and checksum-verified native archive without adding a separate npm-native build or dylib repair path. ## Packed-package smoke test -- `npm/test/pack-smoke.sh` (with helper `npm/test/fixture-release-server.mjs`) verifies the real packed artifact end to end without network access: it stages a copy of the package (or `--package-dir `, or the `package/` contents of an already packed `--tarball `, whose launcher mode is asserted first), swaps a throwaway RSA manifest-signing public key into the staged copy only, builds a stub `sce` archive in the canonical `sce-v-/{bin/sce,LICENSE,README.md}` layout with a matching signed manifest, serves it over loopback HTTPS (self-signed certificate trusted via `NODE_EXTRA_CA_CERTS`, selected via `SCE_NPM_RELEASE_BASE_URL`), runs `npm pack`, fails unless the packed `package/bin/sce.js` entry is executable, installs into an isolated prefix with an isolated npm cache/config, asserts the installed `runtime/sce` has mode `755` and that `node_modules/.bin/sce` resolves to the installed `@crocoder-dev/sce/bin/sce.js`, then runs the installed CLI. +- `npm/test/pack-smoke.sh` (built on the shared release-fixture harness below) verifies the real packed artifact end to end without network access: it stages a copy of the package (or `--package-dir `, or the `package/` contents of an already packed `--tarball `, whose launcher mode is asserted first), swaps a throwaway RSA manifest-signing public key into the staged copy only, builds a stub `sce` archive in the canonical `sce-v-/{bin/sce,LICENSE,README.md}` layout with a matching signed manifest, serves it over loopback HTTPS (self-signed certificate trusted via `NODE_EXTRA_CA_CERTS`, selected via `SCE_NPM_RELEASE_BASE_URL`), runs `npm pack`, fails unless the packed `package/bin/sce.js` entry is executable, installs into an isolated prefix with an isolated npm cache/config, asserts the installed `runtime/sce` has mode `755` and that `node_modules/.bin/sce` resolves to the installed `@crocoder-dev/sce/bin/sce.js`, then runs the installed CLI. +- Shared release-fixture harness: `npm/test/release-fixture.sh` owns every hermetic fixture step: tool checks, package staging and launcher-mode assertion, platform resolution, the throwaway signing key and loopback TLS certificate, canonical archive plus signed manifest, `npm pack`, starting and stopping `npm/test/fixture-release-server.mjs` (the only fixture HTTPS server), the isolated offline `npm install`, and the `runtime/sce` mode and `.bin/sce` target assertions. It is sourced as a Bash library of `fixture_*` functions whose failure and progress lines use the caller's `FIXTURE_LOG_PREFIX` (pack-smoke keeps its `pack-smoke:` messages). It also runs directly. `release-fixture.sh prepare [--package-dir | --tarball ] [--binary ]` writes `stage/`, `pack/`, `release/`, `keys/` (throwaway `signing.pem`, `tls-cert.pem`, `tls-key.pem`), and a shell-sourceable `fixture.env` (`FIXTURE_VERSION`, `FIXTURE_TARGET_TRIPLE`, `FIXTURE_TARBALL`, `FIXTURE_RELEASE_DIR`, `FIXTURE_TLS_CERT`, `FIXTURE_TLS_KEY`). It refuses an already prepared dir. `release-fixture.sh serve [--port-file ]` runs the server in the foreground against `/release` (default port file `/server-port`), so another process can install from the prepared tarball with `SCE_NPM_RELEASE_BASE_URL=https://127.0.0.1:` and `NODE_EXTRA_CA_CERTS=$FIXTURE_TLS_CERT`. The server reads files per request, so consumers can tamper with and restore `release/` in place. Because the harness swaps in a throwaway key and repacks, its consumers never prove the exact release artifact or the production key. - Two archive modes share every signing, manifest, pack, server, and install step. Fixture mode, the default, serves a stub `bin/sce` and asserts exact output from `sce --help` and `sce version`. Real-binary mode (`--binary `, e.g. the Nix-built `sce-release` binary) serves the given executable as `bin/sce` and asserts that `sce --help` exits 0 and that `.version` from `sce version --format json` equals the expected version. - `--expected-version ` overrides the expected version (default: the staged `package.json` version). `--prefix ` installs into a caller-owned directory that is kept after the run; otherwise the prefix is temporary. - The fixture archive is produced by real GNU `tar`, so the smoke also exercises the in-repo archive reader against the release pipeline's archive format under Node. - Only the host platform is exercised. The production public key and manifest trust code are not modified. - Root `flake.nix` runs the smoke in the build sandbox as `checks..npm-pack-smoke` (part of `nix flake check`; loopback HTTPS works inside the sandbox). The sandbox has no `/usr/bin/env`, so there the installed bin is run as `node ` after its `-x` check. Host runs, including the `release-npm-package` gate, exec the bin directly. -- `nix/tests/npm-nixos-vm.nix` (exposed as `packages.x86_64-linux.npm-nixos-vm`; local-only, neither a flake check nor a `.#ci-checks` member) runs real-binary mode inside a minimal NixOS VM (`pkgs.testers.runNixOSTest`, loopback-only networking) as a non-root user with an isolated HOME, npm cache, and prefix, against `${sceReleasePackage}/bin/sce` and the repo `.version`. It then asserts `runtime/sce` mode `755`, an executable `.bin/sce` resolving to the installed `bin/sce.js`, direct shebang execution of `sce --help`, and the `jq`-parsed `sce version --format json` `.version`. NixOS has no FHS loader or `/usr/lib` glibc, so a passing run shows the static-musl binary needs no unavailable dynamic libraries. -- Fixture mode versus the VM: fixture mode proves the packed package, signing/manifest/checksum/archive flow, and launcher wiring on the host, cheaply on every `nix flake check`, but its stub cannot reveal native linkage problems. The VM installs the actual static-musl `x86_64-unknown-linux-musl` release binary that the archive pipeline ships, so it also proves that binary runs through the npm launcher on a system with no FHS libraries. -- The VM covers only `linux/x64`. `linux/arm64` and `darwin/arm64` npm installs are not executed by any npm-level test; their native archives are covered only by the release pipeline's portability audits and extracted-binary `sce version --format json` smoke before upload. -- Running the VM test locally: `nix build .#npm-nixos-vm --print-build-logs` on x86_64-linux. With `/dev/kvm` it runs accelerated. Without it, QEMU falls back to TCG emulation, which is slower but still passes. CI does not run it: hosted `ubuntu-latest` runners do not expose `/dev/kvm`, so the test is run manually before release-affecting npm or musl-build changes. + +## NixOS VM real-install test + +- `nix/tests/npm-nixos-vm.nix` (`pkgs.testers.runNixOSTest`, test name `npm-nixos-vm`, exposed as `packages.x86_64-linux.npm-nixos-vm`) is local-only. It is neither a `checks.` member (so not part of `nix flake check`) nor a `.#ci-checks` member. It boots one minimal NixOS VM with loopback-only networking (`useDHCP = false`, no interfaces, no VLANs, so no external network) and runs every scenario as the non-root user `sce-tester`. The package comes from the repo `npm/` sources, the binary is the Nix-built static-musl `${sceReleasePackage}/bin/sce`, and the expected version is the repo `.version`. +- The VM node sets `environment.stub-ld.enable = false`, so it has no FHS loader (not even the NixOS stub at `/lib64/ld-linux-x86-64.so.2`) and no `/usr/lib` glibc. A passing run shows that the static-musl binary needs no unavailable dynamic libraries. +- Scenario isolation: each scenario subtest gets its own directory under `/home/sce-tester/scenarios/` from `mkScenario` (HOME, TMPDIR, npm cache and userconfig, prefix, fixture dir, server port/PID/log files). Each scenario runs `release-fixture.sh prepare --package-dir ~/npm --binary ` as the test user, starts a detached `release-fixture.sh serve`, and stops it when done. The scenarios share the Nix test helpers `mkScenario`, `asScenarioUser`, `prepareScenarioFixture`, `startScenarioServer`, `stopScenarioServer`, `installScenarioPackage`, and `rebuildScenarioPackage`, and the setup scenario adds `asSetupUser` for its restricted environment. The installer runs for real against real files. No installer function is mocked. +- Subtests, in test-script order: + - `test user is non-root`: the test user's uid is not `0`. + - `stage npm package source`: copies the repo `npm/` sources into the test user's HOME. + - `install the packed package through postinstall with the real binary`: runs `pack-smoke.sh --binary --expected-version <.version> --prefix ` in the VM. + - `installed modes`: `runtime/sce` has mode `755`, and `.bin/sce` is executable and resolves to the installed `bin/sce.js`. + - `direct launcher execution`: runs `.bin/sce --help` through its shebang and checks the `jq`-parsed `.version` from `sce version --format json`. + - `skipped postinstall and npm rebuild recovery` (scenario `rebuild`): installs the packed tarball with `--ignore-scripts`. It asserts that `runtime/sce` is absent and that the launcher exits non-zero with `The native sce binary is not installed.`. Then, twice (`first rebuild`, `second rebuild`), it runs `npm rebuild @crocoder-dev/sce --prefix --foreground-scripts` against the fixture server and asserts `runtime/sce` mode `755`, `--help`, and the parsed `.version`. This proves that a skipped postinstall is recoverable through npm alone and that repeated rebuilds keep a working install. + - `failed reinstall preserves existing binary` (scenario `reinstall`): installs with postinstall and records the `runtime/sce` SHA-256. It then tampers with the served `release/` in place and reruns postinstall with `npm rebuild`. First it swaps the manifest `.sig` for a well-formed signature over other bytes, and the rebuild must fail with `Release manifest authenticity check failed`. Next it restores the signature and appends bytes to the archive, and the rebuild must fail with `Downloaded sce archive checksum mismatch`. Finally it restores the archive, and the rebuild must succeed. After the initial install, after each failure, and after the restored reinstall, it asserts the recorded hash, mode `755`, a working launcher that reports `.version`, no `sce-npm-install-*`/`sce-npm-text-*` dirs in the scenario TMPDIR, and only `sce` in `runtime/`. This proves end to end that a rejected download leaves the previous binary byte-identical and leaves no staging residue. The unit-level malformed-manifest and archive permutations stay in `npm/test/*.test.js` and are not repeated here. + - `real sce setup --bootstrap-context` (scenario `setup`): installs with postinstall, then runs the installed launcher as the test user with a PATH that holds only the `nodejs`, `git`, and `coreutils` bin dirs. Under that PATH, `tar`/`curl`/`gzip`/`bash`/`jq` must not resolve. HOME, the XDG dirs, and TMPDIR are isolated, and `GIT_CONFIG_NOSYSTEM=1` is set. It creates a fresh Git repository under the scenario directory, with a placeholder `origin` remote to satisfy the setup remote preflight. From the repo root it runs `sce setup --bootstrap-context` and asserts the exact `context/` tree: `plans`, `handovers`, `decisions`, `tmp`, `overview.md`, `architecture.md`, `patterns.md`, `glossary.md`, `context-map.md`, and `tmp/.gitignore` (`*` / `!.gitignore`). It then appends a sentinel to `context/overview.md` and reruns from a nested subdirectory. It asserts an unchanged tree, a byte-identical `overview.md`, and the preserved sentinel. Finally it asserts that no ancestor of the repo is a Git checkout or SCE source tree, and that no bootstrapped file references `~/npm` or `/nix/store`. `--repo` is not exercised, because the CLI rejects it alongside `--bootstrap-context`. + - `minimal runtime dependencies` (scenario `minimal`): installs with postinstall, then builds a bin dir that holds only a `node` symlink. It asserts four things. `tar`, `curl`, `gzip`, and `bash` resolve neither on that dir nor on `/var/empty`. `readelf -lW` (run only by the test driver) shows no `INTERP` header for `runtime/sce`. `/lib64/ld-linux-x86-64.so.2` is absent. It then runs `.bin/sce --help` and `version --format json` under `env -i PATH= HOME=`, and runs `runtime/sce` directly under `env -i PATH=/var/empty`. It parses `.version` from each JSON in the test script, with no fallback to the system PATH. This proves that the launcher needs only `node` and that the installed binary needs nothing at all on PATH. +- Fixture mode versus the VM: fixture mode proves the packed package, the signing/manifest/checksum/archive flow, and the launcher wiring on the host, cheaply on every `nix flake check`. Its stub cannot reveal native linkage problems. The VM installs the actual static-musl `x86_64-unknown-linux-musl` release binary that the archive pipeline ships. It also proves that binary runs through the npm launcher on a system with no FHS libraries, along with the recovery, failed-reinstall, real-setup, and minimal-runtime scenarios above. +- The VM covers only `linux/x64`. `linux/arm64` and `darwin/arm64` npm installs are not executed by any npm-level test. Their native archives are covered only by the release pipeline's portability audits and its extracted-binary `sce version --format json` smoke before upload. +- Running the VM test locally: `nix build .#npm-nixos-vm --print-build-logs` on x86_64-linux. With `/dev/kvm` it runs accelerated. Without it, QEMU falls back to TCG emulation, which is slower but still passes. CI does not run it, because hosted `ubuntu-latest` runners do not expose `/dev/kvm`. Run it manually before release-affecting npm or musl-build changes. + +## Hermetic fixtures versus the exact release artifact + +- Every consumer of `npm/test/release-fixture.sh` substitutes a throwaway RSA manifest-signing key. That covers `npm-pack-smoke`, host `pack-smoke.sh` runs, the `release-npm-package` post-pack gate, and every `npm-nixos-vm` subtest. Each consumer writes the throwaway public key into a staged package copy, re-signs a freshly built manifest with the throwaway private key, and repacks (or, for `--tarball`, repacks the extracted contents). They serve that repacked tarball and a rebuilt archive over loopback HTTPS with an ephemeral self-signed certificate, selected through `SCE_NPM_RELEASE_BASE_URL` and `NODE_EXTRA_CA_CERTS`. +- These checks are therefore hermetic development coverage of the package layout, installer behavior, and native binary runtime. They do not prove the exact published `sce-v-npm.tgz` bytes, the bundled production public key `npm/lib/release-manifest-public-key.pem`, the production-signed release manifest, or real GitHub Releases TLS and download URLs. A passing VM or pack-smoke run is not release evidence for those properties. +- Proving the exact artifact and production key is the job of the release-security canary (below), which installs the unmodified tarball with its bundled key. It is not part of the fixture harness. + +## Release-security canary + +- `npm/test/release-canary.sh` installs the exact, unmodified `sce-v-npm.tgz` with its bundled production key. It never re-signs, repacks, or substitutes a key. It sources `release-fixture.sh` with the `release-canary` log prefix for the tool check, the loopback server, the offline fixture install, and the layout assertions. It requires `--expected-version` and an exact version: dist-tags and ranges are rejected, never resolved. +- Modes (exactly one): + - `--release-dir --tarball `: serves the real manifest, `.sig`, and archives from `` over loopback HTTPS with an ephemeral certificate. It is the only mode that sets `SCE_NPM_RELEASE_BASE_URL`/`NODE_EXTRA_CA_CERTS`, and it sets them through the harness install. + - `--github-release v`: downloads the tgz, manifest, and `.sig` from the public `releases/download` URL and installs with no installer overrides. + - `--registry-version ` (optional `--tarball`, default the GitHub asset): with bounded retries (10 × 30s), it requires the registry `dist.integrity` and the `npm pack` bytes of that exact version to equal the GitHub tgz. It then installs `@crocoder-dev/sce@` from the registry and checks the lockfile integrity. + - Both remote modes run every download, install, and `sce` call under `env -i` with only `PATH`, an isolated `HOME`/`TMPDIR`, and the npm cache/userconfig, so caller-set installer overrides cannot leak in. +- Every mode asserts the following: + - The tarball SHA-256 is unchanged by the run and equals the optional `--expected-sha256`. + - The packed name and version are correct. + - The packed `package/lib/release-manifest-public-key.pem` is byte-identical to the checked-in `npm/lib/release-manifest-public-key.pem`. + - `openssl dgst -verify` of the production manifest against that key passes, and the manifest version matches. + - `runtime/sce` has mode `755`, `.bin/sce` resolves to `bin/sce.js`, and the installed launcher is executable. + - `sce --help` runs, and `version --format json` reports the expected version. + - The packed launcher mode is logged, not enforced, because published tarballs up to `v0.4.0-pre-alpha-v12` pack `bin/sce.js` as `0644`. +- Failures print distinct `release-canary:` messages and exit 1. Success ends with `release-canary: OK ( mode: …)`. +- Release gates (every canary job is secret-free and passes `--expected-version` and `--expected-sha256`): + - B1, before the GitHub Release: the `release-canary` job in `.github/workflows/release-sce.yml` runs `--release-dir` against the assembled `dist/`. The `release` job cannot create a GitHub Release unless it passes (see [cli-release-artifact-contract.md](cli-release-artifact-contract.md)). + - B2, before `npm publish`: the `github-release-canary` job in `.github/workflows/publish-npm.yml` runs `--github-release` against the published GitHub Release assets. The `publish` job needs it. + - Registry, after `npm publish`: the `registry-canary` job in `.github/workflows/publish-npm.yml` runs `--registry-version` against the exact published version. It is skipped on dry-run and fails the workflow on mismatch or an unusable install. +- Only B1 sets `SCE_NPM_RELEASE_BASE_URL`/`NODE_EXTRA_CA_CERTS`. B2 and the registry gate set none of `SCE_NPM_RELEASE_BASE_URL`, `SCE_NPM_SKIP_DOWNLOAD`, or `NODE_EXTRA_CA_CERTS`. +- Local runs of all three modes against the already published `v0.4.0-pre-alpha-v12` artifacts exercise the canary script. That release predates the hardened installer, so these runs are not evidence for the new installer code or for the gates inside GitHub Actions. ## Verification baseline @@ -75,7 +131,9 @@ This file captures the implemented npm distribution slice of the `sce` CLI insta - `nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh` - `nix build .#sce-release && nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh --binary ./result/bin/sce` - `nix build .#checks..npm-pack-smoke --print-build-logs` +- `nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/release-fixture.sh prepare ` then `... release-fixture.sh serve ` (reusable hermetic fixture for other consumers) - `nix build .#npm-nixos-vm --print-build-logs` (x86_64-linux; uses KVM when `/dev/kvm` exists, otherwise slower QEMU emulation) +- `nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/release-canary.sh --expected-version (--release-dir --tarball | --github-release v | --registry-version )` (exact-artifact canary; the remote modes need network) - `nix develop -c sh -c 'cd npm && SCE_NPM_SKIP_DOWNLOAD=1 bun ./lib/install.js'` - `nix run .#release-manifest -- --version --artifacts-dir --out-dir --signing-key-file ` - `nix run .#release-npm-package -- --version --out-dir ` diff --git a/context/sce/cli-release-artifact-contract.md b/context/sce/cli-release-artifact-contract.md index 580ccac64..ee963219c 100644 --- a/context/sce/cli-release-artifact-contract.md +++ b/context/sce/cli-release-artifact-contract.md @@ -73,6 +73,13 @@ This file captures the current shared release artifact foundation plus the appro - Cargo/crates.io and npm registry publication are separate downstream publish stages and are not part of the canonical GitHub release artifact assembly job. - `.github/workflows/release-sce.yml` remains the CLI release orchestrator that assembles GitHub release assets from the reusable platform build workflows. - The release orchestrator injects the non-repo manifest-signing private key through the `SCE_RELEASE_MANIFEST_SIGNING_KEY` secret when assembling release-level metadata. +- Release assembly, the exact-artifact canary, and GitHub Release creation are three jobs, and the workflow default token permission is `contents: read`: + - `assemble` (`contents: read`) downloads the platform build artifacts, signs the manifest, builds the npm and Flatpak packages, and uploads `dist/` as the `sce-assembled-dist` workflow artifact. `SCE_RELEASE_MANIFEST_SIGNING_KEY` is referenced only in its `Assemble CLI release manifest` step. + - `release-canary` (`contents: read`, no secrets) is the B1 exact-artifact gate. It downloads `sce-assembled-dist` and runs `npm/test/release-canary.sh --release-dir dist/cli --tarball dist/npm/sce-v-npm.tgz --expected-version --expected-sha256 ` through `nix shell nixpkgs#nodejs nixpkgs#openssl`. It exposes `npm_tarball_sha256` only after the canary passes. + - `release` (`contents: write`) runs only when `release-canary` succeeded with a non-empty `npm_tarball_sha256`. It downloads `sce-assembled-dist`, fails unless the npm tarball SHA-256 equals the canary output, and then creates the GitHub Release from `dist/`. No other job creates a release. + - `publish-npm` runs only after `release` succeeds. It calls `.github/workflows/publish-npm.yml` through `workflow_call` with the tag, the prerelease flag, `dry_run: false`, and only `NPM_TOKEN` (see `cli-npm-distribution-contract.md`). + - Only `create-tag` and `release` hold `contents: write`. Successful execution of this split inside GitHub Actions can only be confirmed by a real release run. + - After a failure, use "Re-run failed jobs" on the same run. It reuses the uploaded `sce-assembled-dist` artifact, and `release` still requires the canary-recorded SHA-256. The downstream B2 (`github-release-canary`) and registry (`registry-canary`) gates live in `publish-npm.yml` (see `cli-npm-distribution-contract.md`). - The release orchestrator also runs `nix run .#release-flatpak-package -- --version --out-dir dist/flatpak` and uploads `dist/flatpak/*.tar.gz`, `dist/flatpak/*.sha256`, and `dist/flatpak/*.json` to the GitHub Release. - Manual GitHub release dispatch resolves the tag from checked-in `.version` and refuses to create the tag when `.version`, `cli/Cargo.toml`, and `npm/package.json` are not already aligned. Manual dispatch also exposes a boolean pre-release checkbox that is passed only to `softprops/action-gh-release` as the GitHub Release-level `prerelease` flag; it does not change tag naming, release title/body/notes, generated release notes, artifact names, or version validation. - Tag-triggered release execution also refuses to proceed when the pushed tag does not equal `v<.version>` or when checked-in Cargo/npm package metadata drift from `.version`. diff --git a/flake.nix b/flake.nix index aaaf2a99c..0da586de1 100644 --- a/flake.nix +++ b/flake.nix @@ -315,6 +315,7 @@ ./npm/test/install.test.js ./npm/test/pack-smoke.sh ./npm/test/platform.test.js + ./npm/test/release-fixture.sh ./npm/test/tar-fixture.js ./scripts/lib/release-manifest-signing.mjs ]; diff --git a/nix/tests/npm-nixos-vm.nix b/nix/tests/npm-nixos-vm.nix index 193fd7096..7bec0e8db 100644 --- a/nix/tests/npm-nixos-vm.nix +++ b/nix/tests/npm-nixos-vm.nix @@ -11,6 +11,66 @@ let prefixDir = "${homeDir}/prefix"; installedDir = "${prefixDir}/node_modules/@crocoder-dev/sce"; launcher = "${prefixDir}/node_modules/.bin/sce"; + npmDir = "${homeDir}/npm"; + missingBinaryMessage = "The native sce binary is not installed."; + + authenticityFailureMessage = "Release manifest authenticity check failed"; + checksumFailureMessage = "Downloaded sce archive checksum mismatch"; + + # Per-scenario paths; every scenario owns its own directory under + # ~/scenarios so installs, fixtures, and temp dirs never overlap. + mkScenario = name: rec { + dir = "${homeDir}/scenarios/${name}"; + prefixDir = "${dir}/prefix"; + fixtureDir = "${dir}/fixture"; + tmpDir = "${dir}/tmp"; + portFile = "${dir}/server-port"; + pidFile = "${dir}/server.pid"; + serverLog = "${dir}/server.log"; + installedDir = "${prefixDir}/node_modules/@crocoder-dev/sce"; + launcher = "${prefixDir}/node_modules/.bin/sce"; + }; + rebuildScenario = mkScenario "rebuild"; + reinstallScenario = mkScenario "reinstall"; + setupScenario = mkScenario "setup"; + minimalScenario = mkScenario "minimal"; + + minimalNodeBinDir = "${minimalScenario.dir}/node-bin"; + minimalHomeDir = "${minimalScenario.dir}/minimal-home"; + absentRuntimeTools = [ + "tar" + "curl" + "gzip" + "bash" + ]; + fhsLoader = "/lib64/ld-linux-x86-64.so.2"; + readelf = "${pkgs.binutils-unwrapped}/bin/readelf"; + minimalRuntimeBinary = "${minimalScenario.installedDir}/runtime/sce"; + minimalLauncherEnv = "${pkgs.coreutils}/bin/env -i PATH=${minimalNodeBinDir} HOME=${minimalHomeDir}"; + minimalBinaryEnv = "${pkgs.coreutils}/bin/env -i PATH=/var/empty HOME=${minimalHomeDir}"; + + setupRepoDir = "${setupScenario.dir}/repo"; + setupNestedDir = "${setupRepoDir}/nested/cwd"; + setupSentinel = "sce-tester sentinel: keep this line"; + setupToolsPath = pkgs.lib.makeBinPath [ + pkgs.nodejs + pkgs.git + pkgs.coreutils + ]; + setupBaselineDirs = [ + "context/plans" + "context/handovers" + "context/decisions" + "context/tmp" + ]; + setupBaselineFiles = [ + "context/overview.md" + "context/architecture.md" + "context/patterns.md" + "context/glossary.md" + "context/context-map.md" + "context/tmp/.gitignore" + ]; asUser = command: @@ -24,6 +84,106 @@ let ${command} ''} ''; + + # Runs as the test user with HOME, npm cache/config, and TMPDIR isolated + # under . + asScenarioUser = + scenarioDir: command: + asUser '' + export HOME=${scenarioDir}/home + export TMPDIR=${scenarioDir}/tmp + export npm_config_cache=${scenarioDir}/npm-cache + export npm_config_userconfig=${scenarioDir}/npmrc + ${command} + ''; + + # Prepares the scenario's release fixture with the real binary. + prepareScenarioFixture = + scenario: + asScenarioUser scenario.dir '' + mkdir -p "$HOME" "$TMPDIR" "$npm_config_cache" + : >"$npm_config_userconfig" + bash ${npmDir}/test/release-fixture.sh prepare ${scenario.fixtureDir} \ + --package-dir ${npmDir} \ + --binary ${sceReleasePackage}/bin/sce 2>&1 + ''; + + # Starts the scenario's fixture server detached and waits for its port. + startScenarioServer = + scenario: + asScenarioUser scenario.dir '' + setsid bash ${npmDir}/test/release-fixture.sh serve ${scenario.fixtureDir} \ + --port-file ${scenario.portFile} \ + >${scenario.serverLog} 2>&1 ${scenario.pidFile} + # No explicit `exit`: it runs /etc/bash_logout, which fails under `set -u`. + for _ in $(seq 100); do + [ -s ${scenario.portFile} ] && break + sleep 0.1 + done + [ -s ${scenario.portFile} ] || { + cat ${scenario.serverLog} >&2 + false + } + ''; + + stopScenarioServer = + scenario: + asScenarioUser scenario.dir '' + kill "$(cat ${scenario.pidFile})" 2>/dev/null || true + ''; + + installScenarioPackage = + scenario: + asScenarioUser scenario.dir '' + . ${scenario.fixtureDir}/fixture.env + SCE_NPM_RELEASE_BASE_URL="https://127.0.0.1:$(cat ${scenario.portFile})" \ + NODE_EXTRA_CA_CERTS="$FIXTURE_TLS_CERT" \ + npm install \ + --prefix ${scenario.prefixDir} \ + --offline \ + --no-audit \ + --no-fund \ + --foreground-scripts \ + "$FIXTURE_TARBALL" 2>&1 + ''; + + asSetupUser = + command: + asScenarioUser setupScenario.dir '' + export PATH=${setupToolsPath} + export XDG_CONFIG_HOME="$HOME/.config" + export XDG_STATE_HOME="$HOME/.local/state" + export XDG_DATA_HOME="$HOME/.local/share" + export XDG_CACHE_HOME="$HOME/.cache" + export GIT_CONFIG_NOSYSTEM=1 + ${command} + ''; + + assertToolsAbsent = + label: path: + asScenarioUser minimalScenario.dir '' + for tool in ${pkgs.lib.escapeShellArgs absentRuntimeTools}; do + if PATH=${path} command -v "$tool" >/dev/null; then + echo "${label}: $tool resolves on PATH=${path}" >&2 + false + fi + done + echo "${label}: ${toString absentRuntimeTools} do not resolve on PATH=${path}" + ''; + + # Reruns postinstall in place against the scenario's fixture server. + rebuildScenarioPackage = + scenario: + asScenarioUser scenario.dir '' + . ${scenario.fixtureDir}/fixture.env + SCE_NPM_RELEASE_BASE_URL="https://127.0.0.1:$(cat ${scenario.portFile})" \ + NODE_EXTRA_CA_CERTS="$FIXTURE_TLS_CERT" \ + npm rebuild @crocoder-dev/sce \ + --prefix ${scenario.prefixDir} \ + --offline \ + --foreground-scripts 2>&1 + ''; in pkgs.testers.runNixOSTest { name = "npm-nixos-vm"; @@ -33,6 +193,8 @@ pkgs.testers.runNixOSTest { networking.interfaces = { }; virtualisation.vlans = [ ]; + environment.stub-ld.enable = false; + users.users.${testUser} = { isNormalUser = true; home = homeDir; @@ -94,5 +256,250 @@ pkgs.testers.runNixOSTest { ).strip() print(f"parsed version: {reported_version}") assert reported_version == ${builtins.toJSON expectedVersion}, f"sce version reported {reported_version!r}" + + def rebuild_and_assert(attempt): + print(machine.succeed(${rebuildScenarioPackage rebuildScenario})) + runtime_mode = machine.succeed("stat -c %a ${rebuildScenario.installedDir}/runtime/sce").strip() + print(f"{attempt}: runtime/sce mode: {runtime_mode}") + assert runtime_mode == "755", f"{attempt}: runtime/sce mode {runtime_mode}, expected 755" + machine.succeed(${asScenarioUser rebuildScenario.dir "${rebuildScenario.launcher} --help >/dev/null"}) + reported_version = machine.succeed( + ${asScenarioUser rebuildScenario.dir "${rebuildScenario.launcher} version --format json | jq -er .version"} + ).strip() + print(f"{attempt}: parsed version: {reported_version}") + assert reported_version == ${builtins.toJSON expectedVersion}, f"{attempt}: sce version reported {reported_version!r}" + + with subtest("skipped postinstall and npm rebuild recovery"): + print(machine.succeed(${prepareScenarioFixture rebuildScenario})) + machine.succeed(${startScenarioServer rebuildScenario}) + try: + print(machine.succeed(${asScenarioUser rebuildScenario.dir '' + . ${rebuildScenario.fixtureDir}/fixture.env + npm install \ + --prefix ${rebuildScenario.prefixDir} \ + --offline \ + --no-audit \ + --no-fund \ + --ignore-scripts \ + "$FIXTURE_TARBALL" 2>&1 + ''})) + machine.fail("test -e ${rebuildScenario.installedDir}/runtime/sce") + print("ignore-scripts install: runtime/sce is absent") + status, output = machine.execute(${asScenarioUser rebuildScenario.dir "${rebuildScenario.launcher} --help 2>&1"}) + print(f"launcher without binary exited {status}: {output.strip()}") + assert status != 0, "launcher without runtime/sce exited 0" + assert ${builtins.toJSON missingBinaryMessage} in output, f"launcher output lacks the missing-binary message: {output!r}" + + rebuild_and_assert("first rebuild") + rebuild_and_assert("second rebuild") + finally: + machine.succeed(${stopScenarioServer rebuildScenario}) + + def runtime_sha256(): + return machine.succeed( + "sha256sum ${reinstallScenario.installedDir}/runtime/sce | cut -d ' ' -f 1" + ).strip() + + def assert_binary_preserved(label, expected_sha256): + actual_sha256 = runtime_sha256() + print(f"{label}: runtime/sce sha256: {actual_sha256}") + assert actual_sha256 == expected_sha256, f"{label}: runtime/sce sha256 {actual_sha256}, expected {expected_sha256}" + runtime_mode = machine.succeed("stat -c %a ${reinstallScenario.installedDir}/runtime/sce").strip() + print(f"{label}: runtime/sce mode: {runtime_mode}") + assert runtime_mode == "755", f"{label}: runtime/sce mode {runtime_mode}, expected 755" + machine.succeed(${asScenarioUser reinstallScenario.dir "${reinstallScenario.launcher} --help >/dev/null"}) + reported_version = machine.succeed( + ${asScenarioUser reinstallScenario.dir "${reinstallScenario.launcher} version --format json | jq -er .version"} + ).strip() + print(f"{label}: parsed version: {reported_version}") + assert reported_version == ${builtins.toJSON expectedVersion}, f"{label}: sce version reported {reported_version!r}" + staging_dirs = machine.succeed( + "find ${reinstallScenario.tmpDir} -mindepth 1 -maxdepth 1" + " \\( -name 'sce-npm-install-*' -o -name 'sce-npm-text-*' \\) -print" + ).strip() + print(f"{label}: installer temp dirs in TMPDIR: {staging_dirs or 'none'}") + assert staging_dirs == "", f"{label}: installer temp dirs left behind: {staging_dirs}" + runtime_entries = machine.succeed("ls -A ${reinstallScenario.installedDir}/runtime").split() + print(f"{label}: runtime/ entries: {runtime_entries}") + assert runtime_entries == ["sce"], f"{label}: runtime/ holds {runtime_entries}, expected only sce" + + def assert_rebuild_fails(label, expected_message): + status, output = machine.execute(${rebuildScenarioPackage reinstallScenario}) + print(output) + print(f"{label}: npm rebuild exited {status}") + assert status != 0, f"{label}: npm rebuild exited 0" + assert expected_message in output, f"{label}: npm rebuild output lacks {expected_message!r}" + print(f"{label}: failed with {expected_message!r}") + + with subtest("failed reinstall preserves existing binary"): + print(machine.succeed(${prepareScenarioFixture reinstallScenario})) + # Pristine copies of the served release files, restored after each tamper. + machine.succeed(${asScenarioUser reinstallScenario.dir '' + . ${reinstallScenario.fixtureDir}/fixture.env + mkdir -p ${reinstallScenario.dir}/saved-release + cp -pR "$FIXTURE_RELEASE_DIR"/. ${reinstallScenario.dir}/saved-release/ + ''}) + machine.succeed(${startScenarioServer reinstallScenario}) + try: + print(machine.succeed(${installScenarioPackage reinstallScenario})) + installed_sha256 = runtime_sha256() + assert_binary_preserved("initial install", installed_sha256) + + # A well-formed signature over different bytes, so verification + # (not decoding) is what rejects it. + machine.succeed(${asScenarioUser reinstallScenario.dir '' + . ${reinstallScenario.fixtureDir}/fixture.env + printf 'not the release manifest\n' | + openssl dgst -sha256 -sign ${reinstallScenario.fixtureDir}/keys/signing.pem | + openssl base64 -A \ + >"$FIXTURE_RELEASE_DIR/sce-v$FIXTURE_VERSION-release-manifest.json.sig" + ''}) + assert_rebuild_fails("invalid manifest signature", ${builtins.toJSON authenticityFailureMessage}) + assert_binary_preserved("after invalid manifest signature", installed_sha256) + + machine.succeed(${asScenarioUser reinstallScenario.dir '' + . ${reinstallScenario.fixtureDir}/fixture.env + sig="sce-v$FIXTURE_VERSION-release-manifest.json.sig" + cp -p ${reinstallScenario.dir}/saved-release/"$sig" "$FIXTURE_RELEASE_DIR/$sig" + printf 'corrupted\n' >>"$FIXTURE_RELEASE_DIR/sce-v$FIXTURE_VERSION-$FIXTURE_TARGET_TRIPLE.tar.gz" + ''}) + assert_rebuild_fails("archive checksum mismatch", ${builtins.toJSON checksumFailureMessage}) + assert_binary_preserved("after archive checksum mismatch", installed_sha256) + + machine.succeed(${asScenarioUser reinstallScenario.dir '' + . ${reinstallScenario.fixtureDir}/fixture.env + archive="sce-v$FIXTURE_VERSION-$FIXTURE_TARGET_TRIPLE.tar.gz" + cp -p ${reinstallScenario.dir}/saved-release/"$archive" "$FIXTURE_RELEASE_DIR/$archive" + ''}) + print(machine.succeed(${rebuildScenarioPackage reinstallScenario})) + assert_binary_preserved("restored fixture reinstall", installed_sha256) + finally: + machine.succeed(${stopScenarioServer reinstallScenario}) + + def setup_tree(): + return machine.succeed( + "cd ${setupRepoDir} && find context -mindepth 1 | LC_ALL=C sort" + ).split() + + def assert_setup_baseline(label): + tree = setup_tree() + print(f"{label}: context tree: {tree}") + for path in ${builtins.toJSON setupBaselineDirs}: + machine.succeed(f"test -d ${setupRepoDir}/{path}") + for path in ${builtins.toJSON setupBaselineFiles}: + machine.succeed(f"test -f ${setupRepoDir}/{path}") + expected_tree = sorted(${builtins.toJSON (setupBaselineDirs ++ setupBaselineFiles)}) + assert tree == expected_tree, f"{label}: context tree {tree}, expected {expected_tree}" + return tree + + with subtest("real sce setup --bootstrap-context"): + print(machine.succeed(${prepareScenarioFixture setupScenario})) + machine.succeed(${startScenarioServer setupScenario}) + try: + print(machine.succeed(${installScenarioPackage setupScenario})) + finally: + machine.succeed(${stopScenarioServer setupScenario}) + + print(machine.succeed(${asSetupUser '' + echo "setup PATH: $PATH" + for tool in tar curl gzip bash jq; do + if command -v "$tool" >/dev/null; then + echo "$tool resolves on the setup PATH" >&2 + false + fi + done + git init --quiet ${setupRepoDir} + git -C ${setupRepoDir} remote add origin https://example.invalid/sce-tester/setup.git + mkdir -p ${setupNestedDir} + ''})) + + print(machine.succeed(${asSetupUser '' + cd ${setupRepoDir} + echo "first run cwd: $(pwd)" + ${setupScenario.launcher} setup --bootstrap-context 2>&1 + ''})) + first_tree = assert_setup_baseline("first run") + tmp_gitignore = machine.succeed("cat ${setupRepoDir}/context/tmp/.gitignore") + assert tmp_gitignore == "*\n!.gitignore\n", f"context/tmp/.gitignore is {tmp_gitignore!r}" + + machine.succeed(${asSetupUser '' + printf '%s\n' ${pkgs.lib.escapeShellArg setupSentinel} >>${setupRepoDir}/context/overview.md + ''}) + overview_before = machine.succeed("cat ${setupRepoDir}/context/overview.md") + + print(machine.succeed(${asSetupUser '' + cd ${setupNestedDir} + echo "second run cwd: $(pwd)" + ${setupScenario.launcher} setup --bootstrap-context 2>&1 + ''})) + second_tree = assert_setup_baseline("second run") + assert second_tree == first_tree, f"second run changed the context tree: {first_tree} -> {second_tree}" + print("second run: context tree unchanged") + overview_after = machine.succeed("cat ${setupRepoDir}/context/overview.md") + sentinel = ${builtins.toJSON setupSentinel} + assert sentinel in overview_after, f"sentinel lost from context/overview.md: {overview_after!r}" + assert overview_after == overview_before, f"context/overview.md changed: {overview_before!r} -> {overview_after!r}" + print(f"second run: sentinel preserved: {sentinel!r}") + + repo_root = machine.succeed(${asSetupUser "git -C ${setupNestedDir} rev-parse --show-toplevel"}).strip() + assert repo_root == "${setupRepoDir}", f"nested cwd resolves to repo {repo_root!r}" + repo_entries = machine.succeed("ls -A ${setupRepoDir}").split() + print(f"repo entries: {repo_entries}") + assert sorted(repo_entries) == [".git", "context", "nested"], f"repo holds {repo_entries}" + for cwd in ["${setupRepoDir}", "${setupNestedDir}"]: + assert not cwd.startswith("${npmDir}/"), f"cwd {cwd} is inside the staged npm source" + machine.fail( + "cd ${setupRepoDir}/.. && while :; do" + " [ -e .git ] || [ -e cli/Cargo.toml ] && exit 0;" + " [ \"$PWD\" = / ] && exit 1; cd ..; done" + ) + machine.fail("grep -rlE '${npmDir}|/nix/store' ${setupRepoDir}/context") + print("no SCE source path in the cwd ancestry or the bootstrapped context") + + def assert_minimal_version(label, version_json): + print(f"{label}: version --format json: {version_json.strip()}") + reported_version = json.loads(version_json)["version"] + assert reported_version == ${builtins.toJSON expectedVersion}, f"{label}: sce version reported {reported_version!r}" + print(f"{label}: parsed version: {reported_version}") + + with subtest("minimal runtime dependencies"): + import json + + print(machine.succeed(${prepareScenarioFixture minimalScenario})) + machine.succeed(${startScenarioServer minimalScenario}) + try: + print(machine.succeed(${installScenarioPackage minimalScenario})) + finally: + machine.succeed(${stopScenarioServer minimalScenario}) + + machine.succeed(${asScenarioUser minimalScenario.dir '' + mkdir -p ${minimalNodeBinDir} ${minimalHomeDir} + ln -s ${pkgs.nodejs}/bin/node ${minimalNodeBinDir}/node + ''}) + node_bin_entries = machine.succeed("ls -A ${minimalNodeBinDir}").split() + print(f"launcher PATH entries: {node_bin_entries}") + assert node_bin_entries == ["node"], f"launcher PATH holds {node_bin_entries}, expected only node" + print(machine.succeed(${assertToolsAbsent "launcher PATH" minimalNodeBinDir})) + print(machine.succeed(${assertToolsAbsent "binary PATH" "/var/empty"})) + + machine.succeed("${readelf} -h ${minimalRuntimeBinary} >/dev/null") + program_headers = machine.succeed("${readelf} -lW ${minimalRuntimeBinary}") + assert "INTERP" not in program_headers and "program interpreter" not in program_headers, ( + f"runtime/sce requests an ELF interpreter:\n{program_headers}" + ) + print("runtime/sce: no ELF INTERP program header") + machine.fail("test -e ${fhsLoader}") + print("no FHS loader at ${fhsLoader}") + + machine.succeed(${asScenarioUser minimalScenario.dir "${minimalLauncherEnv} ${minimalScenario.launcher} --help >/dev/null"}) + print("launcher: --help under env -i with only node on PATH") + assert_minimal_version("launcher", machine.succeed( + ${asScenarioUser minimalScenario.dir "${minimalLauncherEnv} ${minimalScenario.launcher} version --format json"} + )) + machine.succeed(${asScenarioUser minimalScenario.dir "${minimalBinaryEnv} ${minimalRuntimeBinary} --help >/dev/null"}) + print("runtime/sce: --help under env -i with PATH=/var/empty") + assert_minimal_version("runtime/sce", machine.succeed( + ${asScenarioUser minimalScenario.dir "${minimalBinaryEnv} ${minimalRuntimeBinary} version --format json"} + )) ''; } diff --git a/npm/test/pack-smoke.sh b/npm/test/pack-smoke.sh index 9d98c5a3c..ea58a8a5a 100755 --- a/npm/test/pack-smoke.sh +++ b/npm/test/pack-smoke.sh @@ -2,12 +2,11 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=npm/test/release-fixture.sh +source "$SCRIPT_DIR/release-fixture.sh" +FIXTURE_LOG_PREFIX="pack-smoke" + PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -LAUNCHER_ENTRY="package/bin/sce.js" -SERVER_START_TIMEOUT_SECONDS=10 -INSTALLED_PACKAGE_DIR="node_modules/@crocoder-dev/sce" -EXPECTED_RUNTIME_MODE="755" -EXPECTED_HELP_OUTPUT="sce fixture help" SOURCE_TARBALL="" SOURCE_BINARY="" EXPECTED_VERSION="" @@ -35,12 +34,14 @@ installed CLI must exit 0 for `sce --help` and report the expected version via --prefix installs into the given directory and keeps it after the run instead of using a temporary prefix. + +The release fixture is built by release-fixture.sh with a throwaway signing +key, so this smoke does not prove the exact release artifact or production key. EOF } fail() { - printf 'pack-smoke: %s\n' "$1" >&2 - exit 1 + fixture_fail "$1" } while [[ $# -gt 0 ]]; do @@ -84,22 +85,15 @@ while [[ $# -gt 0 ]]; do esac done -for tool in node npm openssl tar gzip sha256sum; do - command -v "$tool" >/dev/null 2>&1 || - fail "Required tool '$tool' is not on PATH. Run via: nix shell nixpkgs#nodejs nixpkgs#openssl -c bash $0" -done +fixture_require_tools if [[ -z "$SOURCE_TARBALL" ]]; then [[ -f "$PACKAGE_DIR/package.json" ]] || fail "No package.json found in $PACKAGE_DIR." fi work_dir="$(mktemp -d "${TMPDIR:-/tmp}/sce-npm-pack-smoke.XXXXXX")" -server_pid="" cleanup() { - if [[ -n "$server_pid" ]]; then - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - fi + fixture_serve_stop rm -rf "$work_dir" } trap cleanup EXIT @@ -111,153 +105,24 @@ prefix_dir="${PREFIX_DIR:-$work_dir/prefix}" keys_dir="$work_dir/keys" mkdir -p "$stage_dir" "$release_dir" "$pack_dir" "$prefix_dir" "$keys_dir" -assert_launcher_mode() { - local tarball="$1" - local launcher_mode - launcher_mode="$(tar -tvzf "$tarball" "$LAUNCHER_ENTRY" | cut -d ' ' -f 1)" || - fail "Packed tarball $tarball is missing $LAUNCHER_ENTRY." - [[ -n "$launcher_mode" ]] || fail "Packed tarball $tarball is missing $LAUNCHER_ENTRY." - if [[ "${launcher_mode:3:1}" != "x" ]]; then - fail "Packed $LAUNCHER_ENTRY has mode $launcher_mode; expected an executable mode (e.g. -rwxr-xr-x). Ensure npm/bin/sce.js is committed as Git mode 100755." - fi - printf 'pack-smoke: packed %s mode %s\n' "$LAUNCHER_ENTRY" "$launcher_mode" -} - -if [[ -n "$SOURCE_TARBALL" ]]; then - assert_launcher_mode "$SOURCE_TARBALL" - tar -xzf "$SOURCE_TARBALL" -C "$stage_dir" --strip-components=1 --no-same-owner - [[ -f "$stage_dir/package.json" ]] || fail "Tarball $SOURCE_TARBALL has no package/package.json." -else - cp -R "$PACKAGE_DIR/." "$stage_dir/" -fi -rm -rf "$stage_dir/runtime" "$stage_dir/node_modules" - -version="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).version" "$stage_dir/package.json")" -platform_json="$( - node --input-type=module -e ' - const { resolveSupportedPlatform } = await import(process.argv[1]); - const platform = resolveSupportedPlatform(); - if (!platform) process.exit(2); - console.log(JSON.stringify(platform)); - ' "$stage_dir/lib/platform.js" -)" || fail "Host platform $(node -p 'process.platform + "/" + process.arch') is not supported by the npm package." -target_triple="$(node -p "JSON.parse(process.argv[1]).targetTriple" "$platform_json")" -target_os="$(node -p "JSON.parse(process.argv[1]).os" "$platform_json")" -target_arch="$(node -p "JSON.parse(process.argv[1]).arch" "$platform_json")" - +fixture_stage_package "$stage_dir" "$PACKAGE_DIR" "$SOURCE_TARBALL" +fixture_resolve_platform "$stage_dir" +version="$FIXTURE_VERSION" expected_version="${EXPECTED_VERSION:-$version}" -printf 'pack-smoke: version %s, target %s\n' "$version" "$target_triple" - -openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$keys_dir/signing.pem" 2>/dev/null -openssl pkey -in "$keys_dir/signing.pem" -pubout -out "$stage_dir/lib/release-manifest-public-key.pem" - -openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ - -subj "/CN=127.0.0.1" \ - -addext "subjectAltName=IP:127.0.0.1" \ - -keyout "$keys_dir/tls-key.pem" -out "$keys_dir/tls-cert.pem" 2>/dev/null - -archive_root="sce-v${version}-${target_triple}" -archive_name="${archive_root}.tar.gz" -manifest_name="sce-v${version}-release-manifest.json" -archive_src="$work_dir/archive-src" -mkdir -p "$archive_src/$archive_root/bin" -if [[ -n "$SOURCE_BINARY" ]]; then - printf 'pack-smoke: serving real binary %s\n' "$SOURCE_BINARY" - cp "$SOURCE_BINARY" "$archive_src/$archive_root/bin/sce" -else - cat >"$archive_src/$archive_root/bin/sce" <&2; exit 64 ;; -esac -EOF -fi -chmod 0755 "$archive_src/$archive_root/bin/sce" -printf 'fixture license\n' >"$archive_src/$archive_root/LICENSE" -printf 'fixture readme\n' >"$archive_src/$archive_root/README.md" -tar -C "$archive_src" --owner=0 --group=0 --numeric-owner -czf "$release_dir/$archive_name" "$archive_root" - -checksum="$(sha256sum "$release_dir/$archive_name" | cut -d ' ' -f 1)" -# shellcheck disable=SC2016 # JavaScript template literal, not a shell expansion. -node -e ' - const [version, archive, checksum, target_triple, os, arch, output] = process.argv.slice(1); - const manifest = { - version, - binary: "sce", - artifacts: [ - { - version, - binary: "sce", - archive, - checksum_file: `${archive}.sha256`, - checksum_sha256: checksum, - target_triple, - os, - arch, - }, - ], - }; - require("fs").writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`); -' "$version" "$archive_name" "$checksum" "$target_triple" "$target_os" "$target_arch" "$release_dir/$manifest_name" -openssl dgst -sha256 -sign "$keys_dir/signing.pem" "$release_dir/$manifest_name" | - openssl base64 -A >"$release_dir/$manifest_name.sig" - -packed_name="$(cd "$pack_dir" && npm pack --silent "$stage_dir")" -tarball="$pack_dir/$packed_name" -assert_launcher_mode "$tarball" - -port_file="$work_dir/server-port" -node "$SCRIPT_DIR/fixture-release-server.mjs" \ - --root "$release_dir" \ - --cert "$keys_dir/tls-cert.pem" \ - --key "$keys_dir/tls-key.pem" \ - --port-file "$port_file" & -server_pid=$! - -for ((attempt = 0; attempt < SERVER_START_TIMEOUT_SECONDS * 10; attempt++)); do - [[ -s "$port_file" ]] && break - kill -0 "$server_pid" 2>/dev/null || fail "Fixture release server exited before listening." - sleep 0.1 -done -[[ -s "$port_file" ]] || fail "Fixture release server did not start within ${SERVER_START_TIMEOUT_SECONDS}s." -port="$(tr -d '\n' <"$port_file")" - -SCE_NPM_RELEASE_BASE_URL="https://127.0.0.1:${port}" \ - NODE_EXTRA_CA_CERTS="$keys_dir/tls-cert.pem" \ - npm_config_cache="$work_dir/npm-cache" \ - npm_config_userconfig="$work_dir/npmrc" \ - npm install \ - --prefix "$prefix_dir" \ - --offline \ - --no-audit \ - --no-fund \ - --foreground-scripts \ - "$tarball" || - fail "npm install of the packed tarball failed." - -installed_dir="$prefix_dir/$INSTALLED_PACKAGE_DIR" -runtime_bin="$installed_dir/runtime/sce" -[[ -f "$runtime_bin" ]] || fail "Installer did not produce $runtime_bin." -runtime_mode="$(node -p "(require('fs').statSync(process.argv[1]).mode & 0o777).toString(8)" "$runtime_bin")" -[[ "$runtime_mode" == "$EXPECTED_RUNTIME_MODE" ]] || - fail "Installed $runtime_bin has mode $runtime_mode; expected $EXPECTED_RUNTIME_MODE." -printf 'pack-smoke: installed runtime/sce mode %s\n' "$runtime_mode" +printf 'pack-smoke: version %s, target %s\n' "$version" "$FIXTURE_TARGET_TRIPLE" -sce_bin="$prefix_dir/node_modules/.bin/sce" -[[ -x "$sce_bin" ]] || fail "Installed sce bin $sce_bin is not executable." -sce_bin_target="$(realpath "$sce_bin")" -expected_bin_target="$(realpath "$installed_dir/bin/sce.js")" -[[ "$sce_bin_target" == "$expected_bin_target" ]] || - fail "Installed sce bin resolves to $sce_bin_target; expected $expected_bin_target." -printf 'pack-smoke: .bin/sce resolves to %s\n' "$sce_bin_target" +fixture_generate_keys "$stage_dir" "$keys_dir" +fixture_build_release "$release_dir" "$keys_dir" "$work_dir" "$SOURCE_BINARY" +fixture_pack "$stage_dir" "$pack_dir" +fixture_serve_start "$release_dir" "$keys_dir" "$work_dir/server-port" +fixture_npm_install "$prefix_dir" "$FIXTURE_TARBALL" "$FIXTURE_PORT" "$keys_dir/tls-cert.pem" "$work_dir" +fixture_assert_install_layout "$prefix_dir" -sce_cmd=("$sce_bin") +sce_cmd=("$FIXTURE_SCE_BIN") if [[ ! -x /usr/bin/env ]]; then printf 'pack-smoke: /usr/bin/env unavailable; running installed bin through node\n' - sce_cmd=(node "$sce_bin") + sce_cmd=(node "$FIXTURE_SCE_BIN") fi help_output="$("${sce_cmd[@]}" --help)" || fail "sce --help exited non-zero." @@ -274,8 +139,8 @@ if [[ -n "$SOURCE_BINARY" ]]; then exit 0 fi -[[ "$help_output" == "$EXPECTED_HELP_OUTPUT" ]] || - fail "sce --help printed '$help_output'; expected '$EXPECTED_HELP_OUTPUT'." +[[ "$help_output" == "$FIXTURE_STUB_HELP_OUTPUT" ]] || + fail "sce --help printed '$help_output'; expected '$FIXTURE_STUB_HELP_OUTPUT'." version_output="$("${sce_cmd[@]}" version)" || fail "sce version exited non-zero." [[ "$version_output" == "sce $expected_version" ]] || diff --git a/npm/test/release-canary.sh b/npm/test/release-canary.sh new file mode 100755 index 000000000..615a52c10 --- /dev/null +++ b/npm/test/release-canary.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/release-fixture.sh" +FIXTURE_LOG_PREFIX="release-canary" + +REPOSITORY_SLUG="crocoder-dev/shared-context-engineering" +NPM_PACKAGE_NAME="@crocoder-dev/sce" +REPOSITORY_PUBLIC_KEY="$SCRIPT_DIR/../lib/release-manifest-public-key.pem" +PACKED_PUBLIC_KEY_ENTRY="package/lib/release-manifest-public-key.pem" +PACKED_PACKAGE_JSON_ENTRY="package/package.json" +VERSION_PATTERN='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' +DOWNLOAD_TIMEOUT_MS=60000 +DOWNLOAD_ATTEMPTS=3 +DOWNLOAD_RETRY_SECONDS=5 +REGISTRY_ATTEMPTS=10 +REGISTRY_RETRY_SECONDS=30 + +MODE="" +RELEASE_DIR="" +SOURCE_TARBALL="" +GITHUB_TAG="" +REGISTRY_VERSION="" +EXPECTED_VERSION="" +EXPECTED_SHA256="" + +usage() { + cat <<'EOF' +Usage: release-canary.sh --expected-version [--expected-sha256 ] + ( --release-dir --tarball + | --github-release + | --registry-version [--tarball ] ) + +Installs the exact, unmodified sce-v-npm.tgz with its bundled +production public key and verifies it. Nothing is re-signed or repacked. + +--release-dir serves the real release manifest, signature, and archives from + over loopback HTTPS and installs against them (pre-release gate). + +--github-release downloads sce-v-npm.tgz and the signed manifest from +the published GitHub Release and installs with no installer overrides. + +--registry-version compares the exact registry tarball and its dist.integrity +with the GitHub Release tgz (or --tarball), with bounded retries, then installs +that exact version from the registry with no installer overrides. + +Every mode asserts the tarball SHA-256 is unchanged by the run (and equals +--expected-sha256 when given), the bundled public key is byte-identical to the +checked-in npm/lib/release-manifest-public-key.pem, the release manifest +signature verifies against it, the installed launcher, .bin/sce, and +runtime/sce modes, and that sce reports --expected-version. +EOF +} + +fail() { + fixture_fail "$1" +} + +require_version() { + local label="$1" + local value="$2" + + [[ "$value" =~ $VERSION_PATTERN ]] || + fail "$label '$value' is not an exact version; dist-tags and ranges are not resolved." +} + +set_mode() { + [[ -z "$MODE" ]] || fail "Choose exactly one of --release-dir, --github-release, or --registry-version." + MODE="$1" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --release-dir) + [[ -n "${2:-}" ]] || fail "Missing value for --release-dir." + [[ -d "$2" ]] || fail "Release directory $2 does not exist." + set_mode local + RELEASE_DIR="$(cd "$2" && pwd)" + shift 2 + ;; + --tarball) + [[ -n "${2:-}" ]] || fail "Missing value for --tarball." + [[ -f "$2" ]] || fail "Tarball $2 does not exist." + SOURCE_TARBALL="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" + shift 2 + ;; + --github-release) + [[ -n "${2:-}" ]] || fail "Missing value for --github-release." + set_mode github + GITHUB_TAG="$2" + shift 2 + ;; + --registry-version) + [[ -n "${2:-}" ]] || fail "Missing value for --registry-version." + set_mode registry + REGISTRY_VERSION="$2" + shift 2 + ;; + --expected-version) + [[ -n "${2:-}" ]] || fail "Missing value for --expected-version." + EXPECTED_VERSION="$2" + shift 2 + ;; + --expected-sha256) + [[ -n "${2:-}" ]] || fail "Missing value for --expected-sha256." + EXPECTED_SHA256="$2" + shift 2 + ;; + --help | -h) + usage + exit 0 + ;; + *) + usage >&2 + fail "Unknown argument: $1" + ;; + esac +done + +[[ -n "$MODE" ]] || { + usage >&2 + fail "Choose exactly one of --release-dir, --github-release, or --registry-version." +} +[[ -n "$EXPECTED_VERSION" ]] || fail "Missing --expected-version." +require_version "--expected-version" "$EXPECTED_VERSION" +case "$MODE" in + local) + [[ -n "$SOURCE_TARBALL" ]] || fail "--release-dir requires --tarball." + ;; + github) + [[ -z "$SOURCE_TARBALL" ]] || fail "--github-release downloads the tarball; do not pass --tarball." + [[ "$GITHUB_TAG" == "v$EXPECTED_VERSION" ]] || + fail "GitHub release tag '$GITHUB_TAG' does not match --expected-version (expected 'v$EXPECTED_VERSION')." + ;; + registry) + require_version "--registry-version" "$REGISTRY_VERSION" + [[ "$REGISTRY_VERSION" == "$EXPECTED_VERSION" ]] || + fail "--registry-version '$REGISTRY_VERSION' does not match --expected-version '$EXPECTED_VERSION'." + ;; +esac +if [[ -n "$EXPECTED_SHA256" && ! "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]]; then + fail "--expected-sha256 must be 64 lowercase hex characters." +fi + +fixture_require_tools +[[ -f "$REPOSITORY_PUBLIC_KEY" ]] || fail "Checked-in public key $REPOSITORY_PUBLIC_KEY does not exist." + +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/sce-npm-release-canary.XXXXXX")" +cleanup() { + fixture_serve_stop + rm -rf "$work_dir" +} +trap cleanup EXIT + +download_dir="$work_dir/download" +prefix_dir="$work_dir/prefix" +keys_dir="$work_dir/keys" +mkdir -p "$download_dir" "$prefix_dir" "$keys_dir" "$work_dir/home" "$work_dir/tmp" + +tarball_name="sce-v${EXPECTED_VERSION}-npm.tgz" +manifest_name="sce-v${EXPECTED_VERSION}-release-manifest.json" +release_base_url="https://github.com/${REPOSITORY_SLUG}/releases/download/v${EXPECTED_VERSION}" + +clean_env=( + env -i + "PATH=$PATH" + "HOME=$work_dir/home" + "TMPDIR=$work_dir/tmp" + "npm_config_cache=$work_dir/npm-cache" + "npm_config_userconfig=$work_dir/npmrc" +) + +sha256_of() { + sha256sum "$1" | cut -d ' ' -f 1 +} + +sha512_integrity_of() { + printf 'sha512-%s' "$(openssl dgst -sha512 -binary "$1" | openssl base64 -A)" +} + +retry() { + local attempts="$1" + local seconds="$2" + local attempt + shift 2 + + for ((attempt = 1; attempt <= attempts; attempt++)); do + "$@" && return 0 + if ((attempt < attempts)); then + fixture_log "attempt $attempt/$attempts failed; retrying in ${seconds}s" + sleep "$seconds" + fi + done + return 1 +} + +download() { + local url="$1" + local output="$2" + + "${clean_env[@]}" node --input-type=module -e ' + import { writeFileSync } from "node:fs"; + const [url, output, timeoutMs] = process.argv.slice(1); + const response = await fetch(url, { signal: AbortSignal.timeout(Number(timeoutMs)) }); + if (!response.ok) { + console.error("GET " + url + " returned HTTP " + response.status); + process.exit(1); + } + writeFileSync(output, Buffer.from(await response.arrayBuffer())); + ' "$url" "$output" "$DOWNLOAD_TIMEOUT_MS" +} + +download_release_asset() { + local name="$1" + + retry "$DOWNLOAD_ATTEMPTS" "$DOWNLOAD_RETRY_SECONDS" download "$release_base_url/$name" "$download_dir/$name" || + fail "Could not download $release_base_url/$name after $DOWNLOAD_ATTEMPTS attempts." + fixture_log "downloaded $release_base_url/$name" +} + +assert_tarball_identity() { + local tarball="$1" + local packed_name packed_version packed_launcher_mode + + packed_name="$(tar -xzOf "$tarball" "$PACKED_PACKAGE_JSON_ENTRY" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).name')" || + fail "Tarball $tarball has no readable $PACKED_PACKAGE_JSON_ENTRY." + packed_version="$(tar -xzOf "$tarball" "$PACKED_PACKAGE_JSON_ENTRY" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version')" + [[ "$packed_name" == "$NPM_PACKAGE_NAME" ]] || + fail "Packed package name '$packed_name' does not match '$NPM_PACKAGE_NAME'." + [[ "$packed_version" == "$EXPECTED_VERSION" ]] || + fail "Packed package version '$packed_version' does not match --expected-version '$EXPECTED_VERSION'." + fixture_log "packed package $packed_name@$packed_version" + + packed_launcher_mode="$(tar -tvzf "$tarball" "$FIXTURE_LAUNCHER_ENTRY" | cut -d ' ' -f 1)" + [[ -n "$packed_launcher_mode" ]] || fail "Tarball $tarball is missing $FIXTURE_LAUNCHER_ENTRY." + fixture_log "packed $FIXTURE_LAUNCHER_ENTRY mode $packed_launcher_mode" +} + +assert_bundled_public_key() { + local tarball="$1" + local bundled_key="$work_dir/bundled-public-key.pem" + local bundled_sha repository_sha + + tar -xzOf "$tarball" "$PACKED_PUBLIC_KEY_ENTRY" >"$bundled_key" || + fail "Tarball $tarball is missing $PACKED_PUBLIC_KEY_ENTRY." + bundled_sha="$(sha256_of "$bundled_key")" + repository_sha="$(sha256_of "$REPOSITORY_PUBLIC_KEY")" + [[ "$bundled_sha" == "$repository_sha" ]] || + fail "Bundled $PACKED_PUBLIC_KEY_ENTRY (sha256 $bundled_sha) is not byte-identical to the checked-in npm/lib/release-manifest-public-key.pem (sha256 $repository_sha)." + fixture_log "bundled public key byte-identical to checked-in key (sha256 $bundled_sha)" +} + +assert_manifest_signature() { + local manifest="$1" + local signature="$2" + local manifest_version + + [[ -f "$manifest" ]] || fail "Release manifest $manifest does not exist." + [[ -f "$signature" ]] || fail "Release manifest signature $signature does not exist." + openssl base64 -d -A -in "$signature" -out "$work_dir/manifest.sig.bin" || + fail "Release manifest signature $signature is not valid base64." + openssl dgst -sha256 -verify "$REPOSITORY_PUBLIC_KEY" -signature "$work_dir/manifest.sig.bin" "$manifest" >/dev/null || + fail "Release manifest $manifest signature does not verify against the checked-in public key." + manifest_version="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).version" "$manifest")" + [[ "$manifest_version" == "$EXPECTED_VERSION" ]] || + fail "Release manifest version '$manifest_version' does not match --expected-version '$EXPECTED_VERSION'." + fixture_log "release manifest $(basename "$manifest") verified with the checked-in public key (sha256 $(sha256_of "$manifest"))" +} + +fetch_registry_tarball() { + local registry_dir="$work_dir/registry" + local packed_name + + rm -rf "$registry_dir" + mkdir -p "$registry_dir" + registry_integrity="$("${clean_env[@]}" npm view "$NPM_PACKAGE_NAME@$REGISTRY_VERSION" dist.integrity)" || return 1 + [[ -n "$registry_integrity" ]] || return 1 + packed_name="$(cd "$registry_dir" && "${clean_env[@]}" npm pack --silent "$NPM_PACKAGE_NAME@$REGISTRY_VERSION")" || return 1 + registry_tarball="$registry_dir/$packed_name" + [[ -f "$registry_tarball" ]] +} + +install_remote() { + local spec="$1" + + "${clean_env[@]}" npm install \ + --prefix "$prefix_dir" \ + --no-audit \ + --no-fund \ + --foreground-scripts \ + "$spec" || + fail "npm install of $spec failed." +} + +case "$MODE" in + local) + tarball="$SOURCE_TARBALL" + manifest="$RELEASE_DIR/$manifest_name" + ;; + github | registry) + download_release_asset "$manifest_name" + download_release_asset "$manifest_name.sig" + manifest="$download_dir/$manifest_name" + if [[ -n "$SOURCE_TARBALL" ]]; then + tarball="$SOURCE_TARBALL" + else + download_release_asset "$tarball_name" + tarball="$download_dir/$tarball_name" + fi + ;; +esac + +tarball_sha_before="$(sha256_of "$tarball")" +fixture_log "tarball $(basename "$tarball") sha256 $tarball_sha_before" +if [[ -n "$EXPECTED_SHA256" ]]; then + [[ "$tarball_sha_before" == "$EXPECTED_SHA256" ]] || + fail "Tarball sha256 $tarball_sha_before does not match --expected-sha256 $EXPECTED_SHA256." +fi + +assert_tarball_identity "$tarball" +assert_bundled_public_key "$tarball" +assert_manifest_signature "$manifest" "$manifest.sig" + +case "$MODE" in + local) + fixture_generate_tls_cert "$keys_dir" + fixture_serve_start "$RELEASE_DIR" "$keys_dir" "$work_dir/server-port" + fixture_npm_install "$prefix_dir" "$tarball" "$FIXTURE_PORT" "$keys_dir/tls-cert.pem" "$work_dir" + fixture_serve_stop + ;; + github) + install_remote "$tarball" + ;; + registry) + registry_integrity="" + registry_tarball="" + retry "$REGISTRY_ATTEMPTS" "$REGISTRY_RETRY_SECONDS" fetch_registry_tarball || + fail "Registry did not serve $NPM_PACKAGE_NAME@$REGISTRY_VERSION after $REGISTRY_ATTEMPTS attempts." + github_integrity="$(sha512_integrity_of "$tarball")" + [[ "$registry_integrity" == "$github_integrity" ]] || + fail "Registry dist.integrity $registry_integrity does not match the GitHub tarball integrity $github_integrity." + [[ "$(sha512_integrity_of "$registry_tarball")" == "$github_integrity" ]] || + fail "Registry tarball bytes for $NPM_PACKAGE_NAME@$REGISTRY_VERSION differ from the GitHub tarball." + fixture_log "registry tarball byte-identical to the GitHub tarball ($registry_integrity)" + install_remote "$NPM_PACKAGE_NAME@$REGISTRY_VERSION" + locked_integrity="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).packages['node_modules/$NPM_PACKAGE_NAME'].integrity" "$prefix_dir/package-lock.json")" + [[ "$locked_integrity" == "$registry_integrity" ]] || + fail "Installed $NPM_PACKAGE_NAME integrity $locked_integrity does not match the registry dist.integrity $registry_integrity." + fixture_log "installed $NPM_PACKAGE_NAME@$REGISTRY_VERSION from the registry with integrity $locked_integrity" + ;; +esac + +fixture_assert_install_layout "$prefix_dir" +installed_launcher="$prefix_dir/$FIXTURE_INSTALLED_PACKAGE_DIR/bin/sce.js" +[[ -x "$installed_launcher" ]] || fail "Installed launcher $installed_launcher is not executable." +fixture_log "installed bin/sce.js mode $(node -p "(require('fs').statSync(process.argv[1]).mode & 0o777).toString(8)" "$installed_launcher")" + +"${clean_env[@]}" "$FIXTURE_SCE_BIN" --help >/dev/null || fail "sce --help exited non-zero." +version_json="$("${clean_env[@]}" "$FIXTURE_SCE_BIN" version --format json)" || + fail "sce version --format json exited non-zero." +reported_version="$(node -p "JSON.parse(process.argv[1]).version" "$version_json")" || + fail "sce version --format json printed invalid JSON: $version_json" +[[ "$reported_version" == "$EXPECTED_VERSION" ]] || + fail "sce version reported '$reported_version'; expected '$EXPECTED_VERSION'." +fixture_log "sce reported version $reported_version" + +tarball_sha_after="$(sha256_of "$tarball")" +[[ "$tarball_sha_after" == "$tarball_sha_before" ]] || + fail "Tarball sha256 changed during the run: $tarball_sha_before before, $tarball_sha_after after." +fixture_log "tarball sha256 unchanged after the run ($tarball_sha_after)" + +printf 'release-canary: OK (%s mode: %s@%s, tarball sha256 %s)\n' "$MODE" "$NPM_PACKAGE_NAME" "$EXPECTED_VERSION" "$tarball_sha_after" diff --git a/npm/test/release-fixture.sh b/npm/test/release-fixture.sh new file mode 100755 index 000000000..719b6da5e --- /dev/null +++ b/npm/test/release-fixture.sh @@ -0,0 +1,391 @@ +#!/usr/bin/env bash +# Shared hermetic release-fixture harness for npm package tests. +# +# Sourced as a library (pack-smoke.sh, VM scenarios, release canary) or run +# directly: +# +# release-fixture.sh prepare [--package-dir | --tarball ] +# [--binary ] +# release-fixture.sh serve [--port-file ] +# +# The fixture swaps a throwaway signing key into the staged package and +# repacks it, so it never proves the exact release artifact or production key. +set -euo pipefail + +FIXTURE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIXTURE_LAUNCHER_ENTRY="package/bin/sce.js" +FIXTURE_SERVER_START_TIMEOUT_SECONDS=10 +FIXTURE_INSTALLED_PACKAGE_DIR="node_modules/@crocoder-dev/sce" +FIXTURE_EXPECTED_RUNTIME_MODE="755" +FIXTURE_STUB_HELP_OUTPUT="sce fixture help" +FIXTURE_ENV_FILE_NAME="fixture.env" +FIXTURE_LOG_PREFIX="${FIXTURE_LOG_PREFIX:-release-fixture}" +FIXTURE_SERVER_PID="" + +fixture_log() { + printf '%s: %s\n' "$FIXTURE_LOG_PREFIX" "$1" +} + +fixture_fail() { + printf '%s: %s\n' "$FIXTURE_LOG_PREFIX" "$1" >&2 + exit 1 +} + +fixture_require_tools() { + local tool + for tool in node npm openssl tar gzip sha256sum; do + command -v "$tool" >/dev/null 2>&1 || + fixture_fail "Required tool '$tool' is not on PATH. Run via: nix shell nixpkgs#nodejs nixpkgs#openssl -c bash $0" + done +} + +fixture_assert_launcher_mode() { + local tarball="$1" + local launcher_mode + launcher_mode="$(tar -tvzf "$tarball" "$FIXTURE_LAUNCHER_ENTRY" | cut -d ' ' -f 1)" || + fixture_fail "Packed tarball $tarball is missing $FIXTURE_LAUNCHER_ENTRY." + [[ -n "$launcher_mode" ]] || fixture_fail "Packed tarball $tarball is missing $FIXTURE_LAUNCHER_ENTRY." + if [[ "${launcher_mode:3:1}" != "x" ]]; then + fixture_fail "Packed $FIXTURE_LAUNCHER_ENTRY has mode $launcher_mode; expected an executable mode (e.g. -rwxr-xr-x). Ensure npm/bin/sce.js is committed as Git mode 100755." + fi + fixture_log "packed $FIXTURE_LAUNCHER_ENTRY mode $launcher_mode" +} + +# Stages package contents into from a packed tarball (asserting its +# launcher mode first) or, when is empty, from . +fixture_stage_package() { + local stage_dir="$1" + local package_dir="$2" + local tarball="$3" + + if [[ -n "$tarball" ]]; then + fixture_assert_launcher_mode "$tarball" + tar -xzf "$tarball" -C "$stage_dir" --strip-components=1 --no-same-owner + [[ -f "$stage_dir/package.json" ]] || fixture_fail "Tarball $tarball has no package/package.json." + else + [[ -f "$package_dir/package.json" ]] || fixture_fail "No package.json found in $package_dir." + cp -R "$package_dir/." "$stage_dir/" + fi + rm -rf "$stage_dir/runtime" "$stage_dir/node_modules" +} + +# Sets FIXTURE_VERSION and FIXTURE_TARGET_{TRIPLE,OS,ARCH} from the staged +# package.json and the package's own platform resolution. +fixture_resolve_platform() { + local stage_dir="$1" + local platform_json + + FIXTURE_VERSION="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).version" "$stage_dir/package.json")" + platform_json="$( + node --input-type=module -e ' + const { resolveSupportedPlatform } = await import(process.argv[1]); + const platform = resolveSupportedPlatform(); + if (!platform) process.exit(2); + console.log(JSON.stringify(platform)); + ' "$stage_dir/lib/platform.js" + )" || fixture_fail "Host platform $(node -p 'process.platform + "/" + process.arch') is not supported by the npm package." + FIXTURE_TARGET_TRIPLE="$(node -p "JSON.parse(process.argv[1]).targetTriple" "$platform_json")" + FIXTURE_TARGET_OS="$(node -p "JSON.parse(process.argv[1]).os" "$platform_json")" + FIXTURE_TARGET_ARCH="$(node -p "JSON.parse(process.argv[1]).arch" "$platform_json")" +} + +# Generates a throwaway manifest signing key (its public half replaces the +# staged package key) and a self-signed loopback TLS certificate. +fixture_generate_keys() { + local stage_dir="$1" + local keys_dir="$2" + + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$keys_dir/signing.pem" 2>/dev/null + openssl pkey -in "$keys_dir/signing.pem" -pubout -out "$stage_dir/lib/release-manifest-public-key.pem" + + fixture_generate_tls_cert "$keys_dir" +} + +fixture_generate_tls_cert() { + local keys_dir="$1" + + openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj "/CN=127.0.0.1" \ + -addext "subjectAltName=IP:127.0.0.1" \ + -keyout "$keys_dir/tls-key.pem" -out "$keys_dir/tls-cert.pem" 2>/dev/null +} + +# Builds the canonical archive (stub binary, or when given), its +# checksum, and the signed release manifest in . +fixture_build_release() { + local release_dir="$1" + local keys_dir="$2" + local scratch_dir="$3" + local binary="$4" + local version="$FIXTURE_VERSION" + local archive_root="sce-v${version}-${FIXTURE_TARGET_TRIPLE}" + local archive_name="${archive_root}.tar.gz" + local manifest_name="sce-v${version}-release-manifest.json" + local archive_src="$scratch_dir/archive-src" + local checksum + + mkdir -p "$archive_src/$archive_root/bin" + if [[ -n "$binary" ]]; then + fixture_log "serving real binary $binary" + cp "$binary" "$archive_src/$archive_root/bin/sce" + else + cat >"$archive_src/$archive_root/bin/sce" <&2; exit 64 ;; +esac +EOF + fi + chmod 0755 "$archive_src/$archive_root/bin/sce" + printf 'fixture license\n' >"$archive_src/$archive_root/LICENSE" + printf 'fixture readme\n' >"$archive_src/$archive_root/README.md" + tar -C "$archive_src" --owner=0 --group=0 --numeric-owner -czf "$release_dir/$archive_name" "$archive_root" + + checksum="$(sha256sum "$release_dir/$archive_name" | cut -d ' ' -f 1)" + # shellcheck disable=SC2016 # JavaScript template literal, not a shell expansion. + node -e ' + const [version, archive, checksum, target_triple, os, arch, output] = process.argv.slice(1); + const manifest = { + version, + binary: "sce", + artifacts: [ + { + version, + binary: "sce", + archive, + checksum_file: `${archive}.sha256`, + checksum_sha256: checksum, + target_triple, + os, + arch, + }, + ], + }; + require("fs").writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`); + ' "$version" "$archive_name" "$checksum" "$FIXTURE_TARGET_TRIPLE" "$FIXTURE_TARGET_OS" "$FIXTURE_TARGET_ARCH" "$release_dir/$manifest_name" + openssl dgst -sha256 -sign "$keys_dir/signing.pem" "$release_dir/$manifest_name" | + openssl base64 -A >"$release_dir/$manifest_name.sig" +} + +# Packs into , asserts the launcher mode, and sets +# FIXTURE_TARBALL. +fixture_pack() { + local stage_dir="$1" + local pack_dir="$2" + local packed_name + + packed_name="$(cd "$pack_dir" && npm pack --silent "$stage_dir")" + FIXTURE_TARBALL="$pack_dir/$packed_name" + fixture_assert_launcher_mode "$FIXTURE_TARBALL" +} + +# Starts fixture-release-server.mjs in the background, waits for its port +# file, and sets FIXTURE_SERVER_PID and FIXTURE_PORT. Callers own cleanup via +# fixture_serve_stop. +fixture_serve_start() { + local release_dir="$1" + local keys_dir="$2" + local port_file="$3" + local attempt + + node "$FIXTURE_SCRIPT_DIR/fixture-release-server.mjs" \ + --root "$release_dir" \ + --cert "$keys_dir/tls-cert.pem" \ + --key "$keys_dir/tls-key.pem" \ + --port-file "$port_file" & + FIXTURE_SERVER_PID=$! + + for ((attempt = 0; attempt < FIXTURE_SERVER_START_TIMEOUT_SECONDS * 10; attempt++)); do + [[ -s "$port_file" ]] && break + kill -0 "$FIXTURE_SERVER_PID" 2>/dev/null || fixture_fail "Fixture release server exited before listening." + sleep 0.1 + done + [[ -s "$port_file" ]] || fixture_fail "Fixture release server did not start within ${FIXTURE_SERVER_START_TIMEOUT_SECONDS}s." + # shellcheck disable=SC2034 # Consumed by sourcing callers. + FIXTURE_PORT="$(tr -d '\n' <"$port_file")" +} + +fixture_serve_stop() { + if [[ -n "$FIXTURE_SERVER_PID" ]]; then + kill "$FIXTURE_SERVER_PID" 2>/dev/null || true + wait "$FIXTURE_SERVER_PID" 2>/dev/null || true + FIXTURE_SERVER_PID="" + fi +} + +# Installs into offline against the loopback fixture +# server at , trusting ; npm cache/config live in . +fixture_npm_install() { + local prefix_dir="$1" + local tarball="$2" + local port="$3" + local tls_cert="$4" + local npm_state_dir="$5" + + SCE_NPM_RELEASE_BASE_URL="https://127.0.0.1:${port}" \ + NODE_EXTRA_CA_CERTS="$tls_cert" \ + npm_config_cache="$npm_state_dir/npm-cache" \ + npm_config_userconfig="$npm_state_dir/npmrc" \ + npm install \ + --prefix "$prefix_dir" \ + --offline \ + --no-audit \ + --no-fund \ + --foreground-scripts \ + "$tarball" || + fixture_fail "npm install of the packed tarball failed." +} + +# Asserts the installed runtime/sce mode and the .bin/sce symlink target, and +# sets FIXTURE_SCE_BIN. +fixture_assert_install_layout() { + local prefix_dir="$1" + local installed_dir="$prefix_dir/$FIXTURE_INSTALLED_PACKAGE_DIR" + local runtime_bin="$installed_dir/runtime/sce" + local runtime_mode sce_bin_target expected_bin_target + + [[ -f "$runtime_bin" ]] || fixture_fail "Installer did not produce $runtime_bin." + runtime_mode="$(node -p "(require('fs').statSync(process.argv[1]).mode & 0o777).toString(8)" "$runtime_bin")" + [[ "$runtime_mode" == "$FIXTURE_EXPECTED_RUNTIME_MODE" ]] || + fixture_fail "Installed $runtime_bin has mode $runtime_mode; expected $FIXTURE_EXPECTED_RUNTIME_MODE." + fixture_log "installed runtime/sce mode $runtime_mode" + + FIXTURE_SCE_BIN="$prefix_dir/node_modules/.bin/sce" + [[ -x "$FIXTURE_SCE_BIN" ]] || fixture_fail "Installed sce bin $FIXTURE_SCE_BIN is not executable." + sce_bin_target="$(realpath "$FIXTURE_SCE_BIN")" + expected_bin_target="$(realpath "$installed_dir/bin/sce.js")" + [[ "$sce_bin_target" == "$expected_bin_target" ]] || + fixture_fail "Installed sce bin resolves to $sce_bin_target; expected $expected_bin_target." + fixture_log ".bin/sce resolves to $sce_bin_target" +} + +fixture_usage() { + cat <<'EOF' +Usage: release-fixture.sh prepare [--package-dir | --tarball ] + [--binary ] + release-fixture.sh serve [--port-file ] + +prepare writes a hermetic fixture release into : the repacked package +tarball (throwaway public key), release/ (archive, signed manifest), keys/ +(throwaway signing key, loopback TLS cert and key), and fixture.env with the +version, target triple, and absolute paths for later consumers. + +serve runs the loopback HTTPS fixture server against /release in the +foreground and writes its port to --port-file (default /server-port). +EOF +} + +fixture_cmd_prepare() { + local fixture_dir="${1:-}" + local package_dir tarball="" binary="" + + [[ -n "$fixture_dir" ]] || fixture_fail "Missing for prepare." + shift + package_dir="$(cd "$FIXTURE_SCRIPT_DIR/.." && pwd)" + while [[ $# -gt 0 ]]; do + case "$1" in + --package-dir) + [[ -n "${2:-}" ]] || fixture_fail "Missing value for --package-dir." + package_dir="$(cd "$2" && pwd)" + shift 2 + ;; + --tarball) + [[ -n "${2:-}" ]] || fixture_fail "Missing value for --tarball." + [[ -f "$2" ]] || fixture_fail "Tarball $2 does not exist." + tarball="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" + shift 2 + ;; + --binary) + [[ -n "${2:-}" ]] || fixture_fail "Missing value for --binary." + [[ -f "$2" ]] || fixture_fail "Binary $2 does not exist." + binary="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" + shift 2 + ;; + *) + fixture_usage >&2 + fixture_fail "Unknown argument: $1" + ;; + esac + done + + fixture_require_tools + mkdir -p "$fixture_dir" + fixture_dir="$(cd "$fixture_dir" && pwd)" + [[ ! -e "$fixture_dir/$FIXTURE_ENV_FILE_NAME" ]] || + fixture_fail "Fixture directory $fixture_dir is already prepared." + + local stage_dir="$fixture_dir/stage" + local release_dir="$fixture_dir/release" + local pack_dir="$fixture_dir/pack" + local keys_dir="$fixture_dir/keys" + mkdir -p "$stage_dir" "$release_dir" "$pack_dir" "$keys_dir" + + fixture_stage_package "$stage_dir" "$package_dir" "$tarball" + fixture_resolve_platform "$stage_dir" + fixture_log "version $FIXTURE_VERSION, target $FIXTURE_TARGET_TRIPLE" + fixture_generate_keys "$stage_dir" "$keys_dir" + fixture_build_release "$release_dir" "$keys_dir" "$fixture_dir" "$binary" + fixture_pack "$stage_dir" "$pack_dir" + + { + printf 'FIXTURE_VERSION=%q\n' "$FIXTURE_VERSION" + printf 'FIXTURE_TARGET_TRIPLE=%q\n' "$FIXTURE_TARGET_TRIPLE" + printf 'FIXTURE_TARBALL=%q\n' "$FIXTURE_TARBALL" + printf 'FIXTURE_RELEASE_DIR=%q\n' "$release_dir" + printf 'FIXTURE_TLS_CERT=%q\n' "$keys_dir/tls-cert.pem" + printf 'FIXTURE_TLS_KEY=%q\n' "$keys_dir/tls-key.pem" + } >"$fixture_dir/$FIXTURE_ENV_FILE_NAME" + fixture_log "prepared $fixture_dir" +} + +fixture_cmd_serve() { + local fixture_dir="${1:-}" + local port_file="" + + [[ -n "$fixture_dir" ]] || fixture_fail "Missing for serve." + shift + while [[ $# -gt 0 ]]; do + case "$1" in + --port-file) + [[ -n "${2:-}" ]] || fixture_fail "Missing value for --port-file." + port_file="$2" + shift 2 + ;; + *) + fixture_usage >&2 + fixture_fail "Unknown argument: $1" + ;; + esac + done + + [[ -f "$fixture_dir/$FIXTURE_ENV_FILE_NAME" ]] || + fixture_fail "Fixture directory $fixture_dir is not prepared; run prepare first." + command -v node >/dev/null 2>&1 || fixture_fail "Required tool 'node' is not on PATH." + exec node "$FIXTURE_SCRIPT_DIR/fixture-release-server.mjs" \ + --root "$fixture_dir/release" \ + --cert "$fixture_dir/keys/tls-cert.pem" \ + --key "$fixture_dir/keys/tls-key.pem" \ + --port-file "${port_file:-$fixture_dir/server-port}" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + case "${1:-}" in + prepare) + shift + fixture_cmd_prepare "$@" + ;; + serve) + shift + fixture_cmd_serve "$@" + ;; + --help | -h) + fixture_usage + ;; + *) + fixture_usage >&2 + fixture_fail "Unknown command: ${1:-}" + ;; + esac +fi