From bf87eba66fdd5f7e8e036555a0bfcc0e03887626 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 9 Oct 2026 14:27:59 +0300 Subject: [PATCH] Run CI through the shared GitHub Actions library --- .github/gates.json | 36 +++ .github/workflows/ci.yml | 239 ++++--------------- .github/workflows/host-image.yml | 91 ++----- .github/workflows/release-engine-package.yml | 200 +++++----------- .github/workflows/verify.yml | 35 --- CLAUDE.md | 4 +- agent.md | 6 +- build/Invoke-EngineRelease.ps1 | 226 ++++++++++++++++++ build/Test-CompatibilitySweep.ps1 | 13 +- build/Test-EnginePackage.ps1 | 72 ++++++ build/Test-ReplayOracle.ps1 | 108 +++++++++ consumer-smoke/install-engine.mjs | 4 +- tools/host-smoke/Invoke-HostImage.ps1 | 161 +++++++++++++ 13 files changed, 749 insertions(+), 446 deletions(-) create mode 100644 .github/gates.json delete mode 100644 .github/workflows/verify.yml create mode 100644 build/Invoke-EngineRelease.ps1 create mode 100644 build/Test-EnginePackage.ps1 create mode 100644 build/Test-ReplayOracle.ps1 create mode 100644 tools/host-smoke/Invoke-HostImage.ps1 diff --git a/.github/gates.json b/.github/gates.json new file mode 100644 index 00000000..1b36dde0 --- /dev/null +++ b/.github/gates.json @@ -0,0 +1,36 @@ +{ + "gates": [ + { + "name": "engine / install", + "command": "cd src/engine && npm ci" + }, + { + "name": "engine / typecheck", + "command": "cd src/engine && npm run typecheck" + }, + { + "name": "engine / lint", + "command": "cd src/engine && npm run lint" + }, + { + "name": "engine / test", + "command": "cd src/engine && npm run test" + }, + { + "name": "engine / build/Test-EnginePackage.ps1", + "command": "pwsh -NoProfile -File ./build/Test-EnginePackage.ps1" + }, + { + "name": "smoke / Test the host", + "command": "dotnet test src/host/SubZeroDev.GameEngine.Host.Tests/SubZeroDev.GameEngine.Host.Tests.csproj -c Release" + }, + { + "name": "smoke / Positive route and probe smoke", + "command": "pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage Smoke" + }, + { + "name": "smoke / Negative fixture -- corrupted artifact must fail to start", + "command": "pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage NegativeFixture" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2384e3ba..1ed70399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,24 +2,17 @@ name: CI # Branches are covered by `pull_request`; `main` by `push`. Restricting push to # main is what stops a branch with an open PR producing two runs of this -# workflow -- the same approach docs.yml takes, and the reason -# it needs no concurrency group either. +# workflow. # # An earlier version triggered on every push *and* pull_request, deduplicated by # a concurrency group with cancel-in-progress. That raced: whichever run started -# second cancelled the first, leaving two check-runs named `engine` on one commit -# -- one success, one cancelled -- and a required-status-check rule that sees the -# cancelled one blocks the merge even though everything passed. +# second cancelled the first, leaving two check-runs on one commit -- one success, +# one cancelled -- and a required-status-check rule that sees the cancelled one +# blocks the merge even though everything passed. # -# No `paths` filter on either trigger. A path-filtered `pull_request` was tried and -# reverted: `engine` is a required status check on `main`'s ruleset, and a workflow -# run that never starts because no matching path changed means the required check -# never reports at all -- GitHub then leaves the PR waiting on a check that will -# never arrive, rather than treating it as satisfied. The `engine` job itself now -# does the equivalent skip internally (see its own "Determine..." step below), -# which keeps the job always reporting while still skipping the expensive steps on -# a documentation-only PR (07-replay.md §8's "never a merge gate on -# documentation-only changes"). +# No `paths` filter on either trigger: `engine / Result` is a required status check on +# `main`'s ruleset, and a workflow run that never starts because no matching path +# changed means the required check never reports at all. "on": push: branches: @@ -28,8 +21,7 @@ name: CI - "v*" pull_request: -permissions: - contents: read +permissions: {} # Only reachable for pushes to main now, since a PR branch produces a single # run. cancel-in-progress is still right there: consecutive pushes to main @@ -39,143 +31,65 @@ concurrency: cancel-in-progress: true jobs: + # Always runs, on every event including a tag push -- a tag can in principle be cut + # against a commit that was never itself verified as a branch push or a checked PR, so + # this is the one place every ref actually gets typecheck/lint/test. + # + # The package half (pack, inspect the tarball with the release workflow's own archive + # guard, consumer smoke against that tarball) is build/Test-EnginePackage.ps1. engine: - name: engine - # Always runs, on every event including a tag push -- a tag can in principle be cut - # against a commit that was never itself verified as a branch push or a checked PR - # (an unenforced assumption an earlier version of this job made), so this is the one - # place every ref actually gets typecheck/lint/test. + permissions: + contents: read + uses: The-Running-Dev/GitHub-ActionTemplates/.github/workflows/node-ci.yml@v0 + with: + working-directory: src/engine + node-versions: '["24"]' + scripts: typecheck lint test + cache: npm + cache-dependency-path: src/engine/package-lock.json + post-build: build/Test-EnginePackage.ps1 + timeout-minutes: 10 + + # Parse-checks every PowerShell script. There are no Pester tests in the repository + # (tools/ holds none), so this is not a pwsh-ci.yml call: that workflow always runs + # Pester and fails on zero tests. Move to pwsh-ci.yml when the first *.Tests.ps1 lands. + powershell: runs-on: ubuntu-latest timeout-minutes: 10 - + permissions: + contents: read steps: - - name: Check out repository - uses: actions/checkout@v7 + - uses: actions/checkout@v7 with: - # pull_request needs both endpoints reachable to diff against; push (branch or - # tag) always runs the real steps below regardless, so shallow is fine there. - fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + persist-credentials: false - # Every input the steps below read: the package, the consumer smoke that installs it, - # the licence packed into it, and this workflow. Diffing `src/engine` alone let a - # consumer-smoke-only PR skip the job that runs the smoke (S128). - - name: Determine whether the engine package changed - id: changed - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - if git diff --quiet "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" -- \ - src/engine consumer-smoke LICENSE .github/workflows/ci.yml; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Set up Node.js - if: steps.changed.outputs.changed == 'true' - uses: actions/setup-node@v7 + - uses: The-Running-Dev/GitHub-ActionTemplates/actions/pwsh-check@v0 with: - node-version: 24 - cache: npm - cache-dependency-path: src/engine/package-lock.json - - - name: Install dependencies - if: steps.changed.outputs.changed == 'true' - working-directory: src/engine - run: npm ci - - # verification: true - - name: Typecheck - if: steps.changed.outputs.changed == 'true' - working-directory: src/engine - run: npm run typecheck - - # verification: true - - name: Lint - if: steps.changed.outputs.changed == 'true' - working-directory: src/engine - run: npm run lint - - # verification: true - - name: Test - if: steps.changed.outputs.changed == 'true' - working-directory: src/engine - run: npm test - - # verification: true - - name: Pack package - if: steps.changed.outputs.changed == 'true' - id: pack-package - working-directory: src/engine - run: | - tarball="$(npm pack --silent)" - echo "tarball=$tarball" >> "$GITHUB_OUTPUT" - - # The release workflow's own archive guard, so a PR is held to the rules a release is - # held to — including the packed LICENSE (S128) — and not to an inline copy of them. - # verification: true - - name: Inspect tarball - if: steps.changed.outputs.changed == 'true' - working-directory: src/engine - env: - TARBALL: ${{ steps.pack-package.outputs.tarball }} - run: | - if [ -z "$TARBALL" ]; then - echo "package step did not produce a tarball path" - exit 1 - fi - node scripts/verify-release.mjs archive --archive "$TARBALL" - - # verification: true - - name: Consumer smoke - if: steps.changed.outputs.changed == 'true' - run: | - package_tgz="${{ steps.pack-package.outputs.tarball }}" - if [ -z "$package_tgz" ]; then - echo "No engine package tarball available for smoke install" - exit 1 - fi - - rm -rf consumer-smoke/node_modules consumer-smoke/dist - (cd consumer-smoke && npm ci) - (cd consumer-smoke && npm run install:engine) - (cd consumer-smoke && npm run build && npm run smoke) + paths: . # The replay regression oracle's cross-version comparison (07-replay.md §8): on every # release tag, run *this* tag's engine against the *previous* tag's own fixture - # submissions and committed .outcome.json files together — the actual "did this change - # alter a game that already exists" question, distinct from the engine job's own test - # run above, which only ever compares a build against its own commit's corpus - # (07-replay.md §1). - # - # Both the fixture and the outcome are pulled from the previous tag, not just the - # outcome — comparing this commit's (possibly edited or removed) fixture inputs against - # an old recorded outcome would not be a clean version-to-version comparison. - # - # Skips cleanly, not a failure, when there is no previous tag yet (plans/27, milestone - # M3) — the very first tag has nothing to compare against. - release-tag-replay: - name: replay oracle — release tag comparison + # submissions and committed .outcome.json files together -- the "did this change alter a + # game that already exists" question, distinct from the engine job's own test run, which + # only compares a build against its own commit's corpus (07-replay.md §1). The logic is + # build/Test-ReplayOracle.ps1; it skips cleanly when there is no previous tag yet. + replay: if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest timeout-minutes: 10 - + permissions: + contents: read steps: - - name: Check out repository (full history, so previous tags resolve) - uses: actions/checkout@v7 + # actions/checkout defaults fetch-tags to false, and fetch-depth: 0 alone did not bring + # sibling tags on a tag-triggered run (pushing v0.2.0 found "No previous tag before + # v0.2.0" while v0.1.0 was on origin), so ask for them. + - uses: actions/checkout@v7 with: fetch-depth: 0 - # actions/checkout defaults fetch-tags to false. On a tag-triggered run that means - # only the triggering tag itself is fetched -- fetch-depth: 0 gets full commit - # history, but not sibling tags -- so "Find the previous release tag" below always - # finds none. Found live: pushing v0.2.0 produced "No previous tag before v0.2.0" - # even though v0.1.0 was on origin the whole time (run 30698926200). fetch-tags: true + persist-credentials: false - - name: Set up Node.js - uses: actions/setup-node@v7 + - uses: actions/setup-node@v7 with: node-version: 24 cache: npm @@ -185,56 +99,5 @@ jobs: working-directory: src/engine run: npm ci - - name: Find the previous release tag - id: previous - run: | - current="${GITHUB_REF#refs/tags/}" - previous=$(git tag --list 'v*' --sort=-v:refname | grep -A1 -F -x "$current" | tail -n1) - if [ -z "$previous" ] || [ "$previous" = "$current" ]; then - echo "No previous tag before $current — nothing to compare yet (M3, plans/27)." - echo "found=false" >> "$GITHUB_OUTPUT" - else - echo "Comparing $current against $previous" - echo "found=true" >> "$GITHUB_OUTPUT" - echo "tag=$previous" >> "$GITHUB_OUTPUT" - fi - - # Runs from the repo root (no working-directory override), matching the pathspec - # below, which is itself repo-root-relative -- src/engine/fixtures/replay. An - # earlier version set working-directory: src/engine here while keeping the - # src/engine/-prefixed pathspec, which resolved to the nonexistent - # src/engine/src/engine/fixtures/replay and silently extracted nothing. - # - # `|| true` on the ls-tree/grep pipeline: an empty match (the previous tag predates - # W22's corpus entirely -- true of v0.1.0, the only tag today, cut before any fixture - # existed) must not itself end the step under this shell's `-eo pipefail` default. - # The `has_fixtures` check below is the real guard against that case; this is cheap - # insurance alongside it. - - name: Extract the previous tag's fixtures and outcomes - id: extract - if: steps.previous.outputs.found == 'true' - run: | - mkdir -p /tmp/replay-baseline - for path in $(git ls-tree -r --name-only "${{ steps.previous.outputs.tag }}" -- src/engine/fixtures/replay | grep -E '\.(fixture|outcome)\.json$' || true); do - name=$(basename "$path") - git show "${{ steps.previous.outputs.tag }}:$path" > "/tmp/replay-baseline/$name" - done - if ls /tmp/replay-baseline/*.fixture.json >/dev/null 2>&1; then - echo "has_fixtures=true" >> "$GITHUB_OUTPUT" - else - echo "${{ steps.previous.outputs.tag }} predates the replay corpus (W22) — nothing to compare yet." - echo "has_fixtures=false" >> "$GITHUB_OUTPUT" - fi - - # stable-life.replay.test.ts (simulation kind, W40) and - # world-graph-mvp.replay.test.ts (world-graph kind, W49) join bureaucracy's own suite - # here -- all read from the same REPLAY_BASELINE_DIR, and all degrade to zero - # test cases rather than a failure when the baseline tag predates their own corpus - # (has_fixtures above only guards "the corpus exists at all", not "every kind's own - # slice of it exists yet" -- v0.1.0-to-just-after-W40 is exactly that transition). - - name: Run the corpus against the previous tag's fixtures and outcomes - if: steps.previous.outputs.found == 'true' && steps.extract.outputs.has_fixtures == 'true' - working-directory: src/engine - env: - REPLAY_BASELINE_DIR: /tmp/replay-baseline - run: npx vitest run src/campaigns/bulgaria-bureaucracy.replay.test.ts src/campaigns/stable-life.replay.test.ts src/campaigns/world-graph-mvp.replay.test.ts + - name: Replay oracle + run: pwsh -NoProfile -File ./build/Test-ReplayOracle.ps1 diff --git a/.github/workflows/host-image.yml b/.github/workflows/host-image.yml index 5a38b419..bc678e3f 100644 --- a/.github/workflows/host-image.yml +++ b/.github/workflows/host-image.yml @@ -10,8 +10,7 @@ name: Platform Static Host Image branches: - main -permissions: - contents: read +permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -37,11 +36,9 @@ jobs: # rather than let `dotnet restore` fall back to an unauthenticated request and fail # with an opaque Docker build error. - name: Verify NuGet restore credential is configured - run: | - if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then - echo "::error::REGISTRY_TOKEN secret is required to restore SubZeroDev.Platform.Hosting from GitHub Packages in the SubZeroDev.Platform repository. The default GITHUB_TOKEN cannot read packages published from a sibling repository." - exit 1 - fi + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage Credential - name: Set up .NET SDK uses: actions/setup-dotnet@v4 @@ -82,38 +79,14 @@ jobs: nuget_github_token=${{ secrets.REGISTRY_TOKEN }} - name: Start the container - run: | - docker run -d --name host-smoke -p 18080:8080 subzerodev-gameengine-host:smoke - sleep 3 - docker logs host-smoke + run: pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage Start # verification: true - name: Positive route and probe smoke - run: | - set -euo pipefail - check() { - local path="$1" expected="$2" - local code - code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:18080${path}")" - if [ "$code" != "$expected" ]; then - echo "GET ${path} -> ${code}, expected ${expected}" - exit 1 - fi - echo "GET ${path} -> ${code}" - } - check / 200 - check /roadmap/ 200 - check /docs/ 200 - check /health/live 200 - check /health/ready 200 - # No SPA fallback (§4): an unknown route must 404, never the landing page. - check /this-route-does-not-exist 404 + run: pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage Smoke - name: Orderly container stop - run: | - docker stop --timeout 10 host-smoke - docker logs host-smoke | tail -5 - docker rm host-smoke + run: pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage Stop # §7 / W62.7: a deliberate missing-artifact fixture, proving the gate actually # fails red rather than merely never having been tested. Program.cs's own startup @@ -121,17 +94,7 @@ jobs: # non-zero exit rather than a container that starts and silently serves less. # verification: true - name: Negative fixture -- corrupted artifact must fail to start - run: | - docker build \ - --build-arg BASE_IMAGE=subzerodev-gameengine-host:smoke \ - -f tools/host-smoke/Dockerfile.negative-fixture \ - -t subzerodev-gameengine-host:negative-fixture \ - . - if docker run --rm subzerodev-gameengine-host:negative-fixture; then - echo "Negative fixture started successfully -- it must not. The missing-artifact guard is not proven." - exit 1 - fi - echo "Negative fixture correctly failed to start." + run: pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage NegativeFixture publish: name: Publish immutable image to GHCR @@ -151,26 +114,16 @@ jobs: - name: Determine whether hosting inputs changed id: changed - run: | - before="${{ github.event.before }}" - if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then - echo "No previous commit to diff against -- publishing." - echo "changed=true" >> "$GITHUB_OUTPUT" - elif git diff --quiet "$before" "${{ github.sha }}" -- \ - Dockerfile .dockerignore src/host site docs \ - src/engine; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi + env: + BEFORE: ${{ github.event.before }} + SHA: ${{ github.sha }} + run: pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage Changed - name: Verify NuGet restore credential is configured if: steps.changed.outputs.changed == 'true' - run: | - if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then - echo "::error::REGISTRY_TOKEN secret is required to restore SubZeroDev.Platform.Hosting from GitHub Packages in the SubZeroDev.Platform repository. The default GITHUB_TOKEN cannot read packages published from a sibling repository." - exit 1 - fi + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage Credential - name: Set up Docker Buildx if: steps.changed.outputs.changed == 'true' @@ -187,9 +140,9 @@ jobs: - name: Compute image reference if: steps.changed.outputs.changed == 'true' id: image - run: | - repo_lower="$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')" - echo "ref=ghcr.io/${repo_lower}-host" >> "$GITHUB_OUTPUT" + env: + REPOSITORY: ${{ github.repository }} + run: pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage ImageRef # One immutable full-commit tag, no `latest`, no deployment -- §6's decision # summary. Digest is recorded in the job summary so a later slice can pin it. @@ -207,6 +160,8 @@ jobs: - name: Record the published digest if: steps.changed.outputs.changed == 'true' - run: | - echo "Published ${{ steps.image.outputs.ref }}:${{ github.sha }}" >> "$GITHUB_STEP_SUMMARY" - echo "Digest: ${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY" + env: + IMAGE_REF: ${{ steps.image.outputs.ref }} + SHA: ${{ github.sha }} + DIGEST: ${{ steps.build.outputs.digest }} + run: pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage Record diff --git a/.github/workflows/release-engine-package.yml b/.github/workflows/release-engine-package.yml index b4bdfb58..db552ef3 100644 --- a/.github/workflows/release-engine-package.yml +++ b/.github/workflows/release-engine-package.yml @@ -11,7 +11,8 @@ name: release-engine-package # Before this split the guards could only run by pushing a tag, which is why the # escaped-quote bug that failed the `v0.10.0` run (run 33721497785) was fixed blind and # never executed. The guard logic itself now lives in `src/engine/scripts/verify-release.mjs` -# so it is unit-tested (`scripts/verify-release.test.ts`) rather than inlined here. +# so it is unit-tested (`scripts/verify-release.test.ts`) rather than inlined here; the +# stages that sequence it live in `build/Invoke-EngineRelease.ps1`. on: push: @@ -30,21 +31,25 @@ on: - "src/engine/scripts/verify-release.mjs" - "src/engine/scripts/verify-release.test.ts" - "src/engine/scripts/stage-license.mjs" + - "build/Invoke-EngineRelease.ps1" - "LICENSE" - ".github/workflows/release-engine-package.yml" -permissions: - contents: read - packages: write +permissions: {} jobs: release: name: release game-engine package runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read + packages: write steps: - name: Check out repository uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Node uses: actions/setup-node@v7 @@ -52,176 +57,85 @@ jobs: node-version: 24 registry-url: https://npm.pkg.github.com - # A validation run has no tag of its own, so it validates against the tag the current - # manifest would require. That is the whole point: the candidate is checked before a - # tag exists, not after one has been pushed. - name: Resolve release mode and tag id: mode - working-directory: src/engine env: EVENT_NAME: ${{ github.event_name }} GIT_REF: ${{ github.ref }} INPUT_TAG: ${{ inputs.tag }} - run: | - input_tag="$INPUT_TAG" - if [ -z "$input_tag" ]; then - input_tag="v$(node -p "require('./package.json').version")" - fi - node scripts/verify-release.mjs mode \ - --event "$EVENT_NAME" --ref "$GIT_REF" --input-tag "$input_tag" | tee mode.txt - grep -E '^(mode|tag)=' mode.txt >> "$GITHUB_OUTPUT" - rm mode.txt - - - name: Guard — the tag and the manifest name the same release + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage Mode + + - name: Guard - the tag and the manifest name the same release working-directory: src/engine - run: node scripts/verify-release.mjs tag --tag "${{ steps.mode.outputs.tag }}" + env: + TAG: ${{ steps.mode.outputs.tag }} + run: node scripts/verify-release.mjs tag --tag "$TAG" - - name: Guard — the candidate is a committed tree + - name: Guard - the candidate is a committed tree working-directory: src/engine run: node scripts/verify-release.mjs clean --root "$GITHUB_WORKSPACE" - # Exit 2 is "could not be evaluated", which is not a pass. In validation mode that is - # recorded and the run continues; a publish run requires a real answer. - - name: Guard — this version is not already published + - name: Guard - this version is not already published id: registry continue-on-error: ${{ steps.mode.outputs.mode != 'publish' }} - working-directory: src/engine env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node scripts/verify-release.mjs registry \ - --package "$(node -p "require('./package.json').name")" \ - --version "$(node -p "require('./package.json').version")" | tee registry.txt - rm registry.txt + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage Registry - name: Record the registry guard's outcome if: always() - run: | - printf -- '- Registry availability guard: **%s** (mode `%s`)\n' \ - "${{ steps.registry.outcome }}" "${{ steps.mode.outputs.mode }}" >> "$GITHUB_STEP_SUMMARY" - - - name: Install dependencies - working-directory: src/engine - run: npm ci + env: + REGISTRY_OUTCOME: ${{ steps.registry.outcome }} + MODE: ${{ steps.mode.outputs.mode }} + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage RegistryOutcome - name: Verify engine - run: | - npm --prefix src/engine run typecheck - npm --prefix src/engine run lint - npm --prefix src/engine test - - # `prepack` starts by removing dist, so this is the package that would publish from a - # clean checkout. The sentinel proves that: a file planted in dist beforehand must not - # survive into the archive. Keep the tarball path as an output — every later step must - # use this archive, never a second pack. + uses: The-Running-Dev/GitHub-ActionTemplates/actions/node-scripts@v0 + with: + path: src/engine + scripts: typecheck lint test + - name: Pack clean package id: pack - working-directory: src/engine - run: | - mkdir -p dist - printf 'stale output from a previous build\n' > dist/stale-sentinel.txt - tarball="$(npm pack --silent)" - test -n "$tarball" - test -f "$tarball" - if tar -tzf "$tarball" | grep -q 'stale-sentinel'; then - echo "the clean build did not remove stale dist output" - exit 1 - fi - echo "tarball=$tarball" >> "$GITHUB_OUTPUT" - - - name: Guard — inspect the archive and record its digest + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage Pack + + - name: Guard - inspect the archive and record its digest id: archive - working-directory: src/engine - run: | - node scripts/verify-release.mjs archive \ - --archive "${{ steps.pack.outputs.tarball }}" | tee archive.txt - grep -E '^digest=' archive.txt >> "$GITHUB_OUTPUT" - rm archive.txt + env: + TARBALL: ${{ steps.pack.outputs.tarball }} + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage Archive - name: Verify packed consumer against that archive env: - ENGINE_TARBALL: ${{ github.workspace }}/src/engine/${{ steps.pack.outputs.tarball }} - run: | - npm --prefix consumer-smoke ci - npm --prefix consumer-smoke run install:engine - npm --prefix consumer-smoke run build - npm --prefix consumer-smoke run smoke - - - name: Guard — the archive the consumer used is still the inspected archive - working-directory: src/engine - run: | - node scripts/verify-release.mjs archive \ - --archive "${{ steps.pack.outputs.tarball }}" \ - --expect "${{ steps.archive.outputs.digest }}" - - # Negative evidence. A guard that has never rejected anything is not known to - # constrain anything, so a validation run proves each rejection against this very - # candidate. Each case must exit 1; exiting 0 fails the step. - - name: Guard negatives — mismatched tag, dirty tree, substituted archive + TARBALL: ${{ steps.pack.outputs.tarball }} + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage Consumer + + - name: Guard - the archive the consumer used is still the inspected archive + env: + TARBALL: ${{ steps.pack.outputs.tarball }} + DIGEST: ${{ steps.archive.outputs.digest }} + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage Archive + + - name: Guard negatives - mismatched tag, dirty tree, substituted archive if: steps.mode.outputs.mode != 'publish' - working-directory: src/engine - run: | - set -u - # `set +e` around the call is load-bearing: `if cmd; then` would swallow the exit - # code, and this asserts the exact code — 1 is "rejected", 2 is "could not be - # evaluated", and only 1 is evidence that the guard fired. - expect_rejected() { - label="$1" - shift - set +e - "$@" - status=$? - set -e - if [ "$status" -eq 0 ]; then - echo "NOT REJECTED: $label" - exit 1 - fi - if [ "$status" -ne 1 ]; then - echo "WRONG EXIT for $label: expected 1 (rejected), got $status" - exit 1 - fi - printf -- '- Rejected as expected: %s\n' "$label" >> "$GITHUB_STEP_SUMMARY" - } - - expect_rejected "a tag that does not match the manifest version" \ - node scripts/verify-release.mjs tag --tag v0.0.0 - - printf '\n' >> ../../README.md - expect_rejected "a candidate tree with a tracked modification" \ - node scripts/verify-release.mjs clean --root "$GITHUB_WORKSPACE" - git -C "$GITHUB_WORKSPACE" checkout -- README.md - - cp "${{ steps.pack.outputs.tarball }}" substituted.tgz - printf 'appended\n' >> substituted.tgz - expect_rejected "an archive modified after its digest was recorded" \ - node scripts/verify-release.mjs archive --archive substituted.tgz \ - --expect "${{ steps.archive.outputs.digest }}" - rm substituted.tgz - - # The only step that publishes, and it is reachable only when `mode` returned - # `publish` — which needs a real `refs/tags/v*` push. It re-verifies the digest first - # and then publishes that exact archive, so the bytes inspected above are the bytes - # that ship; `npm publish` with no argument would pack a second, unverified time. + env: + TARBALL: ${{ steps.pack.outputs.tarball }} + DIGEST: ${{ steps.archive.outputs.digest }} + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage Negatives + - name: Publish package if: steps.mode.outputs.mode == 'publish' - working-directory: src/engine env: + TARBALL: ${{ steps.pack.outputs.tarball }} + DIGEST: ${{ steps.archive.outputs.digest }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node scripts/verify-release.mjs archive \ - --archive "${{ steps.pack.outputs.tarball }}" \ - --expect "${{ steps.archive.outputs.digest }}" - printf '//npm.pkg.github.com/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > .npmrc - npm publish "${{ steps.pack.outputs.tarball }}" + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage Publish - name: Report the candidate if: always() - run: | - { - printf '\n## Candidate\n\n' - printf -- '- Mode: `%s`\n' "${{ steps.mode.outputs.mode }}" - printf -- '- Tag: `%s`\n' "${{ steps.mode.outputs.tag }}" - printf -- '- Commit: `%s`\n' "$GITHUB_SHA" - printf -- '- Archive: `%s`\n' "${{ steps.pack.outputs.tarball }}" - printf -- '- Archive sha256: `%s`\n' "${{ steps.archive.outputs.digest }}" - } >> "$GITHUB_STEP_SUMMARY" + env: + MODE: ${{ steps.mode.outputs.mode }} + TAG: ${{ steps.mode.outputs.tag }} + TARBALL: ${{ steps.pack.outputs.tarball }} + DIGEST: ${{ steps.archive.outputs.digest }} + run: pwsh -NoProfile -File ./build/Invoke-EngineRelease.ps1 -Stage Report diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml deleted file mode 100644 index b50c3323..00000000 --- a/.github/workflows/verify.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: verify - -on: - push: - branches: [main] - pull_request: - -jobs: - powershell: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - # verification: true - - name: Parse-check PowerShell scripts - shell: pwsh - run: | - $failed = $false - Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object { - $parseErrors = $null - [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$null, [ref]$parseErrors) | Out-Null - if ($parseErrors.Count) { - Write-Error "Parse errors in $($_.FullName): $($parseErrors -join '; ')" - $failed = $true - } - } - if ($failed) { exit 1 } - - # verification: true - - name: Run Pester tests - shell: pwsh - run: | - Import-Module Pester -MinimumVersion 5.0 - $result = Invoke-Pester -Path tools -Output Detailed -PassThru - if ($result.FailedCount -gt 0) { exit 1 } diff --git a/CLAUDE.md b/CLAUDE.md index e6081160..268f3d5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -500,8 +500,8 @@ is `0`, so it *would* work — this is a deliberate workflow choice, not a limit > commit was squashed out exactly this way and needed recovering as PR #78. With auto-merge > off, this discipline is the only thing preventing it. -**Three required checks** on `main` — `engine`, `Documentation links and terminology`, -`Verify Documentation Build`. The deploy job is *not* required (it runs only on `main`, so +**Two required checks** on `main` — `engine / Result` (the aggregate job of the shared +`node-ci.yml`, called by `ci.yml`) and `docs / Build`. The deploy job is *not* required (it runs only on `main`, so requiring it would leave every PR pending). **`required_review_thread_resolution` is on.** Automated PR review runs via the diff --git a/agent.md b/agent.md index 6dbf568b..0332a6df 100644 --- a/agent.md +++ b/agent.md @@ -136,8 +136,10 @@ README, whose status is deliberately coarse. open; add new gaps there as full entries. - **Engine suite runs under vitest** — `npm test` from `src/engine/` is the count; a number written here goes stale, as "15 tests" did for a suite that long outgrew it. CI runs it: - `.github/workflows/ci.yml` (`engine`), plus `docs.yml`, the caller of the shared reusable - docs workflow. The pull-request checks (`engine` and `docs / Build`) are required on `main`; + `.github/workflows/ci.yml` (`engine`, a caller of the shared `node-ci.yml`; the package + and consumer-smoke half is `build/Test-EnginePackage.ps1`), plus `docs.yml`, the caller of + the shared reusable docs workflow. The pull-request checks (`engine / Result` and + `docs / Build`) are required on `main`; `docs / Deploy` is not, since it runs only on push to `main` (`TODO.md` W0, closed). - **The docs-site base image is verified for the current W0 baseline.** `docs.ps1` builds on the public `ghcr.io/the-running-dev/docs-template`; the installed image uses diff --git a/build/Invoke-EngineRelease.ps1 b/build/Invoke-EngineRelease.ps1 new file mode 100644 index 00000000..9f157eeb --- /dev/null +++ b/build/Invoke-EngineRelease.ps1 @@ -0,0 +1,226 @@ +<# +.SYNOPSIS +The release workflow's stages (.github/workflows/release-engine-package.yml), one per -Stage. + +.DESCRIPTION +The workflow runs the same stages in `publish` and `validate` mode, so the guards that gate a +publication are the guards a validation run exercises (W108.5). The guard logic itself lives in +src/engine/scripts/verify-release.mjs, where it is unit tested; these stages only sequence it. + +Values move between steps as workflow step outputs, passed here as environment variables: + + Mode EVENT_NAME, GIT_REF, INPUT_TAG -> writes mode, tag to GITHUB_OUTPUT + Registry NODE_AUTH_TOKEN -> exit 2 is "could not be evaluated" + RegistryOutcome REGISTRY_OUTCOME, MODE -> job summary line + Pack (none) -> writes tarball to GITHUB_OUTPUT + Archive TARBALL [, DIGEST] -> writes digest to GITHUB_OUTPUT, or re-checks DIGEST + Consumer TARBALL -> consumer smoke against that archive + Negatives TARBALL, DIGEST -> each guard must reject its case + Publish TARBALL, DIGEST, NODE_AUTH_TOKEN -> npm publish of the inspected archive + Report MODE, TAG, TARBALL, DIGEST -> job summary + +A local validation run needs no GitHub variables: GITHUB_OUTPUT and GITHUB_STEP_SUMMARY are +optional, and the outputs are printed instead. +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [ValidateSet('Mode', 'Registry', 'RegistryOutcome', 'Pack', 'Archive', 'Consumer', 'Negatives', 'Publish', 'Report')] + [string] $Stage +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path +$enginePath = Join-Path $repoRoot 'src/engine' +$guard = 'scripts/verify-release.mjs' + +function Get-EnvironmentValue { + param ([Parameter(Mandatory)] [string] $Name) + $value = [Environment]::GetEnvironmentVariable($Name) + if ($null -eq $value) { return '' } + return $value +} + +function Add-Output { + param ([Parameter(Mandatory)] [string] $Line) + Write-Host $Line + if ($env:GITHUB_OUTPUT) { Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value $Line } +} + +function Add-Summary { + param ([Parameter(Mandatory)] [string] $Text) + if ($env:GITHUB_STEP_SUMMARY) { Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value $Text -NoNewline } +} + +# Runs a command from src/engine and returns its exit code, printing what it wrote. +function Invoke-Guard { + param ([Parameter(Mandatory)] [string[]] $Arguments) + Push-Location $enginePath + try { + $lines = @(& node $guard @Arguments 2>&1 | ForEach-Object { "$_" }) + $code = $LASTEXITCODE + } finally { + Pop-Location + } + $lines | ForEach-Object { Write-Host $_ } + return [pscustomobject]@{ Code = $code; Lines = $lines } +} + +function Assert-Guard { + param ([Parameter(Mandatory)] [string[]] $Arguments) + $result = Invoke-Guard $Arguments + if ($result.Code -ne 0) { exit $result.Code } + return $result +} + +function Get-Manifest { + Get-Content -LiteralPath (Join-Path $enginePath 'package.json') -Raw | ConvertFrom-Json +} + +function Get-Required { + param ([Parameter(Mandatory)] [string] $Name) + $value = Get-EnvironmentValue $Name + if (-not $value) { throw "Stage $Stage needs the $Name environment variable." } + return $value +} + +switch ($Stage) { + # A validation run has no tag of its own, so it validates against the tag the current + # manifest would require. That is the whole point: the candidate is checked before a tag + # exists, not after one has been pushed. + 'Mode' { + $tag = Get-EnvironmentValue 'INPUT_TAG' + if (-not $tag) { $tag = "v$((Get-Manifest).version)" } + $result = Assert-Guard @('mode', '--event', (Get-EnvironmentValue 'EVENT_NAME'), '--ref', (Get-EnvironmentValue 'GIT_REF'), '--input-tag', $tag) + foreach ($line in $result.Lines | Where-Object { $_ -match '^(mode|tag)=' }) { Add-Output $line } + } + + # Exit 2 is "could not be evaluated", which is not a pass. In validation mode the workflow + # records that and continues; a publish run requires a real answer. + 'Registry' { + $manifest = Get-Manifest + [void] (Assert-Guard @('registry', '--package', $manifest.name, '--version', $manifest.version)) + } + + 'RegistryOutcome' { + Add-Summary ("- Registry availability guard: **{0}** (mode ``{1}``)`n" -f (Get-EnvironmentValue 'REGISTRY_OUTCOME'), (Get-EnvironmentValue 'MODE')) + } + + # `prepack` starts by removing dist, so this is the package that would publish from a clean + # checkout. The sentinel proves that: a file planted in dist beforehand must not survive + # into the archive. The tarball path is an output -- every later step must use this + # archive, never a second pack. + 'Pack' { + Push-Location $enginePath + try { + [void] (New-Item -ItemType Directory -Force -Path 'dist') + [System.IO.File]::WriteAllText((Join-Path $enginePath 'dist/stale-sentinel.txt'), "stale output from a previous build`n") + $tarball = "$(npm pack --silent | Select-Object -Last 1)".Trim() + if ($LASTEXITCODE -ne 0) { throw "'npm pack' failed with exit code $LASTEXITCODE." } + if (-not $tarball) { throw 'npm pack produced no tarball name.' } + if (-not (Test-Path -LiteralPath $tarball -PathType Leaf)) { throw "npm pack did not produce $tarball." } + if (@(tar -tzf $tarball) -match 'stale-sentinel') { + throw 'the clean build did not remove stale dist output' + } + Add-Output "tarball=$tarball" + } finally { + Pop-Location + } + } + + # Without DIGEST this inspects the archive and records its digest. With DIGEST it + # re-checks that the archive the consumer smoke used is still the inspected one. + 'Archive' { + $arguments = @('archive', '--archive', (Get-Required 'TARBALL')) + $digest = Get-EnvironmentValue 'DIGEST' + if ($digest) { $arguments += @('--expect', $digest) } + $result = Assert-Guard $arguments + if (-not $digest) { + foreach ($line in $result.Lines | Where-Object { $_ -match '^digest=' }) { Add-Output $line } + } + } + + 'Consumer' { + $env:ENGINE_TARBALL = Join-Path $enginePath (Get-Required 'TARBALL') + try { + foreach ($arguments in @(@('ci'), @('run', 'install:engine'), @('run', 'build'), @('run', 'smoke'))) { + npm --prefix (Join-Path $repoRoot 'consumer-smoke') @arguments + if ($LASTEXITCODE -ne 0) { throw "'npm $($arguments -join ' ')' failed with exit code $LASTEXITCODE." } + } + } finally { + Remove-Item Env:\ENGINE_TARBALL -ErrorAction SilentlyContinue + } + } + + # Negative evidence. A guard that has never rejected anything is not known to constrain + # anything, so a validation run proves each rejection against this very candidate. Each + # case must exit 1: exiting 0 fails the stage, and so does exit 2 ("could not be + # evaluated"), which is not evidence that the guard fired. + 'Negatives' { + $tarball = Get-Required 'TARBALL' + $digest = Get-Required 'DIGEST' + + function Assert-Rejected { + param ( + [Parameter(Mandatory)] [string] $Label, + [Parameter(Mandatory)] [string[]] $Arguments + ) + $code = (Invoke-Guard $Arguments).Code + if ($code -eq 0) { throw "NOT REJECTED: $Label" } + if ($code -ne 1) { throw "WRONG EXIT for ${Label}: expected 1 (rejected), got $code" } + Add-Summary "- Rejected as expected: $Label`n" + } + + Assert-Rejected 'a tag that does not match the manifest version' @('tag', '--tag', 'v0.0.0') + + $readme = Join-Path $repoRoot 'README.md' + [System.IO.File]::AppendAllText($readme, "`n") + try { + Assert-Rejected 'a candidate tree with a tracked modification' @('clean', '--root', $repoRoot) + } finally { + git -C $repoRoot checkout -- README.md + } + + $substituted = Join-Path $enginePath 'substituted.tgz' + Copy-Item -LiteralPath (Join-Path $enginePath $tarball) -Destination $substituted + try { + [System.IO.File]::AppendAllText($substituted, "appended`n") + Assert-Rejected 'an archive modified after its digest was recorded' @('archive', '--archive', $substituted, '--expect', $digest) + } finally { + Remove-Item -LiteralPath $substituted -Force + } + } + + # The only stage that publishes, and the workflow reaches it only when `mode` returned + # `publish` -- which needs a real `refs/tags/v*` push. It re-verifies the digest first and + # then publishes that exact archive, so the bytes inspected above are the bytes that ship; + # `npm publish` with no argument would pack a second, unverified time. + 'Publish' { + $tarball = Get-Required 'TARBALL' + [void] (Assert-Guard @('archive', '--archive', $tarball, '--expect', (Get-Required 'DIGEST'))) + Push-Location $enginePath + try { + [System.IO.File]::WriteAllText((Join-Path $enginePath '.npmrc'), "//npm.pkg.github.com/:_authToken=$(Get-Required 'NODE_AUTH_TOKEN')`n") + npm publish $tarball + if ($LASTEXITCODE -ne 0) { throw "'npm publish' failed with exit code $LASTEXITCODE." } + } finally { + Pop-Location + } + } + + 'Report' { + Add-Summary @" + +## Candidate + +- Mode: ``$(Get-EnvironmentValue 'MODE')`` +- Tag: ``$(Get-EnvironmentValue 'TAG')`` +- Commit: ``$(Get-EnvironmentValue 'GITHUB_SHA')`` +- Archive: ``$(Get-EnvironmentValue 'TARBALL')`` +- Archive sha256: ``$(Get-EnvironmentValue 'DIGEST')`` + +"@ + } +} diff --git a/build/Test-CompatibilitySweep.ps1 b/build/Test-CompatibilitySweep.ps1 index feecede6..5c07f4b6 100644 --- a/build/Test-CompatibilitySweep.ps1 +++ b/build/Test-CompatibilitySweep.ps1 @@ -34,24 +34,25 @@ release tag (W104.7): re-running the next time any of the three repositories re-vendors this engine. 2. The replay regression oracle (07-replay.md), run locally against the baseline tag's own committed fixtures and outcomes -- the same - REPLAY_BASELINE_DIR mechanism .github/workflows/ci.yml's - release-tag-replay job runs automatically on every release tag push. + REPLAY_BASELINE_DIR mechanism build/Test-ReplayOracle.ps1 (the + `replay` job of .github/workflows/ci.yml) runs automatically on every + release tag push. Running it here proves W104.4 (byte-identical replay) without waiting for a tag. - 3. npm pack, with the same tarball-content assertions ci.yml's engine job - makes (no src/, no tsconfig, no test artifacts, dist/ present) -- + 3. npm pack, with the same tarball-content assertions build/Test-EnginePackage.ps1 + (ci.yml's engine job) makes (no src/, no tsconfig, no test artifacts, dist/ present) -- W104.6's clean-pack half. 4. The packed-tarball consumer smoke (consumer-smoke/), installed with no workspace resolution back into src/engine -- W104.6's install/compile/run half, exercising the public and ./authoring entry points together. -This script does not replace ci.yml's two jobs; it reproduces their combined +This script does not replace ci.yml's engine and replay jobs; it reproduces their combined result on demand, from any machine, without needing a release tag pushed first -- exactly the gap W104 exists to close before 0.11 is cut. .PARAMETER BaselineTag The prior release tag to compare replay fixtures against. Defaults to the -most recent 'v*' tag reachable from HEAD, matching ci.yml's own +most recent 'v*' tag reachable from HEAD, matching build/Test-ReplayOracle.ps1's "previous tag" resolution. .PARAMETER SkipConsumerSmoke diff --git a/build/Test-EnginePackage.ps1 b/build/Test-EnginePackage.ps1 new file mode 100644 index 00000000..4b8eaef0 --- /dev/null +++ b/build/Test-EnginePackage.ps1 @@ -0,0 +1,72 @@ +<# +.SYNOPSIS +Packs the engine, inspects the tarball, and runs the consumer smoke against that tarball. + +.DESCRIPTION +The package half of the `engine` CI job (.github/workflows/ci.yml), run after the engine's +install, typecheck, lint and test: + + 1. `npm pack` the engine (prepack builds dist/ and stages the LICENSE). + 2. Inspect the tarball with the release workflow's own archive guard + (src/engine/scripts/verify-release.mjs), so a pull request is held to the rules a + release is held to -- including the packed LICENSE (S128). + 3. Install that tarball into consumer-smoke/ with no link back into src/engine, then build + and run the smoke. ENGINE_TARBALL points install-engine.mjs at the inspected archive, so + the smoke checks the artifact that was inspected and not a second pack. + +Assumes the engine's dependencies are installed. Run it from anywhere: + + pwsh -NoProfile -File ./build/Test-EnginePackage.ps1 +#> +[CmdletBinding()] +param () + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path +$enginePath = Join-Path $repoRoot 'src/engine' +$consumerSmokePath = Join-Path $repoRoot 'consumer-smoke' + +function Invoke-Native { + param ( + [Parameter(Mandatory)] [string] $Command, + [string[]] $Arguments = @() + ) + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "'$Command $($Arguments -join ' ')' failed with exit code $LASTEXITCODE." + } +} + +Write-Host '==> Pack package' -ForegroundColor Cyan +Push-Location $enginePath +try { + $tarballName = (npm pack --silent | Select-Object -Last 1) + if ($LASTEXITCODE -ne 0) { throw "'npm pack' failed with exit code $LASTEXITCODE." } + $tarballName = "$tarballName".Trim() + if (-not $tarballName) { throw 'npm pack produced no tarball name.' } + $tarballPath = Join-Path $enginePath $tarballName + if (-not (Test-Path -LiteralPath $tarballPath -PathType Leaf)) { throw "npm pack did not produce $tarballPath." } + + Write-Host '==> Inspect tarball' -ForegroundColor Cyan + Invoke-Native node @('scripts/verify-release.mjs', 'archive', '--archive', $tarballPath) +} finally { + Pop-Location +} + +Write-Host '==> Consumer smoke' -ForegroundColor Cyan +Push-Location $consumerSmokePath +try { + foreach ($folder in 'node_modules', 'dist') { + if (Test-Path -LiteralPath $folder) { Remove-Item -LiteralPath $folder -Recurse -Force } + } + $env:ENGINE_TARBALL = $tarballPath + Invoke-Native npm @('ci') + Invoke-Native npm @('run', 'install:engine') + Invoke-Native npm @('run', 'build') + Invoke-Native npm @('run', 'smoke') +} finally { + Remove-Item Env:\ENGINE_TARBALL -ErrorAction SilentlyContinue + Pop-Location +} diff --git a/build/Test-ReplayOracle.ps1 b/build/Test-ReplayOracle.ps1 new file mode 100644 index 00000000..dc7bc47f --- /dev/null +++ b/build/Test-ReplayOracle.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS +Runs the replay regression oracle's cross-version comparison (07-replay.md §8). + +.DESCRIPTION +Runs *this* tree's engine against a *baseline* tag's own fixture submissions and committed +.outcome.json files together -- the "did this change alter a game that already exists" +question, distinct from the engine's own test run, which only compares a build against its +own commit's corpus (07-replay.md §1). Both the fixture and the outcome come from the +baseline tag: comparing this commit's (possibly edited or removed) fixtures against an old +recorded outcome would not be a clean version-to-version comparison. + +Which tag is the baseline: + + * -BaselineTag, when given. + * On a tag build (GITHUB_REF is refs/tags/): the tag before by version order. + * Otherwise: the newest 'v*' tag, the same default build/Test-CompatibilitySweep.ps1 uses. + +Skips cleanly (exit 0, not a failure) when there is no baseline tag yet (the very first tag +has nothing to compare against), or when the baseline predates the replay corpus (W22). + +Assumes the engine's dependencies are installed and the tags are fetched. Run it from anywhere: + + pwsh -NoProfile -File ./build/Test-ReplayOracle.ps1 +#> +[CmdletBinding()] +param ( + [Parameter()] + [string] $BaselineTag +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path +$enginePath = Join-Path $repoRoot 'src/engine' +$corpusPath = 'src/engine/fixtures/replay' + +# Writes one blob's bytes unchanged. Piping `git show` through PowerShell would decode and +# re-encode it, and the comparison is against the recorded bytes. +function Save-GitBlob { + param ( + [Parameter(Mandatory)] [string] $Revision, + [Parameter(Mandatory)] [string] $Destination + ) + $start = [System.Diagnostics.ProcessStartInfo]::new('git') + foreach ($argument in '-C', $repoRoot, 'show', $Revision) { $start.ArgumentList.Add($argument) } + $start.RedirectStandardOutput = $true + $process = [System.Diagnostics.Process]::Start($start) + $file = [System.IO.File]::Create($Destination) + try { + $process.StandardOutput.BaseStream.CopyTo($file) + } finally { + $file.Dispose() + $process.WaitForExit() + } + if ($process.ExitCode -ne 0) { throw "git show $Revision failed with exit code $($process.ExitCode)." } +} + +if (-not $BaselineTag) { + $tags = @(git -C $repoRoot tag --list 'v*' --sort=-v:refname) + if ($env:GITHUB_REF -like 'refs/tags/*') { + $current = $env:GITHUB_REF.Substring('refs/tags/'.Length) + $index = [array]::IndexOf($tags, $current) + if ($index -ge 0 -and $index + 1 -lt $tags.Count) { $BaselineTag = $tags[$index + 1] } + if (-not $BaselineTag) { + Write-Host "No previous tag before $current -- nothing to compare yet (M3, plans/27)." + return + } + Write-Host "Comparing $current against $BaselineTag" + } elseif ($tags.Count -gt 0) { + $BaselineTag = $tags[0] + Write-Host "Comparing this tree against $BaselineTag" + } else { + Write-Host "No 'v*' tags found -- nothing to compare yet." + return + } +} + +$baselineDir = Join-Path ([System.IO.Path]::GetTempPath()) "replay-baseline-$BaselineTag" +if (Test-Path -LiteralPath $baselineDir) { Remove-Item -LiteralPath $baselineDir -Recurse -Force } +[void] (New-Item -ItemType Directory -Path $baselineDir) + +$paths = @(git -C $repoRoot ls-tree -r --name-only $BaselineTag -- $corpusPath | Where-Object { $_ -match '\.(fixture|outcome)\.json$' }) +foreach ($path in $paths) { + Save-GitBlob -Revision "${BaselineTag}:$path" -Destination (Join-Path $baselineDir (Split-Path -Path $path -Leaf)) +} +if (-not (Get-ChildItem -LiteralPath $baselineDir -Filter '*.fixture.json')) { + Write-Host "$BaselineTag predates the replay corpus (W22) -- nothing to compare yet." + return +} + +# stable-life.replay.test.ts (simulation kind, W40) and world-graph-mvp.replay.test.ts +# (world-graph kind, W49) join bureaucracy's own suite here. All read REPLAY_BASELINE_DIR, and +# all degrade to zero test cases rather than a failure when the baseline predates their own +# slice of the corpus. +$env:REPLAY_BASELINE_DIR = $baselineDir +Push-Location $enginePath +try { + npx vitest run ` + src/campaigns/bulgaria-bureaucracy.replay.test.ts ` + src/campaigns/stable-life.replay.test.ts ` + src/campaigns/world-graph-mvp.replay.test.ts + if ($LASTEXITCODE -ne 0) { throw "The replay oracle failed against $BaselineTag (exit $LASTEXITCODE)." } +} finally { + Pop-Location + Remove-Item Env:\REPLAY_BASELINE_DIR -ErrorAction SilentlyContinue +} diff --git a/consumer-smoke/install-engine.mjs b/consumer-smoke/install-engine.mjs index f32be0cf..07816225 100644 --- a/consumer-smoke/install-engine.mjs +++ b/consumer-smoke/install-engine.mjs @@ -13,8 +13,8 @@ * `files` and the declaration emit were all still broken — proving nothing about the * artefact that actually ships. * - * CI does the same thing inline (`.github/workflows/ci.yml`, "Consumer smoke"); this - * script is the local equivalent, so `npm run install:engine && npm run build && + * CI does the same thing in `build/Test-EnginePackage.ps1` (the `engine` job of + * `.github/workflows/ci.yml`); this script is the local equivalent, so `npm run install:engine && npm run build && * npm run smoke` works from a clean checkout. */ diff --git a/tools/host-smoke/Invoke-HostImage.ps1 b/tools/host-smoke/Invoke-HostImage.ps1 new file mode 100644 index 00000000..671b7da1 --- /dev/null +++ b/tools/host-smoke/Invoke-HostImage.ps1 @@ -0,0 +1,161 @@ +<# +.SYNOPSIS +The host-image workflow's stages (.github/workflows/host-image.yml), one per -Stage. + +.DESCRIPTION +design/15-platform-static-host.md (W62). Values reach the stages as environment variables, +and outputs go to GITHUB_OUTPUT / GITHUB_STEP_SUMMARY when those exist: + + Credential REGISTRY_TOKEN -> fails loudly when the secret is missing + Start (none) -> starts the smoke container + Smoke (none) -> positive route and probe smoke + Stop (none) -> orderly container stop + NegativeFixture (none) -> a corrupted artifact must fail to start + Changed BEFORE, SHA -> writes changed=true|false + ImageRef REPOSITORY -> writes ref + Record IMAGE_REF, SHA, DIGEST -> job summary + +Start, Smoke, Stop and NegativeFixture need the image built as +subzerodev-gameengine-host:smoke and Docker available: + + pwsh -NoProfile -File ./tools/host-smoke/Invoke-HostImage.ps1 -Stage Smoke +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [ValidateSet('Credential', 'Start', 'Smoke', 'Stop', 'NegativeFixture', 'Changed', 'ImageRef', 'Record')] + [string] $Stage +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '../..')).Path +$image = 'subzerodev-gameengine-host:smoke' +$container = 'host-smoke' +$baseUrl = 'http://127.0.0.1:18080' + +function Get-EnvironmentValue { + param ([Parameter(Mandatory)] [string] $Name) + $value = [Environment]::GetEnvironmentVariable($Name) + if ($null -eq $value) { return '' } + return $value +} + +function Add-Output { + param ([Parameter(Mandatory)] [string] $Line) + Write-Host $Line + if ($env:GITHUB_OUTPUT) { Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value $Line } +} + +function Add-Summary { + param ([Parameter(Mandatory)] [string] $Line) + if ($env:GITHUB_STEP_SUMMARY) { Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value $Line } +} + +function Invoke-Native { + param ( + [Parameter(Mandatory)] [string] $Command, + [string[]] $Arguments = @() + ) + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "'$Command $($Arguments -join ' ')' failed with exit code $LASTEXITCODE." + } +} + +function Test-Route { + param ( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [int] $Expected + ) + $code = [int] (curl -s -o /dev/null -w '%{http_code}' "$baseUrl$Path") + if ($code -ne $Expected) { throw "GET $Path -> $code, expected $Expected" } + Write-Host "GET $Path -> $code" +} + +switch ($Stage) { + # SubZeroDev.Platform.Hosting is published from the sibling SubZeroDev.Platform + # repository. The default GITHUB_TOKEN is scoped to this repository and cannot read + # packages published from a different one, so restoring it needs a real cross-repo + # `read:packages` token. Fail loudly rather than let `dotnet restore` fall back to an + # unauthenticated request and fail with an opaque Docker build error. + 'Credential' { + if (-not (Get-EnvironmentValue 'REGISTRY_TOKEN')) { + Write-Host '::error::REGISTRY_TOKEN secret is required to restore SubZeroDev.Platform.Hosting from GitHub Packages in the SubZeroDev.Platform repository. The default GITHUB_TOKEN cannot read packages published from a sibling repository.' + exit 1 + } + } + + 'Start' { + Invoke-Native docker @('run', '-d', '--name', $container, '-p', '18080:8080', $image) + Start-Sleep -Seconds 3 + Invoke-Native docker @('logs', $container) + } + + 'Smoke' { + Test-Route '/' 200 + Test-Route '/roadmap/' 200 + Test-Route '/docs/' 200 + Test-Route '/health/live' 200 + Test-Route '/health/ready' 200 + # No SPA fallback (§4): an unknown route must 404, never the landing page. + Test-Route '/this-route-does-not-exist' 404 + } + + 'Stop' { + Invoke-Native docker @('stop', '--timeout', '10', $container) + docker logs $container | Select-Object -Last 5 + Invoke-Native docker @('rm', $container) + } + + # §7 / W62.7: a deliberate missing-artifact fixture, proving the gate actually fails red + # rather than merely never having been tested. Program.cs's own startup check + # (StaticArtifact.FindMissingRequiredDocuments) is what turns this into a non-zero exit + # rather than a container that starts and silently serves less. + 'NegativeFixture' { + Push-Location $repoRoot + try { + Invoke-Native docker @( + 'build', + '--build-arg', "BASE_IMAGE=$image", + '-f', 'tools/host-smoke/Dockerfile.negative-fixture', + '-t', 'subzerodev-gameengine-host:negative-fixture', + '.') + } finally { + Pop-Location + } + docker run --rm subzerodev-gameengine-host:negative-fixture + if ($LASTEXITCODE -eq 0) { + throw 'Negative fixture started successfully -- it must not. The missing-artifact guard is not proven.' + } + Write-Host 'Negative fixture correctly failed to start.' + } + + 'Changed' { + $before = Get-EnvironmentValue 'BEFORE' + $sha = Get-EnvironmentValue 'SHA' + if (-not $before -or $before -eq ('0' * 40)) { + Write-Host 'No previous commit to diff against -- publishing.' + Add-Output 'changed=true' + return + } + git -C $repoRoot diff --quiet $before $sha -- Dockerfile .dockerignore src/host site docs src/engine + switch ($LASTEXITCODE) { + 0 { Add-Output 'changed=false' } + 1 { Add-Output 'changed=true' } + default { throw "git diff failed with exit code $LASTEXITCODE." } + } + } + + 'ImageRef' { + Add-Output "ref=ghcr.io/$((Get-EnvironmentValue 'REPOSITORY').ToLowerInvariant())-host" + } + + # One immutable full-commit tag, no `latest`, no deployment -- §6's decision summary. + # The digest is recorded in the job summary so a later slice can pin it. + 'Record' { + Add-Summary "Published $(Get-EnvironmentValue 'IMAGE_REF'):$(Get-EnvironmentValue 'SHA')" + Add-Summary "Digest: $(Get-EnvironmentValue 'DIGEST')" + } +}