From 1bb2cc4a20fe0ad13ad14673734ce8db95884f9a Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 2 Aug 2026 13:53:25 +0900 Subject: [PATCH] refactor: generate CI workflow from gate contracts --- .gitea/workflows/quality-gates.yml | 259 +- config/ci/gates.json | 2641 ++++++++++++++--- docs/operations/ci-quality-gates.md | 74 +- docs/security/supply-chain.md | 69 +- ...-08-01-quality-architecture-remediation.md | 14 + package.json | 4 +- scripts/check-ci-contract.ts | 410 +-- .../check-supply-chain-provider-fixtures.ts | 14 + scripts/check-test-evidence.ts | 21 +- scripts/contracts/ci-gates.ts | 993 +++++++ scripts/contracts/promotion-artifacts.ts | 7 + scripts/generate-ci-workflow.ts | 466 +++ scripts/lib/ci-artifact-validator.ts | 453 +++ scripts/lib/ci-candidate-archive-cli.ts | 26 + scripts/lib/ci-candidate-archive.ts | 607 ++++ scripts/lib/ci-contract-report.ts | 45 + scripts/lib/ci-gate-log.ts | 187 ++ scripts/lib/package-script-graph.ts | 54 + scripts/lib/promotion-stager.ts | 293 ++ scripts/lib/promotion-verifier.ts | 131 +- scripts/lib/provider-evidence.ts | 47 +- scripts/lib/provider-upload-validator.ts | 74 + scripts/lib/release-candidate.ts | 8 +- scripts/lib/test-evidence-artifact.ts | 36 + scripts/run-and-validate-provider.ts | 248 ++ scripts/run-ci-gate.ts | 262 +- scripts/stage-verified-promotion.ts | 22 + ...st-browser-file-storage-runtime-removal.ts | 111 +- scripts/test-optional-recipe-removal.ts | 3 + scripts/test-realtime-runtime-removal.ts | 92 +- scripts/test-sample-removal.ts | 2 + scripts/verify-ci-candidate-archive.ts | 37 + scripts/verify-provider-evidence.ts | 8 +- scripts/verify-supply-chain-promotion.ts | 8 +- .../ci-contract/duplicate-gate-id.json | 14 + tests/fixtures/ci-contract/job-cycle.json | 4 + .../ci-contract/missing-artifact-schema.json | 11 + .../ci-contract/multiply-owned-gate.json | 4 + tests/fixtures/ci-contract/unknown-field.json | 12 + .../ci-contract/unknown-job-dependency.json | 4 + tests/fixtures/ci-contract/unowned-gate.json | 4 + .../ci-workflow-generation.test.ts.snap | 409 +++ tests/unit/ci-artifact-contract.test.ts | 1050 +++++++ tests/unit/ci-workflow-generation.test.ts | 462 +++ tests/unit/supply-chain.test.ts | 76 +- 45 files changed, 8599 insertions(+), 1177 deletions(-) create mode 100644 scripts/contracts/ci-gates.ts create mode 100644 scripts/contracts/promotion-artifacts.ts create mode 100644 scripts/generate-ci-workflow.ts create mode 100644 scripts/lib/ci-artifact-validator.ts create mode 100644 scripts/lib/ci-candidate-archive-cli.ts create mode 100644 scripts/lib/ci-candidate-archive.ts create mode 100644 scripts/lib/ci-contract-report.ts create mode 100644 scripts/lib/ci-gate-log.ts create mode 100644 scripts/lib/package-script-graph.ts create mode 100644 scripts/lib/promotion-stager.ts create mode 100644 scripts/lib/provider-upload-validator.ts create mode 100644 scripts/lib/test-evidence-artifact.ts create mode 100644 scripts/run-and-validate-provider.ts create mode 100644 scripts/stage-verified-promotion.ts create mode 100644 scripts/verify-ci-candidate-archive.ts create mode 100644 tests/fixtures/ci-contract/duplicate-gate-id.json create mode 100644 tests/fixtures/ci-contract/job-cycle.json create mode 100644 tests/fixtures/ci-contract/missing-artifact-schema.json create mode 100644 tests/fixtures/ci-contract/multiply-owned-gate.json create mode 100644 tests/fixtures/ci-contract/unknown-field.json create mode 100644 tests/fixtures/ci-contract/unknown-job-dependency.json create mode 100644 tests/fixtures/ci-contract/unowned-gate.json create mode 100644 tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap create mode 100644 tests/unit/ci-artifact-contract.test.ts create mode 100644 tests/unit/ci-workflow-generation.test.ts diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index 463cf31..b0a0638 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -1,3 +1,4 @@ +# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`. name: frontend-quality-gates on: @@ -31,7 +32,7 @@ env: jobs: merge_gate: - name: ${{ matrix.gate }} / ${{ matrix.name }} + name: "${{ matrix.gate }} / ${{ matrix.name }}" if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }} runs-on: ubuntu-latest timeout-minutes: 45 @@ -51,10 +52,12 @@ jobs: - { gate: FE-GATE-010, name: architecture, browser: false } - { gate: FE-GATE-011, name: build, browser: false } - { gate: FE-GATE-013, name: security, browser: false } - - { gate: FE-GATE-020, name: sample-removal, browser: false } + - { gate: FE-GATE-020, name: removability, browser: false } steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version-file: .nvmrc - name: Frozen install @@ -66,22 +69,22 @@ jobs: run: corepack pnpm exec playwright install --with-deps chromium firefox webkit - name: Run blocking gate run: corepack pnpm ci:gate -- ${{ matrix.gate }} - - name: Upload gate evidence + - name: Upload merge gate evidence if: always() - uses: actions/upload-artifact@v4 + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 with: - name: ${{ matrix.gate }}-${{ gitea.run_id }} + name: "${{ matrix.gate }}-${{ gitea.run_id }}" path: artifacts/ if-no-files-found: error release_gate: - name: ${{ matrix.gate }} / ${{ matrix.name }} + name: "${{ matrix.gate }} / ${{ matrix.name }}" needs: merge_gate if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }} runs-on: ubuntu-latest timeout-minutes: 45 env: - HOSTING_BASE_URL: ${{ vars.HOSTING_BASE_URL }} + HOSTING_BASE_URL: "${{ vars.HOSTING_BASE_URL }}" strategy: fail-fast: false matrix: @@ -91,8 +94,10 @@ jobs: - { gate: FE-GATE-019, name: hosting-header, browser: false } - { gate: FE-GATE-026, name: lab-performance, browser: true } steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version-file: .nvmrc - name: Frozen install @@ -104,25 +109,28 @@ jobs: run: corepack pnpm exec playwright install --with-deps chromium firefox webkit - name: Run blocking gate run: corepack pnpm ci:gate -- ${{ matrix.gate }} - - name: Upload gate evidence + - name: Upload release gate evidence if: always() - uses: actions/upload-artifact@v4 + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 with: - name: ${{ matrix.gate }}-${{ gitea.run_id }} + name: "${{ matrix.gate }}-${{ gitea.run_id }}" path: artifacts/ if-no-files-found: error immutable_build: - name: FE-GATE-015 / immutable-release-candidate + name: "FE-GATE-015 / immutable-release-candidate" needs: release_gate if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }} runs-on: ubuntu-latest timeout-minutes: 45 outputs: dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }} + archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version-file: .nvmrc - name: Frozen install @@ -131,7 +139,7 @@ jobs: corepack pnpm install --frozen-lockfile - name: Build candidate once and verify local evidence run: corepack pnpm ci:gate -- FE-GATE-015 - - name: Archive the exact candidate file set + - name: Archive and validate the exact candidate file set id: candidate run: | mkdir -p .release @@ -144,7 +152,6 @@ jobs: artifacts/release/checksums.txt \ artifacts/release/dependency-inventory.json \ artifacts/release/provenance.json \ - artifacts/release/release-candidate.json \ artifacts/release/verification.json \ artifacts/release/sbom.cdx.json \ artifacts/security/dependency-diff.json \ @@ -152,13 +159,14 @@ jobs: artifacts/security/scan.sarif \ artifacts/security/supply-chain-coherence.json \ artifacts/security/supply-chain-verification.json \ - artifacts/security/vulnerability-report.json - node -e "const m=require('./artifacts/release/release-candidate.json'); process.stdout.write('dist_sha256='+m.distSha256+'\\n')" >> "$GITHUB_OUTPUT" - - name: Upload immutable candidate archive - uses: actions/upload-artifact@v4 + artifacts/security/vulnerability-report.json \ + artifacts/release/release-candidate.json + node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT" + - name: Upload release candidate + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 with: - name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }} - path: .release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz + name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" if-no-files-found: error vulnerability_provider: @@ -167,28 +175,39 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 env: - CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }} - CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml - VULNERABILITY_PROVIDER_COMMAND: ${{ vars.VULNERABILITY_PROVIDER_COMMAND }} - VULNERABILITY_REPORT_PATH: provider-evidence/vulnerability-report.json + CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}" + CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + CANDIDATE_DIST_SHA256: "${{ needs.immutable_build.outputs.dist_sha256 }}" + CANDIDATE_LOCKFILE_PATH: .release/verified-vulnerability/pnpm-lock.yaml + VULNERABILITY_PROVIDER_COMMAND: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" + VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json + VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json steps: - - name: Download immutable candidate archive - uses: actions/download-artifact@v4 + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: - name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }} - path: .release/vulnerability-candidate - - name: Extract the provider input candidate - run: tar -xzf ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" - - name: Run configured external vulnerability provider + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install run: | - test -n "$VULNERABILITY_PROVIDER_COMMAND" - mkdir -p provider-evidence - sh -eu -c "$VULNERABILITY_PROVIDER_COMMAND" - test -s "$VULNERABILITY_REPORT_PATH" - - name: Upload external vulnerability report - uses: actions/upload-artifact@v4 + corepack enable + corepack pnpm install --frozen-lockfile + - name: Download release candidate + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: - name: vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }} + name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: .release/vulnerability-candidate + - name: Verify and extract the candidate through one inode-bound operation + run: node scripts/verify-ci-candidate-archive.ts --archive ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-vulnerability" + - name: Run and validate external vulnerability provider in one trusted supervisor + run: node scripts/run-and-validate-provider.ts --kind vulnerability + - name: Confirm sealed vulnerability provider evidence + run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" + - name: Upload vulnerability provider evidence + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: provider-evidence/vulnerability-report.json if-no-files-found: error @@ -198,28 +217,39 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 env: - CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }} - CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml - PROVENANCE_PROVIDER_COMMAND: ${{ vars.PROVENANCE_PROVIDER_COMMAND }} - PROVENANCE_ATTESTATION_PATH: provider-evidence/provenance-attestation.json + CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}" + CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + CANDIDATE_DIST_SHA256: "${{ needs.immutable_build.outputs.dist_sha256 }}" + CANDIDATE_LOCKFILE_PATH: .release/verified-provenance/pnpm-lock.yaml + PROVENANCE_PROVIDER_COMMAND: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" + PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json + VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json steps: - - name: Download immutable candidate archive - uses: actions/download-artifact@v4 + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: - name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }} - path: .release/provenance-candidate - - name: Extract the provider input candidate - run: tar -xzf ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" - - name: Run configured external provenance provider + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install run: | - test -n "$PROVENANCE_PROVIDER_COMMAND" - mkdir -p provider-evidence - sh -eu -c "$PROVENANCE_PROVIDER_COMMAND" - test -s "$PROVENANCE_ATTESTATION_PATH" - - name: Upload external provenance attestation - uses: actions/upload-artifact@v4 + corepack enable + corepack pnpm install --frozen-lockfile + - name: Download release candidate + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: - name: provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }} + name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: .release/provenance-candidate + - name: Verify and extract the candidate through one inode-bound operation + run: node scripts/verify-ci-candidate-archive.ts --archive ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-provenance" + - name: Run and validate external provenance provider in one trusted supervisor + run: node scripts/run-and-validate-provider.ts --kind provenance + - name: Confirm sealed provenance provider evidence + run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" + - name: Upload provenance provider evidence + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: provider-evidence/provenance-attestation.json if-no-files-found: error @@ -229,56 +259,59 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 env: - VULNERABILITY_REPORT_PATH: .release/vulnerability/vulnerability-report.json - PROVENANCE_ATTESTATION_PATH: .release/provenance/provenance-attestation.json - VULNERABILITY_PUBLIC_KEY_PATH: ${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }} - VULNERABILITY_KEY_ID: ${{ vars.VULNERABILITY_KEY_ID }} - PROVENANCE_PUBLIC_KEY_PATH: ${{ vars.PROVENANCE_PUBLIC_KEY_PATH }} - PROVENANCE_KEY_ID: ${{ vars.PROVENANCE_KEY_ID }} + CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}" + CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + CANDIDATE_ROOT: "${{ gitea.workspace }}/.release/verified-candidate" + VULNERABILITY_REPORT_PATH: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" + PROVENANCE_ATTESTATION_PATH: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" + VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" + VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}" + PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" + PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version-file: .nvmrc - name: Frozen install run: | corepack enable corepack pnpm install --frozen-lockfile - - name: Download immutable candidate archive - uses: actions/download-artifact@v4 + - name: Download release candidate + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: - name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }} + name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: .release/candidate - name: Download vulnerability provider evidence - uses: actions/download-artifact@v4 + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: - name: vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }} + name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: .release/vulnerability - name: Download provenance provider evidence - uses: actions/download-artifact@v4 + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: - name: provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }} + name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: .release/provenance - - name: Extract unchanged candidate - run: tar -xzf ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" - - name: Verify provider signatures and candidate digests - run: | - corepack pnpm verify:provider-evidence - corepack pnpm verify:promotion - - name: Upload promoted unchanged candidate - uses: actions/upload-artifact@v4 + - name: Verify and extract the candidate through one inode-bound operation + run: node scripts/verify-ci-candidate-archive.ts --archive ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-candidate" + - name: Finalize verified promotion from inode-bound captured inputs + run: node scripts/stage-verified-promotion.ts + - name: Upload promoted release + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 with: - name: promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }} + name: "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: | - .release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz - .release/vulnerability/vulnerability-report.json - .release/provenance/provenance-attestation.json - artifacts/security/provider-verification.json - artifacts/security/promotion-verification.json + .release/promoted-staging/release-candidate.tar.gz + .release/promoted-staging/vulnerability-report.json + .release/promoted-staging/provenance-attestation.json + .release/promoted-staging/provider-verification.json + .release/promoted-staging/promotion-verification.json if-no-files-found: error production_gate: - name: ${{ matrix.gate }} / ${{ matrix.name }} + name: "${{ matrix.gate }} / ${{ matrix.name }}" needs: promotion if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }} runs-on: ubuntu-latest @@ -294,8 +327,10 @@ jobs: - { gate: FE-GATE-024, name: runbook-telemetry } - { gate: FE-GATE-025, name: runbook-release-rollback } steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version-file: .nvmrc - name: Frozen install @@ -304,26 +339,28 @@ jobs: corepack pnpm install --frozen-lockfile - name: Run blocking gate run: corepack pnpm ci:gate -- ${{ matrix.gate }} - - name: Upload gate evidence + - name: Upload production gate evidence if: always() - uses: actions/upload-artifact@v4 + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 with: - name: ${{ matrix.gate }}-${{ gitea.run_id }} + name: "${{ matrix.gate }}-${{ gitea.run_id }}" path: artifacts/ if-no-files-found: error field_gate: - name: FE-GATE-018 / field-web-vitals + name: "FE-GATE-018 / field-web-vitals" needs: production_gate if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }} runs-on: ubuntu-latest timeout-minutes: 45 env: - FIELD_WEB_VITALS_INPUT: ${{ vars.FIELD_WEB_VITALS_INPUT }} - MIN_ELIGIBLE_SAMPLES: ${{ vars.MIN_ELIGIBLE_SAMPLES }} + FIELD_WEB_VITALS_INPUT: "${{ vars.FIELD_WEB_VITALS_INPUT }}" + MIN_ELIGIBLE_SAMPLES: "${{ vars.MIN_ELIGIBLE_SAMPLES }}" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version-file: .nvmrc - name: Frozen install @@ -332,22 +369,24 @@ jobs: corepack pnpm install --frozen-lockfile - name: Run blocking gate run: corepack pnpm ci:gate -- FE-GATE-018 - - name: Upload gate evidence + - name: Upload field gate evidence if: always() - uses: actions/upload-artifact@v4 + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 with: - name: FE-GATE-018-${{ gitea.run_id }} + name: "FE-GATE-018-${{ gitea.run_id }}" path: artifacts/ if-no-files-found: error documentation_gate: - name: FE-GATE-017 / diagram-review + name: "FE-GATE-017 / diagram-review" if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }} runs-on: ubuntu-latest timeout-minutes: 45 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version-file: .nvmrc - name: Frozen install @@ -356,10 +395,10 @@ jobs: corepack pnpm install --frozen-lockfile - name: Run documentation gate run: corepack pnpm ci:gate -- FE-GATE-017 - - name: Upload gate evidence + - name: Upload documentation gate evidence if: always() - uses: actions/upload-artifact@v4 + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 with: - name: FE-GATE-017-${{ gitea.run_id }} + name: "FE-GATE-017-${{ gitea.run_id }}" path: artifacts/ if-no-files-found: error diff --git a/config/ci/gates.json b/config/ci/gates.json index 5d3aa8f..b67c174 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -1,11 +1,1629 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "providerAdapter": ".gitea/workflows/quality-gates.yml", - "stages": { - "merge": { + "commands": [ + { + "id": "verify-lockfile", + "script": "verify:lockfile", + "expect": "pass" + }, + { + "id": "check-frozen-lockfile-fixture", + "script": "check:frozen-lockfile:fixture", + "expect": "pass" + }, + { + "id": "lint", + "script": "lint", + "expect": "pass" + }, + { + "id": "check-types", + "script": "check:types", + "expect": "pass" + }, + { + "id": "check-types-recipes", + "script": "check:types:recipes", + "expect": "pass" + }, + { + "id": "check-types-fixture", + "script": "check:types:fixture", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2322:" + }, + { + "id": "check-types-fixture-ts-port", + "script": "check:types:fixture:ts-port", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2322:" + }, + { + "id": "check-types-fixture-ts-result", + "script": "check:types:fixture:ts-result", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2339:" + }, + { + "id": "check-types-fixture-application-output", + "script": "check:types:fixture:application-output", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2322:" + }, + { + "id": "check-types-fixture-application-input", + "script": "check:types:fixture:application-input", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2739:" + }, + { + "id": "check-types-fixture-feature-input", + "script": "check:types:fixture:feature-input", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2345:" + }, + { + "id": "check-types-fixture-failure-kind", + "script": "check:types:fixture:failure-kind", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2322:" + }, + { + "id": "check-types-fixture-reference-operation", + "script": "check:types:fixture:reference-operation", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2345:" + }, + { + "id": "check-types-fixture-async-overlay", + "script": "check:types:fixture:async-overlay", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2322:" + }, + { + "id": "check-types-fixture-route-runtime", + "script": "check:types:fixture:route-runtime", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2353:" + }, + { + "id": "check-types-fixture-page-action", + "script": "check:types:fixture:page-action", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS1360:" + }, + { + "id": "check-types-fixture-icon-button", + "script": "check:types:fixture:icon-button", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2741:" + }, + { + "id": "check-types-fixture-i18n-key", + "script": "check:types:fixture:i18n-key", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2345:" + }, + { + "id": "check-types-fixture-i18n-params", + "script": "check:types:fixture:i18n-params", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2554:" + }, + { + "id": "check-types-fixture-diagnostics", + "script": "check:types:fixture:diagnostics", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "UNKNOWN_DIAGNOSTIC_EVENT" + }, + { + "id": "test-runtime-schema", + "script": "test:runtime-schema", + "expect": "pass" + }, + { + "id": "test-unit", + "script": "test:unit", + "expect": "pass" + }, + { + "id": "test-coverage", + "script": "test:coverage", + "expect": "pass" + }, + { + "id": "check-coverage-fixture", + "script": "check:coverage:fixture", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "Risk coverage failed:" + }, + { + "id": "test-component", + "script": "test:component", + "expect": "pass" + }, + { + "id": "test-integration", + "script": "test:integration", + "expect": "pass" + }, + { + "id": "test-http-scenario-evidence", + "script": "test:http-scenario-evidence", + "expect": "pass" + }, + { + "id": "test-reference-feature", + "script": "test:reference-feature", + "expect": "pass" + }, + { + "id": "test-recipes", + "script": "test:recipes", + "expect": "pass" + }, + { + "id": "test-e2e", + "script": "test:e2e", + "expect": "pass" + }, + { + "id": "test-browser-capabilities", + "script": "test:browser-capabilities", + "expect": "pass" + }, + { + "id": "verify-browser-capability-evidence", + "script": "verify:browser-capability-evidence", + "expect": "pass" + }, + { + "id": "test-storybook", + "script": "test:storybook", + "expect": "pass" + }, + { + "id": "test-visual", + "script": "test:visual", + "expect": "pass" + }, + { + "id": "check-test-evidence-browser", + "script": "check:test-evidence:browser", + "expect": "pass" + }, + { + "id": "check-test-evidence-fixture", + "script": "check:test-evidence:fixture", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "Test evidence failed:" + }, + { + "id": "test-a11y", + "script": "test:a11y", + "expect": "pass" + }, + { + "id": "review-a11y-manual", + "script": "review:a11y-manual", + "expect": "pass" + }, + { + "id": "check-architecture", + "script": "check:architecture", + "expect": "pass" + }, + { + "id": "check-design-system", + "script": "check:design-system", + "expect": "pass" + }, + { + "id": "check-design-system-fixture", + "script": "check:design-system:fixture", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "Design system contract failed:" + }, + { + "id": "check-i18n", + "script": "check:i18n", + "expect": "pass" + }, + { + "id": "check-i18n-fixture", + "script": "check:i18n:fixture", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "I18n contract failed:" + }, + { + "id": "check-diagnostics", + "script": "check:diagnostics", + "expect": "pass" + }, + { + "id": "check-diagnostics-fixture", + "script": "check:diagnostics:fixture", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "Diagnostics contract failed:" + }, + { + "id": "check-browser-file-storage-boundaries", + "script": "check:browser-file-storage-boundaries", + "expect": "pass" + }, + { + "id": "check-realtime-boundaries", + "script": "check:realtime-boundaries", + "expect": "pass" + }, + { + "id": "check-realtime-boundaries-fixture", + "script": "check:realtime-boundaries:fixture", + "expect": "pass" + }, + { + "id": "check-optional-recipes-source", + "script": "check:optional-recipes:source", + "expect": "pass" + }, + { + "id": "check-optional-recipe-fixtures", + "script": "check:optional-recipe-fixtures", + "expect": "pass" + }, + { + "id": "check-registries", + "script": "check:registries", + "expect": "pass" + }, + { + "id": "check-registries-compatibility-fixtures", + "script": "check:registries:compatibility-fixtures", + "expect": "pass" + }, + { + "id": "check-registries-baseline-fixture", + "script": "check:registries:baseline-fixture", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "registry baseline approval digest mismatch:" + }, + { + "id": "check-registries-fixture", + "script": "check:registries:fixture", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "duplicates id=duplicate" + }, + { + "id": "check-routes-fixture", + "script": "check:routes:fixture", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "duplicates routeId=DUPLICATE" + }, + { + "id": "build", + "script": "build", + "expect": "pass" + }, + { + "id": "build-storybook", + "script": "build:storybook", + "expect": "pass" + }, + { + "id": "check-bundle", + "script": "check:bundle", + "expect": "pass" + }, + { + "id": "check-optional-recipes", + "script": "check:optional-recipes", + "expect": "pass" + }, + { + "id": "verify-reproducible-build", + "script": "verify:reproducible-build", + "expect": "pass" + }, + { + "id": "build-release-candidate", + "script": "build:release-candidate", + "expect": "pass" + }, + { + "id": "verify-local-evidence", + "script": "verify:local-evidence", + "expect": "pass" + }, + { + "id": "check-supply-chain-fixtures", + "script": "check:supply-chain:fixtures", + "expect": "pass" + }, + { + "id": "check-supply-chain-provider-fixtures", + "script": "check:supply-chain:provider-fixtures", + "expect": "pass" + }, + { + "id": "check-security-fixtures", + "script": "check:security:fixtures", + "expect": "pass" + }, + { + "id": "check-browser-security", + "script": "check:browser-security", + "expect": "pass" + }, + { + "id": "verify-compatibility", + "script": "verify:compatibility", + "expect": "pass" + }, + { + "id": "drill-runbook", + "script": "drill:runbook", + "args": [ + "--", + "FE-RB-005" + ], + "expect": "pass" + }, + { + "id": "verify-documentation", + "script": "verify:documentation", + "expect": "pass" + }, + { + "id": "collect-web-vitals-evidence", + "script": "collect:web-vitals-evidence", + "expect": "pass" + }, + { + "id": "verify-hosting-headers", + "script": "verify:hosting-headers", + "expect": "pass" + }, + { + "id": "test-sample-removal", + "script": "test:sample-removal", + "expect": "pass" + }, + { + "id": "test-optional-recipe-removal", + "script": "test:optional-recipe-removal", + "expect": "pass" + }, + { + "id": "test-browser-file-storage-removal", + "script": "test:browser-file-storage-removal", + "expect": "pass" + }, + { + "id": "test-realtime-removal", + "script": "test:realtime-removal", + "expect": "pass" + }, + { + "id": "drill-runbook-2", + "script": "drill:runbook", + "args": [ + "--", + "FE-RB-001" + ], + "expect": "pass" + }, + { + "id": "drill-runbook-3", + "script": "drill:runbook", + "args": [ + "--", + "FE-RB-002" + ], + "expect": "pass" + }, + { + "id": "drill-runbook-4", + "script": "drill:runbook", + "args": [ + "--", + "FE-RB-003" + ], + "expect": "pass" + }, + { + "id": "drill-runbook-5", + "script": "drill:runbook", + "args": [ + "--", + "FE-RB-004" + ], + "expect": "pass" + }, + { + "id": "test-performance", + "script": "test:performance", + "expect": "pass" + }, + { + "id": "check-ci", + "script": "check:ci", + "expect": "pass" + } + ], + "artifactSchemas": [ + { + "id": "text", + "kind": "text", + "maxBytes": 67108864 + }, + { + "id": "json-build-manifest", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "build-manifest" + }, + { + "id": "json-bundle-performance", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "bundle-performance" + }, + { + "id": "json-coverage-summary-v8", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "coverage-summary-v8" + }, + { + "id": "json-dependency-diff", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "dependency-diff" + }, + { + "id": "json-dependency-inventory", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "dependency-inventory" + }, + { + "id": "json-field-web-vitals", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "field-web-vitals" + }, + { + "id": "json-generic-json-object", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "generic-json-object" + }, + { + "id": "json-ci-contract-report", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "ci-contract-report" + }, + { + "id": "json-schema-document", + "kind": "json-schema", + "maxBytes": 67108864 + }, + { + "id": "json-http-scenario-receipt", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "http-scenario-receipt" + }, + { + "id": "json-lab-performance", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "lab-performance" + }, + { + "id": "json-license-report", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "license-report" + }, + { + "id": "json-provenance", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "provenance" + }, + { + "id": "json-registry-governance-run", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "registry-governance-run" + }, + { + "id": "json-registry-snapshot", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "registry-snapshot" + }, + { + "id": "json-release-candidate", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "release-candidate" + }, + { + "id": "json-release-verification", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "release-verification" + }, + { + "id": "json-risk-coverage-v3", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "risk-coverage-v3" + }, + { + "id": "json-runbook-record", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "runbook-record" + }, + { + "id": "json-sbom", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "sbom" + }, + { + "id": "json-supply-chain-coherence", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "supply-chain-coherence" + }, + { + "id": "json-supply-chain-verification", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "supply-chain-verification" + }, + { + "id": "json-test-evidence-report", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "test-evidence-report" + }, + { + "id": "json-vulnerability-report", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "vulnerability-report" + }, + { + "id": "junit", + "kind": "junit", + "maxBytes": 67108864 + }, + { + "id": "html", + "kind": "html", + "maxBytes": 67108864 + }, + { + "id": "markdown", + "kind": "markdown", + "maxBytes": 16777216 + }, + { + "id": "sarif-secret-scan", + "kind": "sarif", + "maxBytes": 67108864 + } + ], + "artifacts": [ + { + "id": "artifact-artifacts-quality-install-txt", + "path": "artifacts/quality/install.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-quality-lint-txt", + "path": "artifacts/quality/lint.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-quality-check-types-txt", + "path": "artifacts/quality/check-types.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-004-txt", + "path": "artifacts/quality/gates/FE-GATE-004.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-tests-runtime-schema-xml", + "path": "artifacts/tests/runtime-schema.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-005-txt", + "path": "artifacts/quality/gates/FE-GATE-005.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-tests-unit-xml", + "path": "artifacts/tests/unit.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-coverage-xml", + "path": "artifacts/tests/coverage.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-coverage-coverage-summary-json", + "path": "artifacts/tests/coverage/coverage-summary.json", + "schemaId": "json-coverage-summary-v8" + }, + { + "id": "artifact-artifacts-quality-risk-coverage-json", + "path": "artifacts/quality/risk-coverage.json", + "schemaId": "json-risk-coverage-v3" + }, + { + "id": "artifact-artifacts-quality-risk-coverage-fixture-json", + "path": "artifacts/quality/risk-coverage-fixture.json", + "schemaId": "json-risk-coverage-v3" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-006-txt", + "path": "artifacts/quality/gates/FE-GATE-006.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-tests-component-xml", + "path": "artifacts/tests/component.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-007-txt", + "path": "artifacts/quality/gates/FE-GATE-007.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-tests-integration-xml", + "path": "artifacts/tests/integration.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-http-scenario-executions-json", + "path": "artifacts/tests/http-scenario-executions.json", + "schemaId": "json-http-scenario-receipt" + }, + { + "id": "artifact-artifacts-quality-http-scenario-evidence-json", + "path": "artifacts/quality/http-scenario-evidence.json", + "schemaId": "json-test-evidence-report" + }, + { + "id": "artifact-artifacts-quality-http-scenario-evidence-fixture-json", + "path": "artifacts/quality/http-scenario-evidence-fixture.json", + "schemaId": "json-test-evidence-report" + }, + { + "id": "artifact-artifacts-tests-reference-feature-xml", + "path": "artifacts/tests/reference-feature.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-optional-recipes-xml", + "path": "artifacts/tests/optional-recipes.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-008-txt", + "path": "artifacts/quality/gates/FE-GATE-008.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-tests-e2e-report-index-html", + "path": "artifacts/tests/e2e/report/index.html", + "schemaId": "html" + }, + { + "id": "artifact-artifacts-tests-e2e-results-xml", + "path": "artifacts/tests/e2e/results.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-browser-capabilities-report-index-html", + "path": "artifacts/tests/browser-capabilities/report/index.html", + "schemaId": "html" + }, + { + "id": "artifact-artifacts-tests-browser-capabilities-results-xml", + "path": "artifacts/tests/browser-capabilities/results.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-storybook-report-index-html", + "path": "artifacts/tests/storybook/report/index.html", + "schemaId": "html" + }, + { + "id": "artifact-artifacts-tests-storybook-results-xml", + "path": "artifacts/tests/storybook/results.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-visual-report-index-html", + "path": "artifacts/tests/visual/report/index.html", + "schemaId": "html" + }, + { + "id": "artifact-artifacts-tests-visual-results-xml", + "path": "artifacts/tests/visual/results.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-quality-test-evidence-json", + "path": "artifacts/quality/test-evidence.json", + "schemaId": "json-test-evidence-report" + }, + { + "id": "artifact-artifacts-quality-test-evidence-fixture-json", + "path": "artifacts/quality/test-evidence-fixture.json", + "schemaId": "json-test-evidence-report" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-009-txt", + "path": "artifacts/quality/gates/FE-GATE-009.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-tests-a11y-json", + "path": "artifacts/tests/a11y.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-tests-a11y-manual-APP-HOME-md", + "path": "artifacts/tests/a11y-manual/APP_HOME.md", + "schemaId": "markdown" + }, + { + "id": "artifact-artifacts-tests-a11y-manual-EXAMPLES-UI-md", + "path": "artifacts/tests/a11y-manual/EXAMPLES_UI.md", + "schemaId": "markdown" + }, + { + "id": "artifact-artifacts-tests-a11y-manual-EXAMPLES-STATES-md", + "path": "artifacts/tests/a11y-manual/EXAMPLES_STATES.md", + "schemaId": "markdown" + }, + { + "id": "artifact-artifacts-tests-a11y-manual-EXAMPLES-AUTH-md", + "path": "artifacts/tests/a11y-manual/EXAMPLES_AUTH.md", + "schemaId": "markdown" + }, + { + "id": "artifact-artifacts-tests-a11y-manual-REFERENCE-RESOURCE-LIST-md", + "path": "artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md", + "schemaId": "markdown" + }, + { + "id": "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", + "path": "artifacts/tests/a11y-manual/NOT_FOUND.md", + "schemaId": "markdown" + }, + { + "id": "artifact-artifacts-tests-a11y-manual-report-json", + "path": "artifacts/tests/a11y-manual/report.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-010-txt", + "path": "artifacts/quality/gates/FE-GATE-010.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-quality-dependency-report-json", + "path": "artifacts/quality/dependency-report.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-design-system-json", + "path": "artifacts/quality/design-system.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-design-system-fixture-json", + "path": "artifacts/quality/design-system-fixture.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-i18n-json", + "path": "artifacts/quality/i18n.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-i18n-fixture-json", + "path": "artifacts/quality/i18n-fixture.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-diagnostics-json", + "path": "artifacts/quality/diagnostics.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-diagnostics-fixture-json", + "path": "artifacts/quality/diagnostics-fixture.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-realtime-boundaries-json", + "path": "artifacts/quality/realtime-boundaries.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-optional-recipes-json", + "path": "artifacts/quality/optional-recipes.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-optional-recipe-fixtures-json", + "path": "artifacts/quality/optional-recipe-fixtures.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-registries-json", + "path": "artifacts/quality/registries.json", + "schemaId": "json-registry-snapshot" + }, + { + "id": "artifact-artifacts-quality-registry-compatibility-fixtures-json", + "path": "artifacts/quality/registry-compatibility-fixtures.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-registry-baseline-fixture-json", + "path": "artifacts/quality/registry-baseline-fixture.json", + "schemaId": "json-registry-governance-run" + }, + { + "id": "artifact-artifacts-quality-registry-fixture-json", + "path": "artifacts/quality/registry-fixture.json", + "schemaId": "json-registry-governance-run" + }, + { + "id": "artifact-artifacts-quality-route-registry-fixture-json", + "path": "artifacts/quality/route-registry-fixture.json", + "schemaId": "json-registry-governance-run" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-011-txt", + "path": "artifacts/quality/gates/FE-GATE-011.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-release-build-manifest-json", + "path": "artifacts/release/build-manifest.json", + "schemaId": "json-build-manifest" + }, + { + "id": "artifact-artifacts-release-runtime-config-schema-json", + "path": "artifacts/release/runtime-config.schema.json", + "schemaId": "json-schema-document" + }, + { + "id": "artifact-artifacts-storybook-static-index-html", + "path": "artifacts/storybook/static/index.html", + "schemaId": "html" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-012-txt", + "path": "artifacts/quality/gates/FE-GATE-012.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-performance-bundle-json", + "path": "artifacts/performance/bundle.json", + "schemaId": "json-bundle-performance" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-013-txt", + "path": "artifacts/quality/gates/FE-GATE-013.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-security-scan-sarif", + "path": "artifacts/security/scan.sarif", + "schemaId": "sarif-secret-scan" + }, + { + "id": "artifact-artifacts-release-dependency-inventory-json", + "path": "artifacts/release/dependency-inventory.json", + "schemaId": "json-dependency-inventory" + }, + { + "id": "artifact-artifacts-release-sbom-cdx-json", + "path": "artifacts/release/sbom.cdx.json", + "schemaId": "json-sbom" + }, + { + "id": "artifact-artifacts-release-provenance-json", + "path": "artifacts/release/provenance.json", + "schemaId": "json-provenance" + }, + { + "id": "artifact-artifacts-release-reproducible-build-json", + "path": "artifacts/release/reproducible-build.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-security-dependency-diff-json", + "path": "artifacts/security/dependency-diff.json", + "schemaId": "json-dependency-diff" + }, + { + "id": "artifact-artifacts-security-license-report-json", + "path": "artifacts/security/license-report.json", + "schemaId": "json-license-report" + }, + { + "id": "artifact-artifacts-security-vulnerability-report-json", + "path": "artifacts/security/vulnerability-report.json", + "schemaId": "json-vulnerability-report" + }, + { + "id": "artifact-artifacts-security-supply-chain-verification-json", + "path": "artifacts/security/supply-chain-verification.json", + "schemaId": "json-supply-chain-verification" + }, + { + "id": "artifact-artifacts-security-supply-chain-coherence-json", + "path": "artifacts/security/supply-chain-coherence.json", + "schemaId": "json-supply-chain-coherence" + }, + { + "id": "artifact-artifacts-security-supply-chain-fixtures-json", + "path": "artifacts/security/supply-chain-fixtures.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-security-supply-chain-provider-fixtures-json", + "path": "artifacts/security/supply-chain-provider-fixtures.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-014-txt", + "path": "artifacts/quality/gates/FE-GATE-014.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-release-compatibility-json", + "path": "artifacts/release/compatibility.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-015-txt", + "path": "artifacts/quality/gates/FE-GATE-015.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-release-release-candidate-json", + "path": "artifacts/release/release-candidate.json", + "schemaId": "json-release-candidate" + }, + { + "id": "artifact-artifacts-release-verification-json", + "path": "artifacts/release/verification.json", + "schemaId": "json-release-verification" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-016-txt", + "path": "artifacts/quality/gates/FE-GATE-016.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-runbooks-FE-RB-005-record-json", + "path": "artifacts/runbooks/FE-RB-005/record.json", + "schemaId": "json-runbook-record" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-017-txt", + "path": "artifacts/quality/gates/FE-GATE-017.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-quality-documentation-review-json", + "path": "artifacts/quality/documentation-review.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-018-txt", + "path": "artifacts/quality/gates/FE-GATE-018.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-performance-field-web-vitals-json", + "path": "artifacts/performance/field-web-vitals.json", + "schemaId": "json-field-web-vitals" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-019-txt", + "path": "artifacts/quality/gates/FE-GATE-019.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-release-hosting-headers-json", + "path": "artifacts/release/hosting-headers.json", + "schemaId": "json-generic-json-object" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-020-txt", + "path": "artifacts/quality/gates/FE-GATE-020.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-tests-sample-removal-xml", + "path": "artifacts/tests/sample-removal.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-optional-recipe-removal-xml", + "path": "artifacts/tests/optional-recipe-removal.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-browser-file-storage-runtime-removal-xml", + "path": "artifacts/tests/browser-file-storage-runtime-removal.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-tests-realtime-runtime-removal-xml", + "path": "artifacts/tests/realtime-runtime-removal.xml", + "schemaId": "junit" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-021-txt", + "path": "artifacts/quality/gates/FE-GATE-021.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-runbooks-FE-RB-001-record-json", + "path": "artifacts/runbooks/FE-RB-001/record.json", + "schemaId": "json-runbook-record" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-022-txt", + "path": "artifacts/quality/gates/FE-GATE-022.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-runbooks-FE-RB-002-record-json", + "path": "artifacts/runbooks/FE-RB-002/record.json", + "schemaId": "json-runbook-record" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-023-txt", + "path": "artifacts/quality/gates/FE-GATE-023.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-runbooks-FE-RB-003-record-json", + "path": "artifacts/runbooks/FE-RB-003/record.json", + "schemaId": "json-runbook-record" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-024-txt", + "path": "artifacts/quality/gates/FE-GATE-024.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-runbooks-FE-RB-004-record-json", + "path": "artifacts/runbooks/FE-RB-004/record.json", + "schemaId": "json-runbook-record" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-025-txt", + "path": "artifacts/quality/gates/FE-GATE-025.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-026-txt", + "path": "artifacts/quality/gates/FE-GATE-026.txt", + "schemaId": "text" + }, + { + "id": "artifact-artifacts-performance-lab-json", + "path": "artifacts/performance/lab.json", + "schemaId": "json-lab-performance" + }, + { + "id": "artifact-artifacts-quality-ci-contract-json", + "path": "artifacts/quality/ci-contract.json", + "schemaId": "json-ci-contract-report" + } + ], + "gates": [ + { + "id": "FE-GATE-001", + "name": "manifest-lockfile", + "commandIds": [ + "verify-lockfile", + "check-frozen-lockfile-fixture" + ], + "logArtifactId": "artifact-artifacts-quality-install-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-quality-install-txt" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-002", + "name": "lint", + "commandIds": [ + "lint" + ], + "logArtifactId": "artifact-artifacts-quality-lint-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-quality-lint-txt" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-003", + "name": "typecheck", + "commandIds": [ + "check-types", + "check-types-recipes", + "check-types-fixture", + "check-types-fixture-ts-port", + "check-types-fixture-ts-result", + "check-types-fixture-application-output", + "check-types-fixture-application-input", + "check-types-fixture-feature-input", + "check-types-fixture-failure-kind", + "check-types-fixture-reference-operation", + "check-types-fixture-async-overlay", + "check-types-fixture-route-runtime", + "check-types-fixture-page-action", + "check-types-fixture-icon-button", + "check-types-fixture-i18n-key", + "check-types-fixture-i18n-params", + "check-types-fixture-diagnostics" + ], + "logArtifactId": "artifact-artifacts-quality-check-types-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-quality-check-types-txt" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-004", + "name": "runtime-schema", + "commandIds": [ + "test-runtime-schema" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-004-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-tests-runtime-schema-xml" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-005", + "name": "unit", + "commandIds": [ + "test-unit", + "test-coverage", + "check-coverage-fixture" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-005-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-tests-unit-xml", + "artifact-artifacts-tests-coverage-xml", + "artifact-artifacts-tests-coverage-coverage-summary-json", + "artifact-artifacts-quality-risk-coverage-json", + "artifact-artifacts-quality-risk-coverage-fixture-json" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-006", + "name": "component", + "commandIds": [ + "test-component" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-006-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-tests-component-xml" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-007", + "name": "integration", + "commandIds": [ + "test-integration", + "test-http-scenario-evidence", + "test-reference-feature", + "test-recipes" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-007-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-tests-integration-xml", + "artifact-artifacts-tests-http-scenario-executions-json", + "artifact-artifacts-quality-http-scenario-evidence-json", + "artifact-artifacts-quality-http-scenario-evidence-fixture-json", + "artifact-artifacts-tests-reference-feature-xml", + "artifact-artifacts-tests-optional-recipes-xml" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-008", + "name": "e2e", + "commandIds": [ + "test-e2e", + "test-browser-capabilities", + "verify-browser-capability-evidence", + "test-storybook", + "test-visual", + "check-test-evidence-browser", + "check-test-evidence-fixture" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-008-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-tests-e2e-report-index-html", + "artifact-artifacts-tests-e2e-results-xml", + "artifact-artifacts-tests-browser-capabilities-report-index-html", + "artifact-artifacts-tests-browser-capabilities-results-xml", + "artifact-artifacts-tests-storybook-report-index-html", + "artifact-artifacts-tests-storybook-results-xml", + "artifact-artifacts-tests-visual-report-index-html", + "artifact-artifacts-tests-visual-results-xml", + "artifact-artifacts-quality-test-evidence-json", + "artifact-artifacts-quality-test-evidence-fixture-json" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-009", + "name": "accessibility", + "commandIds": [ + "test-a11y", + "review-a11y-manual" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-009-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-tests-a11y-json", + "artifact-artifacts-tests-a11y-manual-APP-HOME-md", + "artifact-artifacts-tests-a11y-manual-EXAMPLES-UI-md", + "artifact-artifacts-tests-a11y-manual-EXAMPLES-STATES-md", + "artifact-artifacts-tests-a11y-manual-EXAMPLES-AUTH-md", + "artifact-artifacts-tests-a11y-manual-REFERENCE-RESOURCE-LIST-md", + "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", + "artifact-artifacts-tests-a11y-manual-report-json" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-010", + "name": "architecture", + "commandIds": [ + "check-architecture", + "check-design-system", + "check-design-system-fixture", + "check-i18n", + "check-i18n-fixture", + "check-diagnostics", + "check-diagnostics-fixture", + "check-browser-file-storage-boundaries", + "check-realtime-boundaries", + "check-realtime-boundaries-fixture", + "check-optional-recipes-source", + "check-optional-recipe-fixtures", + "check-registries", + "check-registries-compatibility-fixtures", + "check-registries-baseline-fixture", + "check-registries-fixture", + "check-routes-fixture", + "check-ci" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-010-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-quality-dependency-report-json", + "artifact-artifacts-quality-design-system-json", + "artifact-artifacts-quality-design-system-fixture-json", + "artifact-artifacts-quality-i18n-json", + "artifact-artifacts-quality-i18n-fixture-json", + "artifact-artifacts-quality-diagnostics-json", + "artifact-artifacts-quality-diagnostics-fixture-json", + "artifact-artifacts-quality-realtime-boundaries-json", + "artifact-artifacts-quality-optional-recipes-json", + "artifact-artifacts-quality-optional-recipe-fixtures-json", + "artifact-artifacts-quality-registries-json", + "artifact-artifacts-quality-registry-compatibility-fixtures-json", + "artifact-artifacts-quality-registry-baseline-fixture-json", + "artifact-artifacts-quality-registry-fixture-json", + "artifact-artifacts-quality-route-registry-fixture-json", + "artifact-artifacts-quality-ci-contract-json" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-011", + "name": "build", + "commandIds": [ + "build", + "build-storybook" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-011-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-release-build-manifest-json", + "artifact-artifacts-release-runtime-config-schema-json", + "artifact-artifacts-storybook-static-index-html" + ], + "retentionClassId": "release-coherence" + }, + { + "id": "FE-GATE-012", + "name": "bundle", + "commandIds": [ + "build", + "check-bundle", + "check-optional-recipes" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-012-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-performance-bundle-json", + "artifact-artifacts-quality-optional-recipes-json" + ], + "retentionClassId": "release-coherence" + }, + { + "id": "FE-GATE-013", + "name": "security", + "commandIds": [ + "verify-reproducible-build", + "build-release-candidate", + "verify-local-evidence", + "check-supply-chain-fixtures", + "check-supply-chain-provider-fixtures", + "check-security-fixtures", + "check-browser-security" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-013-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-security-scan-sarif", + "artifact-artifacts-release-dependency-inventory-json", + "artifact-artifacts-release-sbom-cdx-json", + "artifact-artifacts-release-provenance-json", + "artifact-artifacts-release-reproducible-build-json", + "artifact-artifacts-security-dependency-diff-json", + "artifact-artifacts-security-license-report-json", + "artifact-artifacts-security-vulnerability-report-json", + "artifact-artifacts-security-supply-chain-verification-json", + "artifact-artifacts-security-supply-chain-coherence-json", + "artifact-artifacts-security-supply-chain-fixtures-json", + "artifact-artifacts-security-supply-chain-provider-fixtures-json" + ], + "retentionClassId": "release-coherence" + }, + { + "id": "FE-GATE-014", + "name": "config-compatibility", + "commandIds": [ + "verify-compatibility" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-014-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-release-compatibility-json" + ], + "retentionClassId": "release-coherence" + }, + { + "id": "FE-GATE-015", + "name": "release-coherence", + "commandIds": [ + "build-release-candidate", + "verify-local-evidence" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-015-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-release-release-candidate-json", + "artifact-artifacts-release-verification-json", + "artifact-artifacts-security-supply-chain-coherence-json" + ], + "retentionClassId": "release-coherence" + }, + { + "id": "FE-GATE-016", + "name": "rollback-drill", + "commandIds": [ + "build", + "drill-runbook" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-016-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-runbooks-FE-RB-005-record-json" + ], + "retentionClassId": "prod-drill" + }, + { + "id": "FE-GATE-017", + "name": "diagram-review", + "commandIds": [ + "verify-documentation" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-017-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-quality-documentation-review-json" + ], + "retentionClassId": "documentation" + }, + { + "id": "FE-GATE-018", + "name": "field-web-vitals", + "commandIds": [ + "collect-web-vitals-evidence" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-018-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-performance-field-web-vitals-json" + ], + "retentionClassId": "field", + "requiresEnvironment": [ + "FIELD_WEB_VITALS_INPUT", + "MIN_ELIGIBLE_SAMPLES" + ] + }, + { + "id": "FE-GATE-019", + "name": "hosting-header", + "commandIds": [ + "build", + "verify-hosting-headers" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-019-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-release-hosting-headers-json" + ], + "retentionClassId": "release-coherence", + "requiresEnvironment": [ + "HOSTING_BASE_URL" + ] + }, + { + "id": "FE-GATE-020", + "name": "removability", + "commandIds": [ + "test-sample-removal", + "test-optional-recipe-removal", + "test-browser-file-storage-removal", + "test-realtime-removal" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-020-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-tests-sample-removal-xml", + "artifact-artifacts-tests-optional-recipe-removal-xml", + "artifact-artifacts-tests-browser-file-storage-runtime-removal-xml", + "artifact-artifacts-tests-realtime-runtime-removal-xml" + ], + "retentionClassId": "merge-cycle" + }, + { + "id": "FE-GATE-021", + "name": "runbook-boot-config", + "commandIds": [ + "build", + "drill-runbook-2" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-021-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-runbooks-FE-RB-001-record-json" + ], + "retentionClassId": "prod-drill" + }, + { + "id": "FE-GATE-022", + "name": "runbook-chunk-mismatch", + "commandIds": [ + "build", + "drill-runbook-3" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-022-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-runbooks-FE-RB-002-record-json" + ], + "retentionClassId": "prod-drill" + }, + { + "id": "FE-GATE-023", + "name": "runbook-api-degradation", + "commandIds": [ + "build", + "drill-runbook-4" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-023-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-runbooks-FE-RB-003-record-json" + ], + "retentionClassId": "prod-drill" + }, + { + "id": "FE-GATE-024", + "name": "runbook-telemetry", + "commandIds": [ + "build", + "drill-runbook-5" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-024-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-runbooks-FE-RB-004-record-json" + ], + "retentionClassId": "prod-drill" + }, + { + "id": "FE-GATE-025", + "name": "runbook-release-rollback", + "commandIds": [ + "build", + "drill-runbook" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-025-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-runbooks-FE-RB-005-record-json" + ], + "retentionClassId": "prod-drill" + }, + { + "id": "FE-GATE-026", + "name": "lab-performance", + "commandIds": [ + "build", + "test-performance" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-026-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-performance-lab-json" + ], + "retentionClassId": "release-coherence" + } + ], + "stages": [ + { + "id": "merge", "readiness": "MERGE_READY", - "needs": null, - "gates": [ + "needs": [], + "gateIds": [ "FE-GATE-001", "FE-GATE-002", "FE-GATE-003", @@ -21,10 +1639,13 @@ "FE-GATE-020" ] }, - "release": { + { + "id": "release", "readiness": "RELEASE_READY", - "needs": "merge", - "gates": [ + "needs": [ + "merge" + ], + "gateIds": [ "FE-GATE-012", "FE-GATE-014", "FE-GATE-015", @@ -32,10 +1653,13 @@ "FE-GATE-026" ] }, - "production": { + { + "id": "production", "readiness": "PROD_PROMOTION_READY", - "needs": "release", - "gates": [ + "needs": [ + "release" + ], + "gateIds": [ "FE-GATE-016", "FE-GATE-021", "FE-GATE-022", @@ -44,449 +1668,600 @@ "FE-GATE-025" ] }, - "field": { + { + "id": "field", "readiness": "FIELD_SLO_READY", - "needs": "production", - "gates": ["FE-GATE-018"] + "needs": [ + "production" + ], + "gateIds": [ + "FE-GATE-018" + ] }, - "documentation": { + { + "id": "documentation", "readiness": "DOCUMENTATION_READY", - "needs": null, - "gates": ["FE-GATE-017"] + "needs": [], + "gateIds": [ + "FE-GATE-017" + ] } - }, - "gates": { - "FE-GATE-001": { - "name": "manifest-lockfile", + ], + "jobs": [ + { + "id": "merge_gate", + "displayName": "${{ matrix.gate }} / ${{ matrix.name }}", + "kind": "gate-matrix", + "needs": [], + "condition": "merge", + "timeoutMinutes": 45, + "gateIds": [ + "FE-GATE-001", + "FE-GATE-002", + "FE-GATE-003", + "FE-GATE-004", + "FE-GATE-005", + "FE-GATE-006", + "FE-GATE-007", + "FE-GATE-008", + "FE-GATE-009", + "FE-GATE-010", + "FE-GATE-011", + "FE-GATE-013", + "FE-GATE-020" + ], + "browserGateIds": [ + "FE-GATE-008", + "FE-GATE-009" + ], + "environment": [], "steps": [ - { "script": "verify:lockfile", "expect": "pass" }, - { "script": "check:frozen-lockfile:fixture", "expect": "pass" } - ], - "logPath": "artifacts/quality/install.txt", - "evidence": ["artifacts/quality/install.txt"], - "retentionClass": "merge-cycle" - }, - "FE-GATE-002": { - "name": "lint", - "steps": [{ "script": "lint", "expect": "pass" }], - "logPath": "artifacts/quality/lint.txt", - "evidence": ["artifacts/quality/lint.txt"], - "retentionClass": "merge-cycle" - }, - "FE-GATE-003": { - "name": "typecheck", - "steps": [ - { "script": "check:types", "expect": "pass" }, - { "script": "check:types:recipes", "expect": "pass" }, - { "script": "check:types:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, - { "script": "check:types:fixture:ts-port", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, - { "script": "check:types:fixture:ts-result", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2339:" }, - { "script": "check:types:fixture:application-output", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, - { "script": "check:types:fixture:application-input", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2739:" }, - { "script": "check:types:fixture:feature-input", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2345:" }, - { "script": "check:types:fixture:failure-kind", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, - { "script": "check:types:fixture:reference-operation", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2345:" }, - { "script": "check:types:fixture:async-overlay", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2322:" }, - { "script": "check:types:fixture:route-runtime", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2353:" }, - { "script": "check:types:fixture:page-action", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS1360:" }, - { "script": "check:types:fixture:icon-button", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2741:" }, - { "script": "check:types:fixture:i18n-key", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2345:" }, - { "script": "check:types:fixture:i18n-params", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "error TS2554:" }, - { "script": "check:types:fixture:diagnostics", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "UNKNOWN_DIAGNOSTIC_EVENT" } - ], - "logPath": "artifacts/quality/check-types.txt", - "evidence": ["artifacts/quality/check-types.txt"], - "retentionClass": "merge-cycle" - }, - "FE-GATE-004": { - "name": "runtime-schema", - "steps": [{ "script": "test:runtime-schema", "expect": "pass" }], - "logPath": "artifacts/quality/gates/FE-GATE-004.txt", - "evidence": ["artifacts/tests/runtime-schema.xml"], - "retentionClass": "merge-cycle" - }, - "FE-GATE-005": { - "name": "unit", - "steps": [ - { "script": "test:unit", "expect": "pass" }, - { "script": "test:coverage", "expect": "pass" }, - { "script": "check:coverage:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Risk coverage failed:" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-005.txt", - "evidence": [ - "artifacts/tests/unit.xml", - "artifacts/tests/coverage.xml", - "artifacts/tests/coverage/coverage-summary.json", - "artifacts/quality/risk-coverage.json", - "artifacts/quality/risk-coverage-fixture.json" - ], - "retentionClass": "merge-cycle" - }, - "FE-GATE-006": { - "name": "component", - "steps": [{ "script": "test:component", "expect": "pass" }], - "logPath": "artifacts/quality/gates/FE-GATE-006.txt", - "evidence": ["artifacts/tests/component.xml"], - "retentionClass": "merge-cycle" - }, - "FE-GATE-007": { - "name": "integration", - "steps": [ - { "script": "test:integration", "expect": "pass" }, - { "script": "test:http-scenario-evidence", "expect": "pass" }, - { "script": "test:reference-feature", "expect": "pass" }, - { "script": "test:recipes", "expect": "pass" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-007.txt", - "evidence": [ - "artifacts/tests/integration.xml", - "artifacts/tests/http-scenario-executions.json", - "artifacts/quality/http-scenario-evidence.json", - "artifacts/quality/http-scenario-evidence-fixture.json", - "artifacts/tests/reference-feature.xml", - "artifacts/tests/optional-recipes.xml" - ], - "retentionClass": "merge-cycle" - }, - "FE-GATE-008": { - "name": "e2e", - "steps": [ - { "script": "test:e2e", "expect": "pass" }, - { "script": "test:browser-capabilities", "expect": "pass" }, { - "script": "verify:browser-capability-evidence", - "expect": "pass" - }, - { "script": "test:storybook", "expect": "pass" }, - { "script": "test:visual", "expect": "pass" }, - { "script": "check:test-evidence:browser", "expect": "pass" }, - { "script": "check:test-evidence:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Test evidence failed:" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-008.txt", - "evidence": [ - "artifacts/tests/e2e/report/index.html", - "artifacts/tests/e2e/results.xml", - "artifacts/tests/browser-capabilities/report/index.html", - "artifacts/tests/browser-capabilities/results.xml", - "artifacts/tests/storybook/report/index.html", - "artifacts/tests/storybook/results.xml", - "artifacts/tests/visual/report/index.html", - "artifacts/tests/visual/results.xml", - "artifacts/quality/test-evidence.json", - "artifacts/quality/test-evidence-fixture.json" - ], - "retentionClass": "merge-cycle" - }, - "FE-GATE-009": { - "name": "accessibility", - "steps": [ - { "script": "test:a11y", "expect": "pass" }, - { "script": "review:a11y-manual", "expect": "pass" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-009.txt", - "evidence": [ - "artifacts/tests/a11y.json", - "artifacts/tests/a11y-manual/APP_HOME.md", - "artifacts/tests/a11y-manual/EXAMPLES_UI.md", - "artifacts/tests/a11y-manual/EXAMPLES_STATES.md", - "artifacts/tests/a11y-manual/EXAMPLES_AUTH.md", - "artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md", - "artifacts/tests/a11y-manual/NOT_FOUND.md", - "artifacts/tests/a11y-manual/report.json" - ], - "retentionClass": "merge-cycle" - }, - "FE-GATE-010": { - "name": "architecture", - "steps": [ - { "script": "check:architecture", "expect": "pass" }, - { "script": "check:design-system", "expect": "pass" }, - { "script": "check:design-system:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Design system contract failed:" }, - { "script": "check:i18n", "expect": "pass" }, - { "script": "check:i18n:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "I18n contract failed:" }, - { "script": "check:diagnostics", "expect": "pass" }, - { "script": "check:diagnostics:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Diagnostics contract failed:" }, - { "script": "check:browser-file-storage-boundaries", "expect": "pass" }, - { "script": "check:realtime-boundaries", "expect": "pass" }, - { "script": "check:realtime-boundaries:fixture", "expect": "pass" }, - { "script": "check:optional-recipes:source", "expect": "pass" }, - { "script": "check:optional-recipe-fixtures", "expect": "pass" }, - { "script": "check:registries", "expect": "pass" }, - { - "script": "check:registries:compatibility-fixtures", - "expect": "pass" - }, - { "script": "check:registries:baseline-fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "registry baseline approval digest mismatch:" }, - { "script": "check:registries:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "duplicates id=duplicate" }, - { "script": "check:routes:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "duplicates routeId=DUPLICATE" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-010.txt", - "evidence": [ - "artifacts/quality/dependency-report.json", - "artifacts/quality/design-system.json", - "artifacts/quality/design-system-fixture.json", - "artifacts/quality/i18n.json", - "artifacts/quality/i18n-fixture.json", - "artifacts/quality/diagnostics.json", - "artifacts/quality/diagnostics-fixture.json", - "artifacts/quality/realtime-boundaries.json", - "artifacts/quality/optional-recipes.json", - "artifacts/quality/optional-recipe-fixtures.json", - "artifacts/quality/registries.json", - "artifacts/quality/registry-compatibility-fixtures.json", - "artifacts/quality/registry-baseline-fixture.json", - "artifacts/quality/registry-fixture.json", - "artifacts/quality/route-registry-fixture.json" - ], - "retentionClass": "merge-cycle" - }, - "FE-GATE-011": { - "name": "build", - "steps": [ - { "script": "build", "expect": "pass" }, - { "script": "build:storybook", "expect": "pass" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-011.txt", - "evidence": [ - "artifacts/release/build-manifest.json", - "artifacts/release/runtime-config.schema.json", - "artifacts/storybook/static/index.html" - ], - "retentionClass": "release-coherence" - }, - "FE-GATE-012": { - "name": "bundle", - "steps": [ - { "script": "build", "expect": "pass" }, - { "script": "check:bundle", "expect": "pass" }, - { "script": "check:optional-recipes", "expect": "pass" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-012.txt", - "evidence": [ - "artifacts/performance/bundle.json", - "artifacts/quality/optional-recipes.json" - ], - "retentionClass": "release-coherence" - }, - "FE-GATE-013": { - "name": "security", - "steps": [ - { "script": "verify:reproducible-build", "expect": "pass" }, - { "script": "build:release-candidate", "expect": "pass" }, - { "script": "verify:local-evidence", "expect": "pass" }, - { "script": "check:supply-chain:fixtures", "expect": "pass" }, - { - "script": "check:supply-chain:provider-fixtures", - "expect": "pass" - }, - { "script": "check:security:fixtures", "expect": "pass" }, - { "script": "check:browser-security", "expect": "pass" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-013.txt", - "evidence": [ - "artifacts/security/scan.sarif", - "artifacts/release/dependency-inventory.json", - "artifacts/release/sbom.cdx.json", - "artifacts/release/provenance.json", - "artifacts/release/reproducible-build.json", - "artifacts/security/dependency-diff.json", - "artifacts/security/license-report.json", - "artifacts/security/vulnerability-report.json", - "artifacts/security/supply-chain-verification.json", - "artifacts/security/supply-chain-coherence.json", - "artifacts/security/supply-chain-fixtures.json", - "artifacts/security/supply-chain-provider-fixtures.json" - ], - "retentionClass": "release-coherence" - }, - "FE-GATE-014": { - "name": "config-compatibility", - "steps": [{ "script": "verify:compatibility", "expect": "pass" }], - "logPath": "artifacts/quality/gates/FE-GATE-014.txt", - "evidence": ["artifacts/release/compatibility.json"], - "retentionClass": "release-coherence" - }, - "FE-GATE-015": { - "name": "release-coherence", - "steps": [ - { "script": "build:release-candidate", "expect": "pass" }, - { "script": "verify:local-evidence", "expect": "pass" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-015.txt", - "evidence": [ - "artifacts/release/release-candidate.json", - "artifacts/release/verification.json", - "artifacts/security/supply-chain-coherence.json" - ], - "retentionClass": "release-coherence" - }, - "FE-GATE-016": { - "name": "rollback-drill", - "steps": [ - { "script": "build", "expect": "pass" }, - { - "script": "drill:runbook", - "args": ["--", "FE-RB-005"], - "expect": "pass" - } - ], - "logPath": "artifacts/quality/gates/FE-GATE-016.txt", - "evidence": [ - "artifacts/runbooks/FE-RB-005/record.json" - ], - "retentionClass": "prod-drill" - }, - "FE-GATE-017": { - "name": "diagram-review", - "steps": [{ "script": "verify:documentation", "expect": "pass" }], - "logPath": "artifacts/quality/gates/FE-GATE-017.txt", - "evidence": ["artifacts/quality/documentation-review.json"], - "retentionClass": "documentation" - }, - "FE-GATE-018": { - "name": "field-web-vitals", - "requiresEnvironment": [ - "FIELD_WEB_VITALS_INPUT", - "MIN_ELIGIBLE_SAMPLES" - ], - "steps": [ - { "script": "collect:web-vitals-evidence", "expect": "pass" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-018.txt", - "evidence": ["artifacts/performance/field-web-vitals.json"], - "retentionClass": "field" - }, - "FE-GATE-019": { - "name": "hosting-header", - "requiresEnvironment": ["HOSTING_BASE_URL"], - "steps": [ - { "script": "build", "expect": "pass" }, - { "script": "verify:hosting-headers", "expect": "pass" } - ], - "logPath": "artifacts/quality/gates/FE-GATE-019.txt", - "evidence": ["artifacts/release/hosting-headers.json"], - "retentionClass": "release-coherence" - }, - "FE-GATE-020": { - "name": "removability", - "steps": [ - { "script": "test:sample-removal", "expect": "pass" }, - { "script": "test:optional-recipe-removal", "expect": "pass" }, - { - "script": "test:browser-file-storage-removal", - "expect": "pass" + "kind": "checkout" }, { - "script": "test:realtime-removal", - "expect": "pass" - } - ], - "logPath": "artifacts/quality/gates/FE-GATE-020.txt", - "evidence": [ - "artifacts/tests/sample-removal.xml", - "artifacts/tests/optional-recipe-removal.xml", - "artifacts/tests/browser-file-storage-runtime-removal.xml", - "artifacts/tests/realtime-runtime-removal.xml" - ], - "retentionClass": "merge-cycle" - }, - "FE-GATE-021": { - "name": "runbook-boot-config", - "steps": [ - { "script": "build", "expect": "pass" }, + "kind": "setup-node" + }, { - "script": "drill:runbook", - "args": ["--", "FE-RB-001"], - "expect": "pass" - } - ], - "logPath": "artifacts/quality/gates/FE-GATE-021.txt", - "evidence": [ - "artifacts/runbooks/FE-RB-001/record.json" - ], - "retentionClass": "prod-drill" - }, - "FE-GATE-022": { - "name": "runbook-chunk-mismatch", - "steps": [ - { "script": "build", "expect": "pass" }, + "kind": "frozen-install" + }, { - "script": "drill:runbook", - "args": ["--", "FE-RB-002"], - "expect": "pass" - } - ], - "logPath": "artifacts/quality/gates/FE-GATE-022.txt", - "evidence": [ - "artifacts/runbooks/FE-RB-002/record.json" - ], - "retentionClass": "prod-drill" - }, - "FE-GATE-023": { - "name": "runbook-api-degradation", - "steps": [ - { "script": "build", "expect": "pass" }, + "kind": "browser-install" + }, { - "script": "drill:runbook", - "args": ["--", "FE-RB-003"], - "expect": "pass" - } - ], - "logPath": "artifacts/quality/gates/FE-GATE-023.txt", - "evidence": [ - "artifacts/runbooks/FE-RB-003/record.json" - ], - "retentionClass": "prod-drill" - }, - "FE-GATE-024": { - "name": "runbook-telemetry", - "steps": [ - { "script": "build", "expect": "pass" }, + "kind": "run-gate" + }, { - "script": "drill:runbook", - "args": ["--", "FE-RB-004"], - "expect": "pass" + "kind": "upload", + "transferId": "merge-gate-evidence", + "name": "${{ matrix.gate }}-${{ gitea.run_id }}", + "paths": [ + "artifacts/" + ], + "always": true } - ], - "logPath": "artifacts/quality/gates/FE-GATE-024.txt", - "evidence": [ - "artifacts/runbooks/FE-RB-004/record.json" - ], - "retentionClass": "prod-drill" + ] }, - "FE-GATE-025": { - "name": "runbook-release-rollback", - "steps": [ - { "script": "build", "expect": "pass" }, + { + "id": "release_gate", + "displayName": "${{ matrix.gate }} / ${{ matrix.name }}", + "kind": "gate-matrix", + "needs": [ + "merge_gate" + ], + "condition": "release", + "timeoutMinutes": 45, + "gateIds": [ + "FE-GATE-012", + "FE-GATE-014", + "FE-GATE-019", + "FE-GATE-026" + ], + "browserGateIds": [ + "FE-GATE-026" + ], + "environment": [ { - "script": "drill:runbook", - "args": ["--", "FE-RB-005"], - "expect": "pass" + "name": "HOSTING_BASE_URL", + "value": "${{ vars.HOSTING_BASE_URL }}" } ], - "logPath": "artifacts/quality/gates/FE-GATE-025.txt", - "evidence": [ - "artifacts/runbooks/FE-RB-005/record.json" - ], - "retentionClass": "prod-drill" - }, - "FE-GATE-026": { - "name": "lab-performance", "steps": [ - { "script": "build", "expect": "pass" }, - { "script": "test:performance", "expect": "pass" } + { + "kind": "checkout" + }, + { + "kind": "setup-node" + }, + { + "kind": "frozen-install" + }, + { + "kind": "browser-install" + }, + { + "kind": "run-gate" + }, + { + "kind": "upload", + "transferId": "release-gate-evidence", + "name": "${{ matrix.gate }}-${{ gitea.run_id }}", + "paths": [ + "artifacts/" + ], + "always": true + } + ] + }, + { + "id": "immutable_build", + "displayName": "FE-GATE-015 / immutable-release-candidate", + "kind": "immutable", + "needs": [ + "release_gate" ], - "logPath": "artifacts/quality/gates/FE-GATE-026.txt", - "evidence": ["artifacts/performance/lab.json"], - "retentionClass": "release-coherence" + "condition": "release", + "timeoutMinutes": 45, + "gateIds": [ + "FE-GATE-015" + ], + "browserGateIds": [], + "environment": [], + "steps": [ + { + "kind": "checkout" + }, + { + "kind": "setup-node" + }, + { + "kind": "frozen-install" + }, + { + "kind": "run-gate" + }, + { + "kind": "archive-candidate", + "stepId": "candidate", + "archivePath": ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", + "members": [ + "dist", + "pnpm-lock.yaml", + "artifacts/performance/bundle.json", + "artifacts/quality/vite-module-inventory.json", + "artifacts/release/build-manifest.json", + "artifacts/release/checksums.txt", + "artifacts/release/dependency-inventory.json", + "artifacts/release/provenance.json", + "artifacts/release/verification.json", + "artifacts/release/sbom.cdx.json", + "artifacts/security/dependency-diff.json", + "artifacts/security/license-report.json", + "artifacts/security/scan.sarif", + "artifacts/security/supply-chain-coherence.json", + "artifacts/security/supply-chain-verification.json", + "artifacts/security/vulnerability-report.json", + "artifacts/release/release-candidate.json" + ], + "archiveOutputName": "archive_sha256", + "distOutputName": "dist_sha256" + }, + { + "kind": "upload", + "transferId": "release-candidate", + "name": "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}", + "paths": [ + ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + ] + } + ] + }, + { + "id": "vulnerability_provider", + "displayName": "external-vulnerability-provider", + "kind": "provider", + "needs": [ + "immutable_build" + ], + "condition": "always", + "timeoutMinutes": 45, + "gateIds": [], + "browserGateIds": [], + "environment": [ + { + "name": "CANDIDATE_ARCHIVE_SHA256", + "value": "${{ needs.immutable_build.outputs.archive_sha256 }}" + }, + { + "name": "CANDIDATE_ARCHIVE_PATH", + "value": ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + }, + { + "name": "CANDIDATE_DIST_SHA256", + "value": "${{ needs.immutable_build.outputs.dist_sha256 }}" + }, + { + "name": "CANDIDATE_LOCKFILE_PATH", + "value": ".release/verified-vulnerability/pnpm-lock.yaml" + }, + { + "name": "VULNERABILITY_PROVIDER_COMMAND", + "value": "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" + }, + { + "name": "VULNERABILITY_REPORT_PATH", + "value": "provider-evidence/untrusted/vulnerability-report.json" + }, + { + "name": "VALIDATED_PROVIDER_REPORT_PATH", + "value": "provider-evidence/vulnerability-report.json" + } + ], + "steps": [ + { + "kind": "checkout" + }, + { + "kind": "setup-node" + }, + { + "kind": "frozen-install" + }, + { + "kind": "download", + "transferId": "release-candidate", + "path": ".release/vulnerability-candidate" + }, + { + "kind": "extract", + "archivePath": ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", + "targetRoot": ".release/verified-vulnerability" + }, + { + "kind": "run-provider", + "provider": "vulnerability" + }, + { + "kind": "validate-provider-evidence", + "provider": "vulnerability" + }, + { + "kind": "upload", + "transferId": "vulnerability-provider-evidence", + "name": "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}", + "paths": [ + "provider-evidence/vulnerability-report.json" + ] + } + ] + }, + { + "id": "provenance_provider", + "displayName": "external-provenance-provider", + "kind": "provider", + "needs": [ + "immutable_build" + ], + "condition": "always", + "timeoutMinutes": 45, + "gateIds": [], + "browserGateIds": [], + "environment": [ + { + "name": "CANDIDATE_ARCHIVE_SHA256", + "value": "${{ needs.immutable_build.outputs.archive_sha256 }}" + }, + { + "name": "CANDIDATE_ARCHIVE_PATH", + "value": ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + }, + { + "name": "CANDIDATE_DIST_SHA256", + "value": "${{ needs.immutable_build.outputs.dist_sha256 }}" + }, + { + "name": "CANDIDATE_LOCKFILE_PATH", + "value": ".release/verified-provenance/pnpm-lock.yaml" + }, + { + "name": "PROVENANCE_PROVIDER_COMMAND", + "value": "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" + }, + { + "name": "PROVENANCE_ATTESTATION_PATH", + "value": "provider-evidence/untrusted/provenance-attestation.json" + }, + { + "name": "VALIDATED_PROVIDER_REPORT_PATH", + "value": "provider-evidence/provenance-attestation.json" + } + ], + "steps": [ + { + "kind": "checkout" + }, + { + "kind": "setup-node" + }, + { + "kind": "frozen-install" + }, + { + "kind": "download", + "transferId": "release-candidate", + "path": ".release/provenance-candidate" + }, + { + "kind": "extract", + "archivePath": ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", + "targetRoot": ".release/verified-provenance" + }, + { + "kind": "run-provider", + "provider": "provenance" + }, + { + "kind": "validate-provider-evidence", + "provider": "provenance" + }, + { + "kind": "upload", + "transferId": "provenance-provider-evidence", + "name": "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}", + "paths": [ + "provider-evidence/provenance-attestation.json" + ] + } + ] + }, + { + "id": "promotion", + "displayName": "promote-verified-immutable-candidate", + "kind": "promotion", + "needs": [ + "immutable_build", + "vulnerability_provider", + "provenance_provider" + ], + "condition": "always", + "timeoutMinutes": 45, + "gateIds": [], + "browserGateIds": [], + "environment": [ + { + "name": "CANDIDATE_ARCHIVE_SHA256", + "value": "${{ needs.immutable_build.outputs.archive_sha256 }}" + }, + { + "name": "CANDIDATE_ARCHIVE_PATH", + "value": ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + }, + { + "name": "CANDIDATE_ROOT", + "value": "${{ gitea.workspace }}/.release/verified-candidate" + }, + { + "name": "VULNERABILITY_REPORT_PATH", + "value": "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" + }, + { + "name": "PROVENANCE_ATTESTATION_PATH", + "value": "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" + }, + { + "name": "VULNERABILITY_PUBLIC_KEY_PATH", + "value": "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" + }, + { + "name": "VULNERABILITY_KEY_ID", + "value": "${{ vars.VULNERABILITY_KEY_ID }}" + }, + { + "name": "PROVENANCE_PUBLIC_KEY_PATH", + "value": "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" + }, + { + "name": "PROVENANCE_KEY_ID", + "value": "${{ vars.PROVENANCE_KEY_ID }}" + } + ], + "steps": [ + { + "kind": "checkout" + }, + { + "kind": "setup-node" + }, + { + "kind": "frozen-install" + }, + { + "kind": "download", + "transferId": "release-candidate", + "path": ".release/candidate" + }, + { + "kind": "download", + "transferId": "vulnerability-provider-evidence", + "path": ".release/vulnerability" + }, + { + "kind": "download", + "transferId": "provenance-provider-evidence", + "path": ".release/provenance" + }, + { + "kind": "extract", + "archivePath": ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", + "targetRoot": ".release/verified-candidate" + }, + { + "kind": "verify-promotion" + }, + { + "kind": "upload", + "transferId": "promoted-release", + "name": "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}", + "paths": [ + ".release/promoted-staging/release-candidate.tar.gz", + ".release/promoted-staging/vulnerability-report.json", + ".release/promoted-staging/provenance-attestation.json", + ".release/promoted-staging/provider-verification.json", + ".release/promoted-staging/promotion-verification.json" + ] + } + ] + }, + { + "id": "production_gate", + "displayName": "${{ matrix.gate }} / ${{ matrix.name }}", + "kind": "gate-matrix", + "needs": [ + "promotion" + ], + "condition": "production", + "timeoutMinutes": 45, + "gateIds": [ + "FE-GATE-016", + "FE-GATE-021", + "FE-GATE-022", + "FE-GATE-023", + "FE-GATE-024", + "FE-GATE-025" + ], + "browserGateIds": [], + "environment": [], + "steps": [ + { + "kind": "checkout" + }, + { + "kind": "setup-node" + }, + { + "kind": "frozen-install" + }, + { + "kind": "run-gate" + }, + { + "kind": "upload", + "transferId": "production-gate-evidence", + "name": "${{ matrix.gate }}-${{ gitea.run_id }}", + "paths": [ + "artifacts/" + ], + "always": true + } + ] + }, + { + "id": "field_gate", + "displayName": "FE-GATE-018 / field-web-vitals", + "kind": "gate-single", + "needs": [ + "production_gate" + ], + "condition": "field", + "timeoutMinutes": 45, + "gateIds": [ + "FE-GATE-018" + ], + "browserGateIds": [], + "environment": [ + { + "name": "FIELD_WEB_VITALS_INPUT", + "value": "${{ vars.FIELD_WEB_VITALS_INPUT }}" + }, + { + "name": "MIN_ELIGIBLE_SAMPLES", + "value": "${{ vars.MIN_ELIGIBLE_SAMPLES }}" + } + ], + "steps": [ + { + "kind": "checkout" + }, + { + "kind": "setup-node" + }, + { + "kind": "frozen-install" + }, + { + "kind": "run-gate" + }, + { + "kind": "upload", + "transferId": "field-gate-evidence", + "name": "FE-GATE-018-${{ gitea.run_id }}", + "paths": [ + "artifacts/" + ], + "always": true + } + ] + }, + { + "id": "documentation_gate", + "displayName": "FE-GATE-017 / diagram-review", + "kind": "gate-single", + "needs": [], + "condition": "documentation", + "timeoutMinutes": 45, + "gateIds": [ + "FE-GATE-017" + ], + "browserGateIds": [], + "environment": [], + "steps": [ + { + "kind": "checkout" + }, + { + "kind": "setup-node" + }, + { + "kind": "frozen-install" + }, + { + "kind": "run-gate" + }, + { + "kind": "upload", + "transferId": "documentation-gate-evidence", + "name": "FE-GATE-017-${{ gitea.run_id }}", + "paths": [ + "artifacts/" + ], + "always": true + } + ] } - }, + ], "retention": { "durationStatus": "UNSUPPORTED_PENDING_ORGANIZATION_POLICY", - "merge-cycle": "at least through pull-request readiness decision", - "release-coherence": "at least until the next release is promoted", - "prod-drill": "at least until the next production promotion decision", - "field": "through the 28-day window and aggregation", - "documentation": "through documentation readiness review" + "classes": [ + { + "id": "merge-cycle", + "policy": "at least through pull-request readiness decision" + }, + { + "id": "release-coherence", + "policy": "at least until the next release is promoted" + }, + { + "id": "prod-drill", + "policy": "at least until the next production promotion decision" + }, + { + "id": "field", + "policy": "through the 28-day window and aggregation" + }, + { + "id": "documentation", + "policy": "through documentation readiness review" + } + ] } } diff --git a/docs/operations/ci-quality-gates.md b/docs/operations/ci-quality-gates.md index 0e8b2d9..9f0ca96 100644 --- a/docs/operations/ci-quality-gates.md +++ b/docs/operations/ci-quality-gates.md @@ -1,6 +1,12 @@ # CI quality-gate orchestration -`config/ci/gates.json` is the executable registry for all 26 gates. The Gitea +`config/ci/gates.json` is the executable registry for all 26 gates and the sole +workflow model. `.gitea/workflows/quality-gates.yml` is generated from that +strict v2 contract; it is not an independent configuration authority. Update +the registry, run `corepack pnpm generate:ci-workflow`, and commit the generated +bytes together. `corepack pnpm check:ci-workflow` rejects missing, stale, CRLF, +or otherwise byte-different output. `corepack pnpm check:ci` runs artifact-schema +checking, the typed contract checker, and this workflow drift check. The Gitea adapter runs each gate as an independent matrix check with full fan-out and no soft-fail wiring. @@ -36,16 +42,54 @@ Release tier의 `immutable_build`는 `dist`의 hidden `.vite` entries, raw `pnpm-lock.yaml`, build/module inventory와 local release/security evidence를 한 번 생성해 run ID/attempt로 구분된 archive로 게시한다. Raw lock bytes의 SHA-256은 dependency inventory와 candidate manifest 양쪽의 `lockfileSha256`과 같아야 한다. -Vulnerability/provenance provider job은 같은 archive를 각각 내려받고 -`CANDIDATE_LOCKFILE_PATH`, `CANDIDATE_DIST_SHA256`를 외부 command에 전달한다. -Promotion은 같은 archive와 두 report를 별도 경로로 내려받은 뒤 local evidence를 -read-only로 다시 계산하고 Ed25519 signature/digest를 확인한다. Promotion job에는 -build/rebuild command가 없으며 검증한 archive 자체를 그대로 승격한다. +Producer는 게시 전에 manifest가 선언한 exact member set과 각 byte digest를 +검증하고 archive SHA-256을 job output으로 고정한다. Vulnerability/provenance +provider job은 같은 archive를 각각 내려받아 output SHA-256과 exact member set을 +확인한 뒤 checkout과 분리된 root에만 추출하고, `CANDIDATE_LOCKFILE_PATH`와 +`CANDIDATE_DIST_SHA256`를 외부 command에 전달한다. Provider command 실행 후에는 +archive와 추출된 candidate를 다시 검증하고, strict provider JSON과 candidate +digest가 일치할 때만 report를 업로드한다. Promotion은 같은 archive와 두 report를 +별도 경로로 내려받고 SHA/member 검증을 마친 뒤 격리된 root에 추출하여 local +evidence를 read-only로 다시 계산하고 Ed25519 signature/digest를 확인한다. +Promotion job에는 build/rebuild command가 없으며 검증한 archive 자체를 변경 없이 +그대로 승격한다. Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. 이 -workflow를 required check로 전환하기 전에 staging instance에서 -`permissions`, `gitea.run_attempt`, `actions/upload-artifact@v4`를 포함한 한 -번의 전체 provider smoke를 통과시켜야 한다. 모든 setup step은 +workflow의 provider job은 Linux runner에서 실행 권한이 있는 +`/usr/bin/bwrap`를 필수로 요구하며, 사용할 Node 실행 파일도 sandbox 안의 +`/usr/local/bin/node`로 고정한다. Provider command는 bubblewrap 안에서 +`/bin/sh -eu -c`로 비대화식 실행되고 30분 안에 종료되어야 한다. Sandbox는 +workspace를 read-only로 bind하고 `.git`을 가리며, 별도의 `untrusted` +raw-evidence 하위 디렉터리만 writable로 노출한다. 따라서 command는 전달된 +candidate/environment 값을 읽고 지정된 raw report 하나만 기록해야 하며, +workspace 수정, host home/toolcache 접근, sealed evidence 직접 기록에 의존하면 +안 된다. Supervisor는 provider 종류에 해당하는 credential prefix와 제한된 +환경만 전달하고, sandbox 또는 출력 경계를 만들 수 없으면 fail closed한다. + +Workflow가 실행하는 action은 `scripts/contracts/ci-gates.ts`의 단일 typed, +runtime-frozen registry에서만 resolve된다. `uses:`에는 repository 별칭, tag, +branch 또는 short SHA를 허용하지 않고 다음 absolute upstream URL과 full 40-hex +commit만 사용한다. + +- `https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5` + (`v4.3.1`) +- `https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020` + (`v4.4.0`) +- `https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7` + (patched `v4` branch) +- `https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7` + (patched `v4` branch) + +Gitea 1.22 공식 Actions 문서는 artifact v4 호환 경로로 이 +`ChristopherHX` patched upload/download fork를 권고한다. 이 권고는 실제 +설치 환경의 호환성을 증명하지 않으므로, 지원 baseline은 계속 Gitea 1.26.4+ +및 Gitea Runner 1.0.0+이며 action 변경은 registry, semantic tests, snapshot과 +generated workflow를 함께 갱신해야 한다. + +이 workflow를 required check로 전환하기 전에 staging instance에서 +`permissions`, `gitea.run_attempt`, 위 digest-pinned patched artifact action을 +포함한 한 번의 실제 end-to-end provider smoke를 반드시 통과시켜야 한다. +mock/local generation 결과는 required-check enablement evidence가 아니다. 모든 setup step은 `node-version-file: .nvmrc`를 사용하므로 CI Node.js 버전은 `.nvmrc`의 exact pin과 같다. CI contract는 `.nvmrc`가 full semantic version인지, 모든 job이 이 파일을 사용하는지 함께 검사한다. `ubuntu-latest` runner label은 @@ -152,6 +196,18 @@ If any external provider command, report, trust path, or key ID is absent, promotion remains unavailable with `FAIL_UNVERIFIED`; there is no local generator/restore fallback. +Promotion verification/staging step과 promoted-release upload action 사이에는 +어떤 step도 둘 수 없고 upload에는 `if: always()`를 사용할 수 없다. 이 인접성은 +실패한 검증의 publication을 막지만 staging path를 upload action에 +cryptographically hand off하지는 않는다. 따라서 promotion runner는 trusted, +exclusive, single-tenant여야 하고 staging step 종료부터 바로 다음 upload action이 +파일을 여는 시점까지 같은 UID로 실행되는 provider command나 다른 process가 +살아남지 않는다는 운영 가정을 만족해야 한다. 업로드된 artifact의 downstream +consumer도 artifact service나 transfer action을 신뢰 경계 밖으로 보고, candidate +manifest와 signed provider evidence에 바인딩된 digest를 다운로드 후 다시 +검증해야 한다. 현재 producer-side adjacency 자체는 consumer-side digest +revalidation을 대신하지 않는다. + Branch protection must mark each `FE-GATE-* / ` check required for its declared tier. This repository cannot configure server-side protection by committing a file. diff --git a/docs/security/supply-chain.md b/docs/security/supply-chain.md index 22508c3..ec8ecae 100644 --- a/docs/security/supply-chain.md +++ b/docs/security/supply-chain.md @@ -45,10 +45,16 @@ including Ed448, are rejected even if a document labels its algorithm `.vite` files), the build manifest, module inventory, release verification, secret-scan result, and local supply-chain evidence once. The candidate manifest hashes the raw lockfile bytes and requires that digest to equal the -dependency inventory's `lockfileSha256`. The two provider jobs download this -same archive separately and receive `CANDIDATE_LOCKFILE_PATH` and -`CANDIDATE_DIST_SHA256`; configured `VULNERABILITY_PROVIDER_COMMAND` and -`PROVENANCE_PROVIDER_COMMAND` must emit the signed reports. +dependency inventory's `lockfileSha256`. Before upload, the producer validates +the manifest-bound exact archive member set and every member digest, then +publishes the archive SHA-256 as an immutable job output. The two provider jobs +download this same archive separately, compare that output digest, validate the +exact member set before extracting only into isolated roots, and receive +`CANDIDATE_LOCKFILE_PATH` and `CANDIDATE_DIST_SHA256`; configured +`VULNERABILITY_PROVIDER_COMMAND` and `PROVENANCE_PROVIDER_COMMAND` must emit the +signed reports. After each external command returns, provider upload validation +rechecks the unchanged archive and extracted candidate, parses the provider JSON +with its strict schema, and binds its dist and lockfile digests before upload. If either provider input is absent, local verification remains meaningful but `artifacts/security/supply-chain-verification.json` records @@ -57,8 +63,59 @@ If either provider input is absent, local verification remains meaningful but set and digests, then read-only revalidates the archived executable schemas, raw lockfile, module inventory, build outputs, release coherence, SBOM, provenance, security scan and supply-chain coherence. It never rebuilds or -rewrites candidate evidence. Scanner or signing outages are not converted to -an empty PASS. +rewrites candidate evidence. Promotion uploads the already verified archive +itself with the two provider reports and verification records; it does not +create a replacement archive from extracted files. Scanner or signing outages +are not converted to an empty PASS. + +The generated workflow is also a supply-chain control. `config/ci/gates.json` +is its sole typed authority. Run `corepack pnpm generate:ci-workflow` after a +contract change and `corepack pnpm check:ci-workflow` (or the encompassing +`corepack pnpm check:ci`) to reject byte drift in the checked-in Gitea adapter. + +Action resolution is separately closed over one typed, runtime-frozen registry +in `scripts/contracts/ci-gates.ts`. Every generated `uses:` value is an absolute +upstream URL pinned to a full commit SHA: + +- `https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5` + (`v4.3.1`) +- `https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020` + (`v4.4.0`) +- `https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7` + (patched `v4` branch) +- `https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7` + (patched `v4` branch) + +Unknown actions, relative repositories, tags/branches and short SHAs are +rejected. Gitea 1.22's official Actions documentation recommends the +`ChristopherHX` patched artifact forks for v4 compatibility; the supported +deployment baseline is nevertheless Gitea 1.26.4+ with Gitea Runner 1.0.0+. +A real end-to-end provider smoke on the staging Gitea instance remains +mandatory before any generated job becomes a required check. + +External provider supervision is fail-closed and requires a Linux runner with +an executable `/usr/bin/bwrap`. Bubblewrap mounts the repository workspace +read-only, hides `.git`, pins the trusted Node executable at +`/usr/local/bin/node`, and exposes only the sibling `untrusted` raw-evidence +directory as writable. Provider commands run non-interactively through +`/bin/sh -eu -c`, receive a minimized environment plus only their own +provider-prefixed credentials, and have a 30-minute limit. They must consume +the supplied candidate paths and digests, write exactly the configured raw +report, and must not depend on workspace mutation, host home/toolcache access, +or direct access to the sealed evidence path. Missing sandbox support, stale or +misplaced outputs, command failure/timeout, and post-command candidate drift +all stop publication. + +The final promotion verification/staging step must be immediately adjacent to +the promoted-release upload, and that upload must not use `always()`. This +reduces the post-verification mutation window but does not seal a pathname +across two action steps. The runner is therefore required to be trusted, +exclusive and single-tenant, with no provider command or other same-UID process +surviving from staging into the immediately following upload. The artifact +service and transfer actions also remain outside the candidate's cryptographic +identity: every downstream consumer must revalidate the downloaded archive, +manifest member digests and signed provider evidence. Producer-side adjacency +does not provide consumer-side digest revalidation. Approved vulnerability exceptions require vulnerability/package identity, owner, a different reviewer, reason and expiry. Expired or self-approved diff --git a/docs/superpowers/plans/2026-08-01-quality-architecture-remediation.md b/docs/superpowers/plans/2026-08-01-quality-architecture-remediation.md index b8decd3..ee34472 100644 --- a/docs/superpowers/plans/2026-08-01-quality-architecture-remediation.md +++ b/docs/superpowers/plans/2026-08-01-quality-architecture-remediation.md @@ -65,6 +65,20 @@ - [ ] Replace regex/token workflow checks with `node scripts/generate-ci-workflow.ts --check`; generated YAML must match byte-for-byte. - [ ] Re-run tests and check mode; commit with `git commit -m "refactor: generate CI workflow from gate contracts"`. +### Task 3b: Semantic validation for every CI evidence format + +**Files:** +- Modify: `scripts/contracts/release-artifacts.ts` +- Modify: producer scripts for the remaining generic JSON evidence +- Modify: `scripts/lib/ci-artifact-validator.ts` +- Modify: `config/ci/gates.json` +- Modify: `tests/unit/ci-artifact-contract.test.ts` + +- [ ] Inventory every artifact still mapped to `generic-json-object` and export/reuse the producer's strict schema, including cross-field status/failure/count invariants. Do not treat a non-empty JSON object as semantic evidence. +- [ ] Replace substring-only JUnit/HTML acceptance with bounded well-formed document validation. Reject DTD/entities, malformed nesting, duplicate/invalid roots, and trailing non-whitespace content. +- [ ] Add invalid-but-pattern-matching fixtures for all structured kinds and a table proving every configured artifact resolves to a semantic validator. +- [ ] Run focused artifact tests, `corepack pnpm check:ci`, types, lint, and diff checks; commit separately so this evidence-quality closeout is independently reviewable. + ### Task 4: One authoritative architecture graph **Files:** diff --git a/package.json b/package.json index 2d21720..e425e92 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,9 @@ "drill:runbook": "node scripts/drill-runbook.ts", "drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005", "ci:gate": "node scripts/run-ci-gate.ts", - "check:ci": "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts", + "generate:ci-workflow": "node scripts/generate-ci-workflow.ts", + "check:ci-workflow": "node scripts/generate-ci-workflow.ts --check", + "check:ci": "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow", "verify:documentation": "node scripts/verify-documentation-readiness.ts", "build:app-only": "vite build && node scripts/generate-build-manifest.ts", "generate:contract-set": "node scripts/generate-contract-set.ts", diff --git a/scripts/check-ci-contract.ts b/scripts/check-ci-contract.ts index 6b1df3d..fc949ab 100644 --- a/scripts/check-ci-contract.ts +++ b/scripts/check-ci-contract.ts @@ -1,217 +1,42 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; import { evaluatePromotionReadiness, PROMOTION_FORMULA, type GateResult, } from "../src/application/policies/promotion-readiness.ts"; +import { + loadCiGateContract, + indexCiGateContract, +} from "./contracts/ci-gates.ts"; +import { generateCiWorkflow, renderCiWorkflow } from "./generate-ci-workflow.ts"; +import { + ciContractReportSchema, +} from "./lib/ci-contract-report.ts"; +import { validatePackageScriptGraph } from "./lib/package-script-graph.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; -type GateDefinition = Readonly<{ - steps?: readonly unknown[]; - evidence?: readonly string[]; - retentionClass?: string; -}>; -type CiContractDocument = Readonly<{ - providerAdapter: string; - stages: Readonly>>; - gates: Readonly>; - retention: Readonly<{ durationStatus: unknown }>; -}>; - -const document = parseCiContractDocument( - JSON.parse(await readFile("config/ci/gates.json", "utf8")), -); -const workflow = await readFile(document.providerAdapter, "utf8"); -const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { - scripts?: Record; -}; +const contract = await loadCiGateContract(process.cwd()); +const index = indexCiGateContract(contract); +const [packageDocument, nodeVersion] = await Promise.all([ + readFile("package.json", "utf8").then((value) => JSON.parse(value) as { scripts?: Record }), + readFile(".nvmrc", "utf8").then((value) => value.trim()), +]); const packageScripts = packageDocument.scripts ?? {}; -const nodeVersion = (await readFile(".nvmrc", "utf8")).trim(); -const gateRunner = await readFile("scripts/run-ci-gate.ts", "utf8"); -const drillRunner = await readFile("scripts/drill-runbook.ts", "utf8"); -const buildManifestGenerator = await readFile( - "scripts/generate-build-manifest.ts", - "utf8", -); const failures: string[] = []; -if (!/^\d+\.\d+\.\d+$/.test(nodeVersion)) { + +if (!/^\d+\.\d+\.\d+$/u.test(nodeVersion)) { failures.push(".nvmrc must contain one exact Node.js semantic version"); } -const setupNodeCount = - workflow.match(/uses:\s*actions\/setup-node@v4/g)?.length ?? 0; -const nodeVersionFileCount = - workflow.match(/node-version-file:\s*\.nvmrc/g)?.length ?? 0; -if (setupNodeCount === 0 || nodeVersionFileCount !== setupNodeCount) { - failures.push("every setup-node step must use node-version-file: .nvmrc"); -} -if (/node-version\s*:/.test(workflow) || /NODE_VERSION\s*:/.test(workflow)) { - failures.push("workflow must not override the exact .nvmrc Node.js pin"); -} -const stageFormula: Readonly> = { - merge: PROMOTION_FORMULA.MERGE_READY, - release: PROMOTION_FORMULA.RELEASE_READY, - production: PROMOTION_FORMULA.PROD_PROMOTION_READY, - field: PROMOTION_FORMULA.FIELD_SLO_READY, - documentation: PROMOTION_FORMULA.DOCUMENTATION_READY, -}; - -for (const [stage, expectedGates] of Object.entries(stageFormula)) { - const actual = document.stages[stage]?.gates; - if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) { - failures.push(`${stage} gate formula drift`); - } -} - -const configuredGateIds = Object.keys(document.gates).sort(); -const expectedGateIds = Array.from( - { length: 26 }, - (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, -); -if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) { - failures.push("gate registry must contain FE-GATE-001..026 exactly once"); -} - -for (const [gateId, gate] of Object.entries(document.gates)) { - if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) { - failures.push(`${gateId} lacks command, evidence, or retention wiring`); - } - for (const [index, step] of (gate.steps ?? []).entries()) { - if (!isRecord(step) || (step.expect !== "pass" && step.expect !== "fail")) { - failures.push(`${gateId}[${index}] has an invalid step expectation`); - continue; - } - if (step.expect === "pass") { - if ( - step.expectedExitCode !== undefined || - step.expectedDiagnosticId !== undefined - ) { - failures.push( - `${gateId}[${index}] passing step declares a negative fixture identity`, - ); - } - continue; - } - if ( - typeof step.expectedExitCode !== "number" || - !Number.isSafeInteger(step.expectedExitCode) || - step.expectedExitCode < 1 || - step.expectedExitCode > 255 - ) { - failures.push(`${gateId}[${index}] lacks an exact expected exit code`); - } - const diagnosticId = step.expectedDiagnosticId; - if ( - typeof diagnosticId !== "string" || - diagnosticId.trim().length === 0 || - diagnosticId.length > 256 || - ["\r", "\n", "\0"].some( - (character) => - typeof diagnosticId === "string" && diagnosticId.includes(character), - ) - ) { - failures.push( - `${gateId}[${index}] lacks a bounded expected diagnostic identity`, - ); - } - } -} - -const runbookGateEvidence = Object.freeze({ - "FE-GATE-016": "artifacts/runbooks/FE-RB-005/record.json", - "FE-GATE-021": "artifacts/runbooks/FE-RB-001/record.json", - "FE-GATE-022": "artifacts/runbooks/FE-RB-002/record.json", - "FE-GATE-023": "artifacts/runbooks/FE-RB-003/record.json", - "FE-GATE-024": "artifacts/runbooks/FE-RB-004/record.json", - "FE-GATE-025": "artifacts/runbooks/FE-RB-005/record.json", -}); -for (const [gateId, evidencePath] of Object.entries(runbookGateEvidence)) { - const evidence = document.gates[gateId]?.evidence; - if ( - !Array.isArray(evidence) || - evidence.length !== 1 || - evidence[0] !== evidencePath - ) { - failures.push(`${gateId} runbook evidence path drift`); - } -} -if ( - !drillRunner.includes( - "const artifactDirectory = `artifacts/runbooks/${runbookId}`", - ) || - drillRunner.includes( - "artifacts/runbooks/${runbookId}/${release.releaseId}", - ) -) { - failures.push( - "runbook evidence path must be stable while releaseId stays in the record", - ); -} - -const forbiddenWorkflowPatterns = [ - /continue-on-error\s*:/, - /retention-days\s*:/, - /allow_failure\s*:/, -]; -for (const pattern of forbiddenWorkflowPatterns) { - if (pattern.test(workflow)) { - failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`); - } -} -const jobTimeoutCount = workflow.match(/timeout-minutes:\s*45/g)?.length ?? 0; -if (jobTimeoutCount !== 9) { - failures.push("every CI gate job must declare timeout-minutes: 45"); -} -if (/if-no-files-found:\s*warn/.test(workflow)) { - failures.push("CI evidence upload must fail when artifacts are absent"); -} -for (const requiredToken of [ - "merge_gate:", - "release_gate:", - "production_gate:", - "field_gate:", - "documentation_gate:", - "immutable_build:", - "vulnerability_provider:", - "provenance_provider:", - "promotion:", - "needs: merge_gate", - "needs: release_gate", - "needs: production_gate", - "needs: immutable_build", - "needs: [immutable_build, vulnerability_provider, provenance_provider]", - "actions/download-artifact@v4", - "actions/upload-artifact@v4", - "if: always()", - "permissions:", - "contents: read", - 'CI: "true"', - 'VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"', - 'VITE_COMMIT_SHA: "${{ gitea.sha }}"', - 'RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"', - 'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"', - "VULNERABILITY_REPORT_PATH:", - "PROVENANCE_ATTESTATION_PATH:", - "CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml", - "VULNERABILITY_PROVIDER_COMMAND:", - "PROVENANCE_PROVIDER_COMMAND:", - "VULNERABILITY_PUBLIC_KEY_PATH:", - "VULNERABILITY_KEY_ID:", - "PROVENANCE_PUBLIC_KEY_PATH:", - "PROVENANCE_KEY_ID:", - " pnpm-lock.yaml \\", - "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}", - "corepack pnpm verify:provider-evidence", - "corepack pnpm verify:promotion", -]) { - if (!workflow.includes(requiredToken)) { - failures.push(`workflow missing ${requiredToken}`); - } -} for (const script of [ "build:release-candidate", "verify:local-evidence", "verify:provider-evidence", "verify:promotion", + "generate:ci-workflow", + "check:ci-workflow", + "check:ci", ]) { if (!packageScripts[script]) failures.push(`package script missing ${script}`); } @@ -220,160 +45,87 @@ for (const removedScript of [ "verify:supply-chain", "verify:supply-chain:promotion", ]) { - if (packageScripts[removedScript]) { - failures.push(`legacy package script remains ${removedScript}`); - } + if (packageScripts[removedScript]) failures.push(`legacy package script remains ${removedScript}`); } if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"] ?? "")) { failures.push("verify:promotion must not build or rebuild candidate bytes"); } -const promotionWorkflow = workflow.match( - /\n {2}promotion:\n(?[\s\S]*?)\n {2}production_gate:/u, -)?.groups?.body; -if (!promotionWorkflow) { - failures.push("workflow promotion job is missing or misplaced"); -} else if ( - /\b(?:build|build:[\w-]+|rebuild)\b/u.test( - promotionWorkflow.replaceAll("immutable_build", ""), - ) -) { - failures.push("workflow promotion job must not build or rebuild candidate bytes"); +failures.push(...validatePackageScriptGraph(packageScripts, "check:ci")); + +const immutable = index.gates.get("FE-GATE-015"); +const immutableCommands = immutable?.commandIds.map((id) => index.commands.get(id)?.script); +if (JSON.stringify(immutableCommands) !== JSON.stringify(["build:release-candidate", "verify:local-evidence"])) { + failures.push("FE-GATE-015 must build candidate bytes once and verify local evidence only"); } -const immutableGateSteps = document.gates["FE-GATE-015"]?.steps; -if ( - JSON.stringify(immutableGateSteps) !== - JSON.stringify([ - { script: "build:release-candidate", expect: "pass" }, - { script: "verify:local-evidence", expect: "pass" }, - ]) -) { - failures.push("FE-GATE-015 must build the candidate once and verify local evidence only"); +const architecture = index.gates.get("FE-GATE-010"); +if (!architecture?.commandIds.some((id) => index.commands.get(id)?.script === "check:ci")) { + failures.push("a blocking gate must execute check:ci"); } -for (const requiredToken of [ - "ciCheckoutIdentityFailures", - "ciBuildEnvironmentFailures", - "SOURCE_DATE_EPOCH", - '"--format=%H%n%ct"', - "env: gateEnvironment", - "classifyGateStepResult", - "timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS", -]) { - if (!gateRunner.includes(requiredToken)) { - failures.push(`CI gate runner missing ${requiredToken}`); - } -} -for (const requiredToken of [ - "assertCiBuildEnvironment(process.env)", - "releaseId", - "sourceDateEpoch", -]) { - if (!buildManifestGenerator.includes(requiredToken)) { - failures.push(`build manifest generator missing ${requiredToken}`); - } +if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.path === "artifacts/quality/ci-contract.json")) { + failures.push("FE-GATE-010 must publish the typed CI contract report"); } -const passingResults: Record = {}; -for (const gateId of expectedGateIds) passingResults[gateId] = "PASS"; +const expectedGateIds = Array.from( + { length: 26 }, + (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, +); +const passingResults: Record = Object.fromEntries( + expectedGateIds.map((gateId) => [gateId, "PASS"]), +); const allPass = evaluatePromotionReadiness(passingResults); const negativeFixtures: Array<{ readiness: keyof typeof PROMOTION_FORMULA; failedGate: string; passed: boolean; }> = []; -for (const readiness of Object.keys(PROMOTION_FORMULA) as Array< - keyof typeof PROMOTION_FORMULA ->) { - const gateIds = PROMOTION_FORMULA[readiness]; - const failedGate = gateIds[0]; - if (!failedGate) throw new Error(`${readiness} has no configured gates`); - const result = evaluatePromotionReadiness({ - ...passingResults, - [failedGate]: "FAIL", - }); - const passed = result[readiness] === false; +for (const readiness of Object.keys(PROMOTION_FORMULA) as Array) { + const failedGate = PROMOTION_FORMULA[readiness][0]; + if (!failedGate) throw new TypeError(`${readiness} has no configured gates`); + const evaluated = evaluatePromotionReadiness({ ...passingResults, [failedGate]: "FAIL" }); + const passed = evaluated[readiness] === false; negativeFixtures.push({ readiness, failedGate, passed }); if (!passed) failures.push(`${readiness} did not fail closed`); } -if (!Object.values(allPass).every(Boolean)) { - failures.push("all-PASS formula did not produce every readiness state"); -} +if (!Object.values(allPass).every(Boolean)) failures.push("all-PASS formula did not produce every readiness state"); -const report = { - schemaVersion: 1, - generatedAt: new Date().toISOString(), - providerAdapter: document.providerAdapter, +const renderedWorkflow = renderCiWorkflow(contract); +const workflowCheck = await generateCiWorkflow({ + root: process.cwd(), + contract, + check: true, +}); +if (!workflowCheck.matches) { + failures.push( + `generated workflow drift at byte ${workflowCheck.firstDifferenceByte ?? "missing"}, line ${workflowCheck.firstDifferenceLine ?? "missing"}`, + ); +} +let checkedWorkflowBytes = Buffer.from(renderedWorkflow, "utf8"); +try { + checkedWorkflowBytes = await readFile(workflowCheck.target); +} catch (error) { + failures.push(`generated workflow is unreadable: ${error instanceof Error ? error.message : String(error)}`); +} +const report = ciContractReportSchema.parse({ + schemaVersion: 2, nodeVersion, - gateCount: configuredGateIds.length, - noDowngrade: failures.every( - (failure) => !failure.includes("downgrade"), - ), - durationStatus: document.retention.durationStatus, + gateCount: contract.gates.length, + commandDefinitionCount: contract.commands.length, + commandReferenceCount: contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0), + artifactCount: contract.artifacts.length, + jobCount: contract.jobs.length, + workflowSha256: createHash("sha256").update(checkedWorkflowBytes).digest("hex"), + durationStatus: contract.retention.durationStatus, negativeFixtures, failures, passed: failures.length === 0, -}; -await mkdir("artifacts/quality", { recursive: true }); -await writeFile( - "artifacts/quality/ci-contract.json", - `${JSON.stringify(report, null, 2)}\n`, -); +}); +await writeValidatedJsonArtifact({ + path: "artifacts/quality/ci-contract.json", + schema: ciContractReportSchema, + value: report, +}); if (failures.length > 0) { process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`); process.exit(1); } -process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n"); - -function parseCiContractDocument(value: unknown): CiContractDocument { - if (!isRecord(value)) throw new TypeError("CI gate config must be an object"); - if ( - typeof value.providerAdapter !== "string" || - !isRecord(value.stages) || - !isRecord(value.gates) || - !isRecord(value.retention) - ) { - throw new TypeError("CI gate config is missing required registries"); - } - const stages: Record = {}; - for (const [stage, candidate] of Object.entries(value.stages)) { - if (!isRecord(candidate)) throw new TypeError(`Invalid CI stage: ${stage}`); - if ( - candidate.gates !== undefined && - (!Array.isArray(candidate.gates) || - !candidate.gates.every((gate) => typeof gate === "string")) - ) { - throw new TypeError(`Invalid gate list for CI stage: ${stage}`); - } - stages[stage] = { - gates: candidate.gates as readonly string[] | undefined, - }; - } - const gates: Record = {}; - for (const [gateId, candidate] of Object.entries(value.gates)) { - if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`); - if ( - candidate.evidence !== undefined && - (!Array.isArray(candidate.evidence) || - !candidate.evidence.every((path) => typeof path === "string")) - ) { - throw new TypeError(`Invalid evidence list for CI gate: ${gateId}`); - } - gates[gateId] = { - steps: Array.isArray(candidate.steps) ? candidate.steps : undefined, - evidence: candidate.evidence as readonly string[] | undefined, - retentionClass: - typeof candidate.retentionClass === "string" - ? candidate.retentionClass - : undefined, - }; - } - return { - providerAdapter: value.providerAdapter, - stages, - gates, - retention: { durationStatus: value.retention.durationStatus }, - }; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} +process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n"); diff --git a/scripts/check-supply-chain-provider-fixtures.ts b/scripts/check-supply-chain-provider-fixtures.ts index 8775b5d..0af6a40 100644 --- a/scripts/check-supply-chain-provider-fixtures.ts +++ b/scripts/check-supply-chain-provider-fixtures.ts @@ -35,6 +35,7 @@ try { ), ); const actualDefaultVerifier = await verifyPromotionInputs({ + artifactType: "provider-verification", environment: actualProviderEnvironment, }); @@ -83,16 +84,19 @@ try { }); const fixtures = { absent: await verifyPromotionInputs({ + artifactType: "provider-verification", repositoryRoot: fixtureRoot, environment: {}, verifyLocalEvidence: acceptLocalEvidence, }), validImmutable: await verifyPromotionInputs({ + artifactType: "provider-verification", repositoryRoot: fixtureRoot, environment: validEnvironment, verifyLocalEvidence: acceptLocalEvidence, }), wrongDigest: await verifyPromotionInputs({ + artifactType: "provider-verification", repositoryRoot: fixtureRoot, environment: wrongEnvironment, verifyLocalEvidence: acceptLocalEvidence, @@ -103,6 +107,7 @@ try { }; await writeFile(path.join(fixtureRoot, "dist/app.js"), "mutated\n"); fixtures.postAttestationMutation = await verifyPromotionInputs({ + artifactType: "provider-verification", repositoryRoot: fixtureRoot, environment: validEnvironment, verifyLocalEvidence: acceptLocalEvidence, @@ -159,6 +164,7 @@ function absoluteProviderEnvironment( ): NodeJS.ProcessEnv { const absolute = { ...environment }; for (const key of [ + "CANDIDATE_ARCHIVE_PATH", "VULNERABILITY_REPORT_PATH", "PROVENANCE_ATTESTATION_PATH", "VULNERABILITY_PUBLIC_KEY_PATH", @@ -204,6 +210,10 @@ async function writeProviderEnvironment( provenanceKeys.privateKey, ); await Promise.all([ + writeFile( + path.join(repositoryRoot, directory, "candidate.tar.gz"), + "fixture archive\n", + ), writeFile( path.join(repositoryRoot, directory, "vulnerability.json"), `${JSON.stringify(vulnerability)}\n`, @@ -226,6 +236,10 @@ async function writeProviderEnvironment( ), ]); return { + CANDIDATE_ARCHIVE_PATH: `${directory}/candidate.tar.gz`, + CANDIDATE_ARCHIVE_SHA256: createHash("sha256") + .update("fixture archive\n") + .digest("hex"), VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`, PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`, VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`, diff --git a/scripts/check-test-evidence.ts b/scripts/check-test-evidence.ts index 6377b7c..9158bde 100644 --- a/scripts/check-test-evidence.ts +++ b/scripts/check-test-evidence.ts @@ -13,6 +13,7 @@ import { type HttpScenarioReceipt, } from "./lib/http-scenario-evidence.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { testEvidenceReportSchema } from "./lib/test-evidence-artifact.ts"; const scenarioContributionSchema = z .object({ @@ -40,24 +41,6 @@ const policySchema = z }) .strict(); -const reportSchema = z - .object({ - schemaVersion: z.literal(2), - sourceRoot: z.string().min(1), - status: z.enum(["PASS", "FAIL"]), - facts: z - .object({ - scannedFiles: z.number().int().nonnegative(), - visualBaselines: z.number().int().nonnegative(), - sharedScenarios: z.number().int().nonnegative(), - declaredScenarioExecutions: z.number().int().nonnegative(), - executedScenarioExecutions: z.number().int().nonnegative(), - }) - .strict(), - failures: z.array(z.string()), - }) - .strict(); - function argumentValue(name: string, fallback: string): string { const index = process.argv.indexOf(name); return index >= 0 && process.argv[index + 1] @@ -455,7 +438,7 @@ const report = { await mkdir(path.dirname(artifactPath), { recursive: true }); await writeValidatedJsonArtifact({ path: artifactPath, - schema: reportSchema, + schema: testEvidenceReportSchema, value: report, }); if (failures.length > 0) { diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts new file mode 100644 index 0000000..dd65938 --- /dev/null +++ b/scripts/contracts/ci-gates.ts @@ -0,0 +1,993 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { z } from "zod"; + +import { PROMOTION_FORMULA } from "../../src/application/policies/promotion-readiness.ts"; +import { + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, +} from "../lib/release-candidate.ts"; +import { validatePackageScriptGraph } from "../lib/package-script-graph.ts"; +import { PROMOTED_STAGING_PATHS } from "./promotion-artifacts.ts"; + +const ciActionRegistrationSchema = z + .object({ + repository: z + .string() + .regex( + /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u, + "CI action repository must be an absolute upstream GitHub URL", + ), + revision: z + .string() + .regex(/^[0-9a-f]{40}$/u, "CI action revision must be a full 40-hex commit SHA"), + version: z.string().min(1).max(64), + }) + .strict() + .readonly(); + +const ciActionRegistrySchema = z + .object({ + checkout: ciActionRegistrationSchema, + setupNode: ciActionRegistrationSchema, + uploadArtifact: ciActionRegistrationSchema, + downloadArtifact: ciActionRegistrationSchema, + }) + .strict() + .readonly(); + +export type CiActionRegistry = z.infer; +export type CiActionId = keyof CiActionRegistry; + +export function parseCiActionRegistry(value: unknown): CiActionRegistry { + const result = ciActionRegistrySchema.safeParse(value); + if (!result.success) { + const diagnostic = result.error.issues + .map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`) + .join("\n"); + throw new TypeError(`CI action registry invalid:\n${diagnostic}`); + } + return result.data; +} + +export const CI_ACTION_REGISTRY = parseCiActionRegistry({ + checkout: { + repository: "https://github.com/actions/checkout", + revision: "34e114876b0b11c390a56381ad16ebd13914f8d5", + version: "v4.3.1", + }, + setupNode: { + repository: "https://github.com/actions/setup-node", + revision: "49933ea5288caeca8642d1e84afbd3f7d6820020", + version: "v4.4.0", + }, + uploadArtifact: { + repository: "https://github.com/ChristopherHX/gitea-upload-artifact", + revision: "81f940d004763f986ba3582c007fd842dd5cb0d7", + version: "v4 branch", + }, + downloadArtifact: { + repository: "https://github.com/ChristopherHX/gitea-download-artifact", + revision: "75635f32b4c1c41c4b3d64e8f85210112ed4c9c7", + version: "v4 branch", + }, +}); + +const actionIdByStepKind = Object.freeze({ + checkout: "checkout", + "setup-node": "setupNode", + upload: "uploadArtifact", + download: "downloadArtifact", +} as const satisfies Readonly>); + +export function resolveCiActionUses(actionId: CiActionId): string { + const action = CI_ACTION_REGISTRY[actionId]; + if (!action) throw new TypeError(`unknown CI action: ${String(actionId)}`); + return `${action.repository}@${action.revision}`; +} + +export function resolveCiStepActionUses(stepKind: string): string | null { + const actionId = actionIdByStepKind[stepKind as keyof typeof actionIdByStepKind]; + return actionId ? resolveCiActionUses(actionId) : null; +} + +function hasAsciiControl(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f || codePoint === 0x7f; + }); +} + +function hasForbiddenLineOrControl(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0)!; + return ( + codePoint <= 0x1f || + codePoint === 0x7f || + codePoint === 0x85 || + codePoint === 0x2028 || + codePoint === 0x2029 + ); + }); +} + +const nonEmpty = z + .string() + .min(1) + .max(4_096) + .refine((value) => !hasForbiddenLineOrControl(value), "control and Unicode line-break characters are forbidden") + .refine((value) => value === value.trim(), "leading/trailing whitespace is forbidden"); +const id = nonEmpty.max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u); +const repositoryPath = nonEmpty.superRefine((value, context) => { + if ( + value.includes("\\") || + value.includes("\0") || + path.posix.isAbsolute(value) || + path.posix.normalize(value) !== value || + value === "." || + value === ".." || + value.startsWith("../") || + value.includes("/../") + ) { + context.addIssue({ code: "custom", message: `unsafe repository path: ${value}` }); + } +}); +const workflowValue = nonEmpty.refine( + (value) => !value.includes("\0") && !value.includes("\r"), + "workflow values must not contain NUL or carriage returns", +); +const workflowPath = workflowValue.superRefine((value, context) => { + if ( + value.includes("\\") || + path.posix.isAbsolute(value) || + path.posix.normalize(value) !== value || + value === "." || + value === ".." || + value.startsWith("../") || + value.includes("/../") + ) { + context.addIssue({ code: "custom", message: `unsafe workflow path: ${value}` }); + } +}); + +const commandSchema = z + .object({ + id, + script: nonEmpty.regex(/^[A-Za-z0-9:_-]+$/u), + args: z.array(z.string().max(512).refine((value) => !hasAsciiControl(value), "command arguments contain controls")).max(32).optional(), + timeoutMs: z.number().int().min(1_000).max(3_600_000).optional(), + expect: z.enum(["pass", "fail"]), + expectedExitCode: z.number().int().min(1).max(255).optional(), + expectedDiagnosticId: nonEmpty.max(256).optional(), + }) + .strict() + .superRefine((command, context) => { + const hasNegativeIdentity = + command.expectedExitCode !== undefined || + command.expectedDiagnosticId !== undefined; + if (command.expect === "pass" && hasNegativeIdentity) { + context.addIssue({ + code: "custom", + message: `pass command carries negative identity: ${command.id}`, + }); + } + if ( + command.expect === "fail" && + (command.expectedExitCode === undefined || !command.expectedDiagnosticId) + ) { + context.addIssue({ + code: "custom", + message: `negative command lacks exact identity: ${command.id}`, + }); + } + if ( + command.expectedDiagnosticId && + /[\n\r\0]/u.test(command.expectedDiagnosticId) + ) { + context.addIssue({ + code: "custom", + message: `negative command diagnostic is unsafe: ${command.id}`, + }); + } + }); + +const artifactSchemaSchema = z.discriminatedUnion("kind", [ + z.object({ id, kind: z.literal("text"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z + .object({ + id, + kind: z.literal("json"), + maxBytes: z.number().int().min(1).max(268_435_456), + executableSchemaId: z.enum([ + "generic-json-object", + "coverage-summary-v8", + "risk-coverage-v3", + "build-manifest", + "module-inventory", + "dependency-inventory", + "registry-snapshot", + "registry-governance-run", + "bundle-performance", + "sbom", + "provenance", + "dependency-diff", + "license-report", + "vulnerability-report", + "field-web-vitals", + "lab-performance", + "release-verification", + "runbook-record", + "supply-chain-verification", + "release-candidate", + "supply-chain-coherence", + "http-scenario-receipt", + "test-evidence-report", + "provider-vulnerability", + "provider-provenance", + "provider-verification", + "ci-contract-report", + ]), + }) + .strict(), + z + .object({ + id, + kind: z.literal("json-schema"), + maxBytes: z.number().int().min(1).max(268_435_456), + }) + .strict(), + z.object({ id, kind: z.literal("junit"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z.object({ id, kind: z.literal("html"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z.object({ id, kind: z.literal("markdown"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z.object({ id, kind: z.literal("sarif"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z + .object({ id, kind: z.literal("candidate-archive"), maxBytes: z.number().int().min(1).max(268_435_456) }) + .strict(), +]); + +const artifactSchema = z + .object({ id, path: repositoryPath, schemaId: id }) + .strict(); + +const gateSchema = z + .object({ + id: z.string().regex(/^FE-GATE-\d{3}$/u), + name: nonEmpty.regex(/^[a-z0-9][a-z0-9-]*$/u), + commandIds: z.array(id).min(1).max(128), + logArtifactId: id, + evidenceArtifactIds: z.array(id).min(1).max(128), + retentionClassId: id, + requiresEnvironment: z.array(z.string().max(128).regex(/^[A-Z][A-Z0-9_]*$/u)).max(32).optional(), + }) + .strict(); + +const stageSchema = z + .object({ + id, + readiness: z.enum([ + "MERGE_READY", + "RELEASE_READY", + "PROD_PROMOTION_READY", + "FIELD_SLO_READY", + "DOCUMENTATION_READY", + ]), + needs: z.array(id).max(16), + gateIds: z.array(id).min(1).max(64), + }) + .strict(); + +const environmentBindingSchema = z + .object({ name: z.string().regex(/^[A-Z][A-Z0-9_]*$/u), value: workflowValue }) + .strict(); + +const checkoutStep = z.object({ kind: z.literal("checkout") }).strict(); +const setupNodeStep = z.object({ kind: z.literal("setup-node") }).strict(); +const frozenInstallStep = z.object({ kind: z.literal("frozen-install") }).strict(); +const browserInstallStep = z.object({ kind: z.literal("browser-install") }).strict(); +const runGateStep = z.object({ kind: z.literal("run-gate") }).strict(); +const archiveCandidateStep = z + .object({ + kind: z.literal("archive-candidate"), + stepId: id, + archivePath: workflowPath, + members: z.array(repositoryPath).min(1).max(128), + archiveOutputName: id, + distOutputName: id, + }) + .strict(); +const uploadStep = z + .object({ + kind: z.literal("upload"), + transferId: id, + name: workflowValue, + paths: z.array(workflowPath).min(1).max(128), + always: z.boolean().optional(), + }) + .strict(); +const downloadStep = z + .object({ kind: z.literal("download"), transferId: id, path: workflowPath }) + .strict(); +const validateCandidateArchiveStep = z + .object({ + kind: z.literal("validate-candidate-archive"), + archivePath: workflowPath, + }) + .strict(); +const extractStep = z + .object({ + kind: z.literal("extract"), + archivePath: workflowPath, + targetRoot: workflowPath, + }) + .strict(); +const providerStep = z + .object({ kind: z.literal("run-provider"), provider: z.enum(["vulnerability", "provenance"]) }) + .strict(); +const validateProviderStep = z + .object({ + kind: z.literal("validate-provider-evidence"), + provider: z.enum(["vulnerability", "provenance"]), + }) + .strict(); +const promotionStep = z.object({ kind: z.literal("verify-promotion") }).strict(); + +const jobStepSchema = z.discriminatedUnion("kind", [ + checkoutStep, + setupNodeStep, + frozenInstallStep, + browserInstallStep, + runGateStep, + archiveCandidateStep, + uploadStep, + downloadStep, + validateCandidateArchiveStep, + extractStep, + providerStep, + validateProviderStep, + promotionStep, +]); + +const jobSchema = z + .object({ + id, + displayName: workflowValue, + kind: z.enum(["gate-matrix", "gate-single", "immutable", "provider", "promotion"]), + needs: z.array(id).max(32), + condition: z.enum(["always", "merge", "release", "production", "field", "documentation"]), + timeoutMinutes: z.number().int().positive(), + gateIds: z.array(id).max(64), + browserGateIds: z.array(id).max(64), + environment: z.array(environmentBindingSchema).max(32), + steps: z.array(jobStepSchema).min(1).max(64), + }) + .strict(); + +const retentionSchema = z + .object({ + durationStatus: nonEmpty, + classes: z.array(z.object({ id, policy: nonEmpty }).strict()).min(1).max(32), + }) + .strict(); + +const ciGateContractBaseSchema = z + .object({ + schemaVersion: z.literal(2), + providerAdapter: repositoryPath, + commands: z.array(commandSchema).min(1).max(256), + artifactSchemas: z.array(artifactSchemaSchema).min(1).max(128), + artifacts: z.array(artifactSchema).min(1).max(512), + gates: z.array(gateSchema).min(1).max(64), + stages: z.array(stageSchema).min(1).max(16), + jobs: z.array(jobSchema).min(1).max(32), + retention: retentionSchema, + }) + .strict(); + +const ciGateContractSchema = ciGateContractBaseSchema.superRefine( + (contract, context) => validateContractSemantics(contract, context), +); + +export type CiGateContract = z.infer; +export type CiGateCommand = CiGateContract["commands"][number]; +export type CiGateArtifactSchema = CiGateContract["artifactSchemas"][number]; +export type CiGateArtifact = CiGateContract["artifacts"][number]; +export type CiGate = CiGateContract["gates"][number]; +export type CiWorkflowJob = CiGateContract["jobs"][number]; +export type CiWorkflowStep = CiWorkflowJob["steps"][number]; + +export type CiGateContractIndex = Readonly<{ + commands: ReadonlyMap; + artifactSchemas: ReadonlyMap; + artifacts: ReadonlyMap; + gates: ReadonlyMap; + stages: ReadonlyMap; + jobs: ReadonlyMap; + retentionClasses: ReadonlyMap; +}>; + +export function parseCiGateContract(value: unknown): CiGateContract { + const result = ciGateContractSchema.safeParse(value); + if (!result.success) { + const diagnostic = result.error.issues + .map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`) + .join("\n"); + throw new TypeError(`CI gate contract invalid:\n${diagnostic}`); + } + return result.data; +} + +export async function loadCiGateContract(root = process.cwd()): Promise { + const [rawContract, rawPackage] = await Promise.all([ + readFile(path.join(root, "config/ci/gates.json"), "utf8"), + readFile(path.join(root, "package.json"), "utf8"), + ]); + const contract = parseCiGateContract(JSON.parse(rawContract)); + const packageDocument = z + .object({ scripts: z.record(z.string(), z.string()).default({}) }) + .passthrough() + .parse(JSON.parse(rawPackage)); + const missing = contract.commands + .map((command) => command.script) + .filter((script, index, scripts) => scripts.indexOf(script) === index) + .filter((script) => !packageDocument.scripts[script]); + if (missing.length > 0) { + throw new TypeError(`CI gate contract missing package scripts: ${missing.join(", ")}`); + } + const expectedCheckCi = "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow"; + if (packageDocument.scripts["check:ci"] !== expectedCheckCi) { + throw new TypeError("check:ci must use the exact canonical non-recursive orchestration"); + } + const graphFailures = validatePackageScriptGraph(packageDocument.scripts, "check:ci"); + if (graphFailures.length > 0) { + throw new TypeError(`CI package script graph invalid:\n${graphFailures.join("\n")}`); + } + return contract; +} + +export function indexCiGateContract(contract: CiGateContract): CiGateContractIndex { + return Object.freeze({ + commands: new Map(contract.commands.map((entry) => [entry.id, entry])), + artifactSchemas: new Map(contract.artifactSchemas.map((entry) => [entry.id, entry])), + artifacts: new Map(contract.artifacts.map((entry) => [entry.id, entry])), + gates: new Map(contract.gates.map((entry) => [entry.id, entry])), + stages: new Map(contract.stages.map((entry) => [entry.id, entry])), + jobs: new Map(contract.jobs.map((entry) => [entry.id, entry])), + retentionClasses: new Map(contract.retention.classes.map((entry) => [entry.id, entry])), + }); +} + +function validateContractSemantics( + contract: z.infer, + context: z.RefinementCtx, +): void { + const issue = (message: string, path: PropertyKey[] = []) => + context.addIssue({ code: "custom", message, path }); + if (contract.providerAdapter !== ".gitea/workflows/quality-gates.yml") { + issue("providerAdapter must target the canonical generated workflow"); + } + const registries = [ + ["command", contract.commands], + ["artifact schema", contract.artifactSchemas], + ["artifact", contract.artifacts], + ["gate", contract.gates], + ["stage", contract.stages], + ["job", contract.jobs], + ["retention class", contract.retention.classes], + ] as const; + for (const [label, entries] of registries) { + const seen = new Set(); + for (const entry of entries) { + if (seen.has(entry.id)) issue(`duplicate ${label} id: ${entry.id}`); + seen.add(entry.id); + } + } + + const commandTuples = new Map(); + for (const command of contract.commands) { + const { id: _id, ...tuple } = command; + const key = JSON.stringify(tuple); + const previous = commandTuples.get(key); + if (previous) issue(`duplicate command tuple: ${previous}, ${command.id}`); + commandTuples.set(key, command.id); + } + const artifactPaths = new Map(); + for (const artifact of contract.artifacts) { + const previous = artifactPaths.get(artifact.path); + if (previous) issue(`duplicate artifact path: ${previous}, ${artifact.id}`); + artifactPaths.set(artifact.path, artifact.id); + } + + const commandIds = new Set(contract.commands.map(({ id }) => id)); + const schemaIds = new Set(contract.artifactSchemas.map(({ id }) => id)); + const artifactIds = new Set(contract.artifacts.map(({ id }) => id)); + const gateIds = new Set(contract.gates.map(({ id }) => id)); + const stageIds = new Set(contract.stages.map(({ id }) => id)); + const jobIds = new Set(contract.jobs.map(({ id }) => id)); + const retentionIds = new Set(contract.retention.classes.map(({ id }) => id)); + + for (const artifact of contract.artifacts) { + if (!schemaIds.has(artifact.schemaId)) { + issue(`unknown artifact schema ${artifact.schemaId} for ${artifact.id}`); + } + } + for (const gate of contract.gates) { + if (new Set(gate.commandIds).size !== gate.commandIds.length) { + issue(`duplicate command reference within gate: ${gate.id}`); + } + if (new Set(gate.evidenceArtifactIds).size !== gate.evidenceArtifactIds.length) { + issue(`duplicate artifact reference within gate: ${gate.id}`); + } + for (const commandId of gate.commandIds) { + if (!commandIds.has(commandId)) issue(`unknown command ${commandId} for ${gate.id}`); + } + for (const artifactId of [gate.logArtifactId, ...gate.evidenceArtifactIds]) { + if (!artifactIds.has(artifactId)) issue(`unknown artifact ${artifactId} for ${gate.id}`); + } + if (!retentionIds.has(gate.retentionClassId)) { + issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`); + } + } + const referencedCommands = new Set(contract.gates.flatMap(({ commandIds }) => commandIds)); + for (const command of contract.commands) { + if (!referencedCommands.has(command.id)) issue(`orphan command: ${command.id}`); + } + const referencedArtifacts = new Set( + contract.gates.flatMap((gate) => [gate.logArtifactId, ...gate.evidenceArtifactIds]), + ); + for (const artifact of contract.artifacts) { + if (!referencedArtifacts.has(artifact.id)) issue(`orphan artifact: ${artifact.id}`); + } + const referencedSchemas = new Set(contract.artifacts.map(({ schemaId }) => schemaId)); + for (const schema of contract.artifactSchemas) { + if (!referencedSchemas.has(schema.id)) issue(`orphan artifact schema: ${schema.id}`); + } + for (const stage of contract.stages) { + for (const dependency of stage.needs) { + if (dependency === stage.id) issue(`stage self dependency: ${stage.id}`); + else if (!stageIds.has(dependency)) issue(`unknown stage dependency ${dependency} for ${stage.id}`); + } + for (const gateId of stage.gateIds) { + if (!gateIds.has(gateId)) issue(`unknown gate ${gateId} for stage ${stage.id}`); + } + } + for (const cycle of findCycles(contract.stages.map((stage) => [stage.id, stage.needs] as const))) { + issue(`stage dependency cycle: ${cycle.join(" -> ")}`); + } + + const expectedGateIds = Array.from( + { length: 26 }, + (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, + ); + if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) { + issue("gate registry must contain FE-GATE-001..026 in canonical order"); + } + const expectedStages: ReadonlyArray = [ + ["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY], + ["release", "RELEASE_READY", ["merge"], PROMOTION_FORMULA.RELEASE_READY], + ["production", "PROD_PROMOTION_READY", ["release"], PROMOTION_FORMULA.PROD_PROMOTION_READY], + ["field", "FIELD_SLO_READY", ["production"], PROMOTION_FORMULA.FIELD_SLO_READY], + ["documentation", "DOCUMENTATION_READY", [], PROMOTION_FORMULA.DOCUMENTATION_READY], + ]; + const stageShape = contract.stages.map(({ id, readiness, needs, gateIds }) => [id, readiness, needs, gateIds]); + if (JSON.stringify(stageShape) !== JSON.stringify(expectedStages)) { + issue("stage formula/order/ownership drift"); + } + const stageOwners = new Map(); + for (const stage of contract.stages) { + for (const gateId of stage.gateIds) { + stageOwners.set(gateId, [...(stageOwners.get(gateId) ?? []), stage.id]); + } + } + for (const gateId of expectedGateIds) { + if ((stageOwners.get(gateId) ?? []).length !== 1) issue(`gate must belong to exactly one stage: ${gateId}`); + } + + const owners = new Map(); + for (const job of contract.jobs) { + for (const [label, values] of [ + ["needs", job.needs], + ["gateIds", job.gateIds], + ["browserGateIds", job.browserGateIds], + ] as const) { + if (new Set(values).size !== values.length) { + issue(`duplicate ${label} reference in job: ${job.id}`); + } + } + if (job.timeoutMinutes !== 45) issue(`job timeout must be 45 minutes: ${job.id}`); + for (const dependency of job.needs) { + if (dependency === job.id) issue(`job self dependency: ${job.id}`); + else if (!jobIds.has(dependency)) issue(`unknown job dependency ${dependency} for ${job.id}`); + } + for (const gateId of job.gateIds) { + if (!gateIds.has(gateId)) issue(`unknown gate ${gateId} for job ${job.id}`); + owners.set(gateId, [...(owners.get(gateId) ?? []), job.id]); + } + for (const browserGateId of job.browserGateIds) { + if (!job.gateIds.includes(browserGateId)) { + issue(`browser gate ${browserGateId} is not owned by ${job.id}`); + } + } + validateJobStepKinds(job, issue); + for (const step of job.steps) { + const uses = resolveCiStepActionUses(step.kind); + if (uses && !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[0-9a-f]{40}$/u.test(uses)) { + issue(`CI action ref is not an absolute upstream URL pinned to a full commit SHA: ${step.kind}`); + } + } + const envNames = new Set(job.environment.map(({ name }) => name)); + for (const gateId of job.gateIds) { + const gate = contract.gates.find(({ id }) => id === gateId); + for (const required of gate?.requiresEnvironment ?? []) { + if (!envNames.has(required)) issue(`job ${job.id} lacks environment ${required} for ${gateId}`); + } + } + } + for (const gate of contract.gates) { + const gateOwners = owners.get(gate.id) ?? []; + if (gateOwners.length === 0) issue(`unowned gate: ${gate.id}`); + if (gateOwners.length > 1) issue(`multiply owned gate: ${gate.id} by ${gateOwners.join(", ")}`); + } + const expectedJobOwnership: Readonly> = { + merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"], + release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"], + immutable_build: ["FE-GATE-015"], + vulnerability_provider: [], + provenance_provider: [], + promotion: [], + production_gate: ["FE-GATE-016", "FE-GATE-021", "FE-GATE-022", "FE-GATE-023", "FE-GATE-024", "FE-GATE-025"], + field_gate: ["FE-GATE-018"], + documentation_gate: ["FE-GATE-017"], + }; + if (JSON.stringify(contract.jobs.map(({ id }) => id)) !== JSON.stringify(Object.keys(expectedJobOwnership))) { + issue("job registry must contain the exact nine canonical jobs in semantic order"); + } + for (const [jobId, gateIds] of Object.entries(expectedJobOwnership)) { + const job = contract.jobs.find(({ id }) => id === jobId); + if (!job || JSON.stringify(job.gateIds) !== JSON.stringify(gateIds)) { + issue(`exact gate execution ownership drift: ${jobId}`); + } + } + const expectedJobGraph: Readonly> = { + merge_gate: ["gate-matrix", [], "merge"], + release_gate: ["gate-matrix", ["merge_gate"], "release"], + immutable_build: ["immutable", ["release_gate"], "release"], + vulnerability_provider: ["provider", ["immutable_build"], "always"], + provenance_provider: ["provider", ["immutable_build"], "always"], + promotion: ["promotion", ["immutable_build", "vulnerability_provider", "provenance_provider"], "always"], + production_gate: ["gate-matrix", ["promotion"], "production"], + field_gate: ["gate-single", ["production_gate"], "field"], + documentation_gate: ["gate-single", [], "documentation"], + }; + for (const [jobId, [kind, needs, condition]] of Object.entries(expectedJobGraph)) { + const job = contract.jobs.find(({ id }) => id === jobId); + if (!job || job.kind !== kind || JSON.stringify(job.needs) !== JSON.stringify(needs) || job.condition !== condition) { + issue(`job graph drift: ${jobId}`); + } + } + const expectedStepKinds: Readonly> = { + merge_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"], + release_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"], + immutable_build: ["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"], + vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"], + provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"], + promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "extract", "verify-promotion", "upload"], + production_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"], + field_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"], + documentation_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"], + }; + for (const [jobId, expected] of Object.entries(expectedStepKinds)) { + const actual = contract.jobs.find(({ id }) => id === jobId)?.steps.map(({ kind }) => kind); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + issue(`canonical job step sequence drift: ${jobId}`); + } + } + const expectedEnvironmentBindings: Readonly []>> = { + merge_gate: [], + release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }], + immutable_build: [], + vulnerability_provider: [ + { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, + { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, + { name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" }, + { name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-vulnerability/pnpm-lock.yaml" }, + { name: "VULNERABILITY_PROVIDER_COMMAND", value: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" }, + { name: "VULNERABILITY_REPORT_PATH", value: "provider-evidence/untrusted/vulnerability-report.json" }, + { name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/vulnerability-report.json" }, + ], + provenance_provider: [ + { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, + { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, + { name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" }, + { name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-provenance/pnpm-lock.yaml" }, + { name: "PROVENANCE_PROVIDER_COMMAND", value: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" }, + { name: "PROVENANCE_ATTESTATION_PATH", value: "provider-evidence/untrusted/provenance-attestation.json" }, + { name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/provenance-attestation.json" }, + ], + promotion: [ + { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, + { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, + { name: "CANDIDATE_ROOT", value: "${{ gitea.workspace }}/.release/verified-candidate" }, + { name: "VULNERABILITY_REPORT_PATH", value: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" }, + { name: "PROVENANCE_ATTESTATION_PATH", value: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" }, + { name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" }, + { name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" }, + { name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" }, + { name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" }, + ], + production_gate: [], + field_gate: [ + { name: "FIELD_WEB_VITALS_INPUT", value: "${{ vars.FIELD_WEB_VITALS_INPUT }}" }, + { name: "MIN_ELIGIBLE_SAMPLES", value: "${{ vars.MIN_ELIGIBLE_SAMPLES }}" }, + ], + documentation_gate: [], + }; + for (const job of contract.jobs) { + const environmentNames = job.environment.map(({ name }) => name); + if (new Set(environmentNames).size !== environmentNames.length || + JSON.stringify(job.environment) !== JSON.stringify(expectedEnvironmentBindings[job.id])) { + issue(`job environment binding drift: ${job.id}`); + } + } + if (owners.get("FE-GATE-015")?.[0] !== "immutable_build") { + issue("FE-GATE-015 must be owned only by immutable_build"); + } + for (const job of contract.jobs.filter(({ kind }) => kind === "gate-matrix")) { + if (job.gateIds.includes("FE-GATE-015")) issue(`release matrix duplicates FE-GATE-015: ${job.id}`); + } + const browserGateIds = contract.jobs.flatMap(({ browserGateIds }) => browserGateIds).sort(asciiCompare); + if (JSON.stringify(browserGateIds) !== JSON.stringify(["FE-GATE-008", "FE-GATE-009", "FE-GATE-026"])) { + issue(`browser gate set drift: ${browserGateIds.join(",")}`); + } + for (const job of contract.jobs) { + const browserSteps = job.steps.filter(({ kind }) => kind === "browser-install").length; + const expected = job.browserGateIds.length > 0 ? 1 : 0; + if (browserSteps !== expected) issue(`browser install step drift: ${job.id}`); + } + for (const cycle of findCycles(contract.jobs.map((job) => [job.id, job.needs] as const))) { + issue(`job dependency cycle: ${cycle.join(" -> ")}`); + } + + const uploads = new Map }>(); + for (const job of contract.jobs) { + for (const step of job.steps) { + if (step.kind !== "upload") continue; + const previous = uploads.get(step.transferId); + if (previous) issue(`duplicate upload transfer ${step.transferId}: ${previous.producer}, ${job.id}`); + uploads.set(step.transferId, { producer: job.id, step }); + } + } + const dependencies = new Map(contract.jobs.map((job) => [job.id, job.needs])); + for (const job of contract.jobs) { + for (const step of job.steps) { + if (step.kind !== "download") continue; + const upload = uploads.get(step.transferId); + if (!upload) issue(`unknown download transfer ${step.transferId} for ${job.id}`); + else if (!isDependencyReachable(job.id, upload.producer, dependencies)) { + issue(`download producer ${upload.producer} is unreachable from ${job.id}`); + } + } + } + const promotion = contract.jobs.find(({ id }) => id === "promotion"); + if (promotion) { + if (promotion.kind !== "promotion") issue("promotion job kind drift"); + if (promotion.gateIds.length > 0 || promotion.steps.some(({ kind }) => kind === "run-gate")) { + issue("promotion job must not own or run a gate"); + } + const forbidden = promotion.steps.filter(({ kind }) => + ["archive-candidate", "run-provider"].includes(kind), + ); + if (forbidden.length > 0) issue("promotion job must not build or rebuild candidate bytes"); + const order = promotion.steps.map(({ kind }) => kind); + const verificationIndex = order.indexOf("verify-promotion"); + const uploadIndex = order.indexOf("upload"); + if (verificationIndex < 0 || uploadIndex !== verificationIndex + 1) { + issue("promotion verification and upload must be immediately adjacent"); + } + const upload = promotion.steps[uploadIndex]; + if (upload?.kind === "upload" && upload.always) { + issue("promotion upload must not use always"); + } + if ( + order.indexOf("extract") < order.lastIndexOf("download") || + order.indexOf("verify-promotion") < order.indexOf("extract") || + order.indexOf("upload") < order.indexOf("verify-promotion") + ) { + issue("promotion formula order must download, verify, then upload"); + } + } + const immutable = contract.jobs.find(({ id }) => id === "immutable_build"); + const archive = immutable?.steps.find(({ kind }) => kind === "archive-candidate"); + const expectedArchiveMembers = ["dist", ...RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH]; + if ( + !archive || + archive.kind !== "archive-candidate" || + JSON.stringify(archive.members) !== JSON.stringify(expectedArchiveMembers) + ) { + issue("immutable candidate archive member set drift"); + } + if ( + archive?.kind === "archive-candidate" && + (archive.stepId !== "candidate" || + archive.archivePath !== ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" || + archive.archiveOutputName !== "archive_sha256" || + archive.distOutputName !== "dist_sha256") + ) { + issue("immutable candidate output identity drift"); + } + const promotionUpload = promotion?.steps.find( + (step) => step.kind === "upload" && step.transferId === "promoted-release", + ); + if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_STAGING_PATHS)) { + issue("promotion upload bundle must contain the exact five typed paths"); + } + + validateCanonicalStepFields(contract, issue); +} + +function validateCanonicalStepFields( + contract: z.infer, + issue: (message: string, path?: PropertyKey[]) => void, +): void { + const immutable = contract.jobs.find(({ id }) => id === "immutable_build"); + const immutableArchive = immutable?.steps.find(({ kind }) => kind === "archive-candidate"); + const immutableUpload = immutable?.steps.find( + (step) => step.kind === "upload" && step.transferId === "release-candidate", + ); + if ( + !immutableArchive || + immutableArchive.kind !== "archive-candidate" || + !immutableUpload || + immutableUpload.kind !== "upload" || + JSON.stringify(immutableUpload.paths) !== JSON.stringify([immutableArchive.archivePath]) + ) { + issue("immutable archive and upload fields must remain linked"); + } + + const providerExpectations = { + vulnerability_provider: { + provider: "vulnerability", + downloadPath: ".release/vulnerability-candidate", + archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", + targetRoot: ".release/verified-vulnerability", + lockfilePath: ".release/verified-vulnerability/pnpm-lock.yaml", + rawPath: "provider-evidence/untrusted/vulnerability-report.json", + rawName: "VULNERABILITY_REPORT_PATH", + sealedPath: "provider-evidence/vulnerability-report.json", + transferId: "vulnerability-provider-evidence", + }, + provenance_provider: { + provider: "provenance", + downloadPath: ".release/provenance-candidate", + archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", + targetRoot: ".release/verified-provenance", + lockfilePath: ".release/verified-provenance/pnpm-lock.yaml", + rawPath: "provider-evidence/untrusted/provenance-attestation.json", + rawName: "PROVENANCE_ATTESTATION_PATH", + sealedPath: "provider-evidence/provenance-attestation.json", + transferId: "provenance-provider-evidence", + }, + } as const; + for (const [jobId, expected] of Object.entries(providerExpectations)) { + const job = contract.jobs.find(({ id }) => id === jobId); + const environment = new Map(job?.environment.map(({ name, value }) => [name, value])); + const download = job?.steps.find(({ kind }) => kind === "download"); + const extract = job?.steps.find(({ kind }) => kind === "extract"); + const runProvider = job?.steps.find(({ kind }) => kind === "run-provider"); + const validateProvider = job?.steps.find(({ kind }) => kind === "validate-provider-evidence"); + const upload = job?.steps.find( + (step) => step.kind === "upload" && step.transferId === expected.transferId, + ); + if ( + !download || download.kind !== "download" || download.transferId !== "release-candidate" || download.path !== expected.downloadPath || + !extract || extract.kind !== "extract" || extract.archivePath !== expected.archivePath || extract.targetRoot !== expected.targetRoot || + !runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider || + !validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider || + environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath || + environment.get("CANDIDATE_LOCKFILE_PATH") !== expected.lockfilePath || + environment.get(expected.rawName) !== expected.rawPath || + environment.get("VALIDATED_PROVIDER_REPORT_PATH") !== expected.sealedPath || + !upload || upload.kind !== "upload" || JSON.stringify(upload.paths) !== JSON.stringify([expected.sealedPath]) + ) { + issue(`provider archive, extraction, evidence, and upload fields must remain linked: ${jobId}`); + } + } + + const promotion = contract.jobs.find(({ id }) => id === "promotion"); + const promotionDownloads = promotion?.steps.filter(({ kind }) => kind === "download"); + const expectedDownloads = [ + { kind: "download", transferId: "release-candidate", path: ".release/candidate" }, + { kind: "download", transferId: "vulnerability-provider-evidence", path: ".release/vulnerability" }, + { kind: "download", transferId: "provenance-provider-evidence", path: ".release/provenance" }, + ]; + const promotionExtract = promotion?.steps.find(({ kind }) => kind === "extract"); + if ( + JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) || + !promotionExtract || + promotionExtract.kind !== "extract" || + promotionExtract.archivePath !== ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" || + promotionExtract.targetRoot !== ".release/verified-candidate" + ) { + issue("promotion download and extraction fields must remain linked"); + } +} + +function validateJobStepKinds( + job: z.infer, + issue: (message: string, path?: PropertyKey[]) => void, +): void { + const allowed: Readonly["kind"], ReadonlySet>> = { + "gate-matrix": new Set(["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"]), + "gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]), + immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]), + provider: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "run-provider", "validate-provider-evidence", "upload"]), + promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "verify-promotion", "upload"]), + }; + for (const step of job.steps) { + if (!allowed[job.kind].has(step.kind)) { + issue(`step kind ${step.kind} is forbidden for ${job.kind} job ${job.id}`); + } + } + const kinds = job.steps.map(({ kind }) => kind); + const extractIndex = kinds.indexOf("extract"); + if ((job.kind === "provider" || job.kind === "promotion") && extractIndex < 0) { + issue(`verified extraction step is missing: ${job.id}`); + } + if (job.kind === "provider") { + const providerIndex = kinds.indexOf("run-provider"); + const validateProviderIndex = kinds.indexOf("validate-provider-evidence"); + const uploadIndex = kinds.indexOf("upload"); + if ( + providerIndex < extractIndex || + validateProviderIndex < providerIndex || + uploadIndex < validateProviderIndex + ) { + issue(`provider execution/validation/upload order drift: ${job.id}`); + } + } +} + +function findCycles(entries: readonly (readonly [string, readonly string[]])[]): string[][] { + const graph = new Map(entries); + const visiting = new Set(); + const visited = new Set(); + const stack: string[] = []; + const cycles: string[][] = []; + const visit = (node: string): void => { + if (visiting.has(node)) { + const start = stack.indexOf(node); + cycles.push([...stack.slice(start), node]); + return; + } + if (visited.has(node) || !graph.has(node)) return; + visiting.add(node); + stack.push(node); + for (const next of graph.get(node) ?? []) visit(next); + stack.pop(); + visiting.delete(node); + visited.add(node); + }; + for (const node of graph.keys()) visit(node); + return cycles; +} + +function isDependencyReachable( + consumer: string, + producer: string, + graph: ReadonlyMap, +): boolean { + const pending = [...(graph.get(consumer) ?? [])]; + const visited = new Set(); + while (pending.length > 0) { + const current = pending.shift()!; + if (current === producer) return true; + if (visited.has(current)) continue; + visited.add(current); + pending.push(...(graph.get(current) ?? [])); + } + return false; +} + +export function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/scripts/contracts/promotion-artifacts.ts b/scripts/contracts/promotion-artifacts.ts new file mode 100644 index 0000000..6552067 --- /dev/null +++ b/scripts/contracts/promotion-artifacts.ts @@ -0,0 +1,7 @@ +export const PROMOTED_STAGING_PATHS = Object.freeze([ + ".release/promoted-staging/release-candidate.tar.gz", + ".release/promoted-staging/vulnerability-report.json", + ".release/promoted-staging/provenance-attestation.json", + ".release/promoted-staging/provider-verification.json", + ".release/promoted-staging/promotion-verification.json", +] as const); diff --git a/scripts/generate-ci-workflow.ts b/scripts/generate-ci-workflow.ts new file mode 100644 index 0000000..f05b093 --- /dev/null +++ b/scripts/generate-ci-workflow.ts @@ -0,0 +1,466 @@ +import { randomUUID } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { + mkdir, + open, + readFile, + rename, + rm, +} from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { + indexCiGateContract, + loadCiGateContract, + resolveCiStepActionUses, + type CiGateContract, + type CiWorkflowJob, + type CiWorkflowStep, +} from "./contracts/ci-gates.ts"; +import { + assertSafeExistingPublishPath, + assertSafePublishLeaf, + ensureSafePublishDirectory, +} from "./lib/ci-gate-log.ts"; + +export type GenerateCiWorkflowOptions = Readonly<{ + root: string; + contract?: CiGateContract; + check: boolean; +}>; + +export type GenerateCiWorkflowResult = Readonly<{ + target: string; + written: boolean; + matches: boolean; + firstDifferenceByte: number | null; + firstDifferenceLine: number | null; +}>; + +export type CiWorkflowFileSystem = Readonly<{ + mkdir(directory: string): Promise; + readFile(target: string): Promise; + open(target: string, flags: number, mode: number): Promise<{ + writeFile(content: string, encoding: "utf8"): Promise; + sync(): Promise; + close(): Promise; + }>; + openDirectory(target: string): Promise<{ sync(): Promise; close(): Promise }>; + rename(source: string, destination: string): Promise; + rm(target: string): Promise; +}>; + +const defaultFileSystem: CiWorkflowFileSystem = Object.freeze({ + mkdir: async (directory) => mkdir(directory, { recursive: true }), + readFile: async (target) => readFile(target), + open: async (target, flags, mode) => open(target, flags, mode), + openDirectory: async (target) => open(target, constants.O_RDONLY), + rename: async (source, destination) => rename(source, destination), + rm: async (target) => rm(target, { force: true }), +}); + +export function renderCiWorkflow(contract: CiGateContract): string { + const index = indexCiGateContract(contract); + const transfers = new Map(); + for (const job of contract.jobs) { + for (const step of job.steps) { + if (step.kind === "upload") transfers.set(step.transferId, { name: step.name }); + } + } + const lines = [ + "# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`.", + "name: frontend-quality-gates", + "", + "on:", + " push:", + " branches: [develop]", + ' tags: ["v*"]', + " pull_request:", + " workflow_dispatch:", + " inputs:", + " stage:", + " description: Highest promotion tier to evaluate", + " required: true", + " default: merge", + " type: choice", + " options:", + " - merge", + " - release", + " - production", + " - field", + " - documentation", + "", + "permissions:", + " contents: read", + "", + "env:", + ' CI: "true"', + ' VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"', + ' VITE_COMMIT_SHA: "${{ gitea.sha }}"', + ' RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"', + ' CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"', + "", + "jobs:", + ]; + for (const [jobIndex, job] of contract.jobs.entries()) { + if (jobIndex > 0) lines.push(""); + lines.push(...renderJob(job, index, transfers)); + } + return `${lines.join("\n").replace(/\n+$/u, "")}\n`; +} + +function renderJob( + job: CiWorkflowJob, + index: ReturnType, + transfers: ReadonlyMap>, +): string[] { + const lines = [` ${yamlKey(job.id)}:`, ` name: ${yamlScalar(job.displayName)}`]; + if (job.needs.length === 1) lines.push(` needs: ${yamlKey(job.needs[0]!)}`); + if (job.needs.length > 1) lines.push(` needs: [${job.needs.map(yamlKey).join(", ")}]`); + const condition = renderCondition(job.condition); + if (condition) lines.push(` if: ${condition}`); + lines.push(" runs-on: ubuntu-latest", ` timeout-minutes: ${job.timeoutMinutes}`); + if (job.kind === "immutable") { + const archive = job.steps.find((step) => step.kind === "archive-candidate"); + if (!archive || archive.kind !== "archive-candidate") throw new TypeError("immutable job lacks archive step"); + lines.push( + " outputs:", + ` ${archive.distOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.distOutputName} }}`, + ` ${archive.archiveOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.archiveOutputName} }}`, + ); + } + if (job.environment.length > 0) { + lines.push(" env:"); + for (const binding of job.environment) { + lines.push(` ${binding.name}: ${yamlScalar(binding.value)}`); + } + } + if (job.kind === "gate-matrix") { + lines.push(" strategy:", " fail-fast: false", " matrix:", " include:"); + const includesBrowser = job.steps.some(({ kind }) => kind === "browser-install"); + for (const gateId of job.gateIds) { + const gate = index.gates.get(gateId); + if (!gate) throw new TypeError(`unknown gate while rendering: ${gateId}`); + const browser = job.browserGateIds.includes(gateId); + lines.push( + ` - { gate: ${gateId}, name: ${gate.name}${includesBrowser ? `, browser: ${browser ? "true" : "false"}` : ""} }`, + ); + } + } + lines.push(" steps:"); + for (const step of job.steps) lines.push(...renderStep(job, step, transfers)); + return lines; +} + +function renderStep( + job: CiWorkflowJob, + step: CiWorkflowStep, + transfers: ReadonlyMap>, +): string[] { + switch (step.kind) { + case "checkout": + return [ + ` - uses: ${requiredStepActionUses(step.kind)}`, + " with:", + " persist-credentials: false", + ]; + case "setup-node": + return [ + ` - uses: ${requiredStepActionUses(step.kind)}`, + " with:", + " node-version-file: .nvmrc", + ]; + case "frozen-install": + return [ + " - name: Frozen install", + " run: |", + " corepack enable", + " corepack pnpm install --frozen-lockfile", + ]; + case "browser-install": + return [ + " - name: Install Playwright browsers", + ...(job.kind === "gate-matrix" ? [" if: ${{ matrix.browser }}"] : []), + " run: corepack pnpm exec playwright install --with-deps chromium firefox webkit", + ]; + case "run-gate": { + const gateId = job.kind === "gate-matrix" ? "${{ matrix.gate }}" : job.gateIds[0]; + if (!gateId) throw new TypeError(`run-gate step lacks ownership: ${job.id}`); + const name = job.id === "documentation_gate" ? "Run documentation gate" : job.id === "immutable_build" ? "Build candidate once and verify local evidence" : "Run blocking gate"; + return [` - name: ${name}`, ` run: corepack pnpm ci:gate -- ${gateId}`]; + } + case "archive-candidate": { + const archive = shellDoubleQuoted(step.archivePath); + const lines = [ + " - name: Archive and validate the exact candidate file set", + ` id: ${yamlKey(step.stepId)}`, + " run: |", + " mkdir -p .release", + ` tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ${archive} \\`, + ]; + step.members.forEach((member, memberIndex) => { + lines.push(` ${shellWord(member)}${memberIndex === step.members.length - 1 ? "" : " \\"}`); + }); + lines.push( + ` node scripts/verify-ci-candidate-archive.ts --archive ${archive} --github-output "$GITHUB_OUTPUT"`, + ); + return lines; + } + case "download": + return [ + ` - name: Download ${humanize(step.transferId)}`, + ` uses: ${requiredStepActionUses(step.kind)}`, + " with:", + ` name: ${yamlScalar(requiredTransferName(transfers, step.transferId))}`, + ` path: ${yamlScalar(step.path)}`, + ]; + case "validate-candidate-archive": + return [ + " - name: Validate immutable candidate before extraction", + ` run: node scripts/verify-ci-candidate-archive.ts --archive ${shellDoubleQuoted(step.archivePath)}`, + ]; + case "extract": + return [ + " - name: Verify and extract the candidate through one inode-bound operation", + ` run: node scripts/verify-ci-candidate-archive.ts --archive ${shellDoubleQuoted(step.archivePath)} --extract-to ${shellDoubleQuoted(step.targetRoot)}`, + ]; + case "run-provider": { + return [ + ` - name: Run and validate external ${step.provider} provider in one trusted supervisor`, + ` run: node scripts/run-and-validate-provider.ts --kind ${step.provider}`, + ]; + } + case "validate-provider-evidence": + return [ + ` - name: Confirm sealed ${step.provider} provider evidence`, + ' run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"', + ]; + case "verify-promotion": + return [ + " - name: Finalize verified promotion from inode-bound captured inputs", + " run: node scripts/stage-verified-promotion.ts", + ]; + case "upload": { + const lines = [ + ` - name: Upload ${humanize(step.transferId)}`, + ...(step.always ? [" if: always()"] : []), + ` uses: ${requiredStepActionUses(step.kind)}`, + " with:", + ` name: ${yamlScalar(step.name)}`, + ]; + if (step.paths.length === 1) lines.push(` path: ${yamlScalar(step.paths[0]!)}`); + else { + lines.push(" path: |"); + for (const target of step.paths) lines.push(` ${target}`); + } + lines.push(" if-no-files-found: error"); + return lines; + } + } +} + +function requiredStepActionUses(stepKind: string): string { + const uses = resolveCiStepActionUses(stepKind); + if (!uses) throw new TypeError(`workflow step has no registered CI action: ${stepKind}`); + return uses; +} + +function renderCondition(condition: CiWorkflowJob["condition"]): string | null { + const expressions: Record = { + always: null, + merge: "${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}", + release: "${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}", + production: "${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}", + field: "${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}", + documentation: "${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}", + }; + return expressions[condition]; +} + +function requiredTransferName( + transfers: ReadonlyMap>, + transferId: string, +): string { + const transfer = transfers.get(transferId); + if (!transfer) throw new TypeError(`download transfer has no typed producer: ${transferId}`); + return transfer.name; +} + +function yamlKey(value: string): string { + if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new TypeError(`unsafe YAML key: ${value}`); + return value; +} + +function yamlScalar(value: string): string { + if (/^[A-Za-z0-9._/-]+$/u.test(value)) return value; + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n")}"`; +} + +function shellWord(value: string): string { + if (!/^[A-Za-z0-9._/-]+$/u.test(value)) throw new TypeError(`unsafe shell word: ${value}`); + return value; +} + +function shellDoubleQuoted(value: string): string { + const expressions: string[] = []; + const withoutExpressions = value.replace(/\$\{\{ [A-Za-z0-9_.-]+ \}\}/gu, (expression) => { + expressions.push(expression); + return `__CI_EXPRESSION_${expressions.length - 1}__`; + }); + if (withoutExpressions.includes("$")) { + throw new TypeError(`unapproved shell interpolation in workflow value: ${value}`); + } + let escaped = withoutExpressions + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("`", "\\`"); + expressions.forEach((expression, index) => { + escaped = escaped.replace(`__CI_EXPRESSION_${index}__`, expression); + }); + return `"${escaped}"`; +} + +function humanize(value: string): string { + return value.replaceAll("-", " "); +} + +export function createCiWorkflowGenerator( + dependencies: Readonly<{ + fileSystem?: CiWorkflowFileSystem; + createNonce?: () => string; + }> = {}, +) { + const fileSystem = dependencies.fileSystem ?? defaultFileSystem; + const createNonce = dependencies.createNonce ?? randomUUID; + return async function generate(options: GenerateCiWorkflowOptions): Promise { + const root = path.resolve(options.root); + const contract = options.contract ?? (await loadCiGateContract(root)); + const target = path.resolve(root, contract.providerAdapter); + if (path.relative(root, target).startsWith("..") || path.relative(root, target) === "") { + throw new TypeError(`workflow target escapes repository root: ${contract.providerAdapter}`); + } + const expected = Buffer.from(renderCiWorkflow(contract), "utf8"); + let actual: Buffer | null = null; + const existingPathIsSafe = + fileSystem === defaultFileSystem + ? await assertSafeExistingPublishPath(root, target) + : true; + if (existingPathIsSafe) { + try { + actual = await fileSystem.readFile(target); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } + } + const difference = firstDifference(expected, actual); + if (options.check || difference === null) { + return Object.freeze({ + target, + written: false, + matches: difference === null, + firstDifferenceByte: difference?.byte ?? null, + firstDifferenceLine: difference?.line ?? null, + }); + } + + let parentIdentity: Stats | undefined; + if (fileSystem === defaultFileSystem) { + parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target)); + await assertSafePublishLeaf(target, contract.providerAdapter); + } else { + await fileSystem.mkdir(path.dirname(target)); + } + const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${createNonce()}.tmp`); + let ownsTemporary = false; + try { + const handle = await fileSystem.open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o644, + ); + ownsTemporary = true; + let failure: unknown; + try { + await handle.writeFile(expected.toString("utf8"), "utf8"); + await handle.sync(); + } catch (error) { + failure = error; + } + try { + await handle.close(); + } catch (error) { + failure ??= error; + } + if (failure) throw failure; + if (fileSystem === defaultFileSystem && parentIdentity) { + const current = await ensureSafePublishDirectory(root, path.dirname(target)); + if ( + parentIdentity.dev <= 0 || + parentIdentity.ino <= 0 || + current.dev !== parentIdentity.dev || + current.ino !== parentIdentity.ino + ) { + throw new TypeError("CI workflow parent directory identity changed"); + } + await assertSafePublishLeaf(target, contract.providerAdapter); + } + await fileSystem.rename(temporary, target); + ownsTemporary = false; + const directory = await fileSystem.openDirectory(path.dirname(target)); + try { + try { + await directory.sync(); + } catch (error) { + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error; + } + } finally { + await directory.close(); + } + } catch (error) { + if (ownsTemporary) { + try { + await fileSystem.rm(temporary); + } catch { + // The owned sibling temp is the only cleanup target; preserve the publish failure. + } + } + throw error; + } + return Object.freeze({ target, written: true, matches: true, firstDifferenceByte: null, firstDifferenceLine: null }); + }; +} + +export const generateCiWorkflow = createCiWorkflowGenerator(); + +function firstDifference(expected: Buffer, actual: Buffer | null): { byte: number; line: number } | null { + if (actual?.equals(expected)) return null; + const limit = Math.min(expected.byteLength, actual?.byteLength ?? 0); + let byte = 0; + while (byte < limit && expected[byte] === actual?.[byte]) byte += 1; + const line = expected.subarray(0, byte).toString("utf8").split("\n").length; + return { byte, line }; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} + +const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + const check = process.argv.includes("--check"); + try { + const result = await generateCiWorkflow({ root: process.cwd(), check }); + if (!result.matches) { + process.stderr.write( + `CI workflow drift: ${result.target} differs at byte ${result.firstDifferenceByte ?? 0}, line ${result.firstDifferenceLine ?? 1}\n`, + ); + process.exitCode = 1; + } else { + process.stdout.write(check ? "CI workflow bytes: PASS\n" : "CI workflow generated atomically\n"); + } + } catch (error) { + process.stderr.write(`CI workflow generation failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/lib/ci-artifact-validator.ts b/scripts/lib/ci-artifact-validator.ts new file mode 100644 index 0000000..c0517b5 --- /dev/null +++ b/scripts/lib/ci-artifact-validator.ts @@ -0,0 +1,453 @@ +import { constants, type Stats } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { z, type ZodType } from "zod"; + +import type { + CiGateArtifact, + CiGateArtifactSchema, +} from "../contracts/ci-gates.ts"; +import { ciContractReportSchema } from "./ci-contract-report.ts"; +import { + buildManifestArtifactSchema, + bundlePerformanceArtifactSchema, + dependencyDiffArtifactSchema, + dependencyInventoryArtifactSchema, + fieldWebVitalsArtifactSchema, + jsonSchemaDocumentArtifactSchema, + labPerformanceArtifactSchema, + licenseReportArtifactSchema, + moduleInventoryArtifactSchema, + provenanceArtifactSchema, + registryGovernanceRunArtifactSchema, + registrySnapshotArtifactSchema, + releaseVerificationArtifactSchema, + runbookRecordArtifactSchema, + sbomArtifactSchema, + supplyChainVerificationArtifactSchema, + vulnerabilityReportArtifactSchema, +} from "../contracts/release-artifacts.ts"; +import { httpScenarioReceiptSchema } from "./http-scenario-evidence.ts"; +import { supplyChainCoherenceReportSchema } from "./local-release-evidence.ts"; +import { + providerVerificationArtifactSchema, + provenanceProviderAttestationSchema, + vulnerabilityProviderReportSchema, +} from "./provider-evidence.ts"; +import { releaseCandidateManifestSchema } from "./release-candidate.ts"; +import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"; +import { secretScanSarifSchema } from "./secret-scan-evaluator.ts"; +import { testEvidenceReportSchema } from "./test-evidence-artifact.ts"; + +const jsonObjectSchema = z.record(z.string(), z.json()).refine( + (value) => Object.keys(value).length > 0, + "generic JSON artifact must be a non-empty object", +); +const coverageCounterSchema = z + .object({ + total: z.number().int().nonnegative(), + covered: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + pct: z.number().min(0).max(100), + }) + .strict() + .superRefine((counter, context) => { + if (counter.covered + counter.skipped > counter.total) { + context.addIssue({ code: "custom", message: "coverage counter exceeds total" }); + } + const expected = counter.total === 0 + ? 100 + : Math.floor((counter.covered / counter.total) * 10_000) / 100; + if (counter.pct !== expected) { + context.addIssue({ code: "custom", path: ["pct"], message: "coverage pct is not exact" }); + } + }); +const coverageSummarySchema = z + .record( + z.string(), + z + .object({ + lines: coverageCounterSchema, + statements: coverageCounterSchema, + functions: coverageCounterSchema, + branches: coverageCounterSchema, + }) + .strict(), + ) + .refine((value) => "total" in value, "coverage summary lacks total"); +const riskCoverageArtifactSchema = z + .object({ + schemaVersion: z.literal(3), + policy: z.string().min(1), + summary: z.string().min(1), + status: z.enum(["PASS", "FAIL"]), + selectedTotal: z.number().int().nonnegative(), + repositoryTotal: z.number().int().positive(), + counterBearingTotal: z.number().int().nonnegative(), + instrumentedCounterBearingTotal: z.number().int().nonnegative(), + counterlessTotal: z.number().int().nonnegative(), + counterlessModules: z.array(z.string()), + preExclusionTotal: z.number().int().positive(), + generatedExclusionCount: z.number().int().nonnegative(), + generatedExclusions: z.array(z.string()), + ownershipScope: z.literal("ALL_POLICY_HIGH_RISK"), + ownedHighRiskPaths: z.array(z.string()), + waivedHighRiskPaths: z.array(z.string()), + uncoveredModules: z.array(z.string()), + results: z + .array( + z + .object({ + scope: z.string().min(1), + metric: z.enum(["lines", "statements", "functions", "branches"]), + threshold: z.number().min(0).max(100), + received: z.number().min(0).max(100), + passed: z.boolean(), + }) + .strict(), + ) + .min(4), + failures: z.array(z.string()), + }) + .strict() + .superRefine((artifact, context) => { + const fail = (path: PropertyKey[], message: string) => + context.addIssue({ code: "custom", path, message }); + if (artifact.counterBearingTotal + artifact.counterlessTotal !== artifact.repositoryTotal) { + fail(["counterBearingTotal"], "counter partition must equal repositoryTotal"); + } + if (artifact.instrumentedCounterBearingTotal > artifact.counterBearingTotal) { + fail(["instrumentedCounterBearingTotal"], "instrumented counters exceed counter-bearing total"); + } + if (artifact.counterlessModules.length !== artifact.counterlessTotal) { + fail(["counterlessModules"], "counterless list length drift"); + } + if (artifact.generatedExclusions.length !== artifact.generatedExclusionCount) { + fail(["generatedExclusions"], "generated exclusion list length drift"); + } + if ( + artifact.preExclusionTotal !== + artifact.repositoryTotal + artifact.generatedExclusionCount + ) { + fail(["preExclusionTotal"], "pre-exclusion inventory total drift"); + } + if ( + artifact.selectedTotal > artifact.repositoryTotal || + artifact.uncoveredModules.length !== artifact.repositoryTotal - artifact.selectedTotal + ) { + fail(["selectedTotal"], "selected/uncovered repository totals drift"); + } + if ( + (artifact.status === "PASS") !== + (artifact.failures.length === 0 && artifact.results.every(({ passed }) => passed)) + ) { + fail(["status"], "status must agree with failures and threshold results"); + } + artifact.results.forEach((result, index) => { + if (result.passed !== (result.received >= result.threshold)) { + fail(["results", index, "passed"], "threshold result is inconsistent"); + } + }); + for (const [field, values] of [ + ["counterlessModules", artifact.counterlessModules], + ["generatedExclusions", artifact.generatedExclusions], + ["ownedHighRiskPaths", artifact.ownedHighRiskPaths], + ["waivedHighRiskPaths", artifact.waivedHighRiskPaths], + ["uncoveredModules", artifact.uncoveredModules], + ] as const) { + if (new Set(values).size !== values.length) fail([field], "path list contains duplicates"); + } + const owned = new Set(artifact.ownedHighRiskPaths); + if (artifact.waivedHighRiskPaths.some((modulePath) => owned.has(modulePath))) { + fail(["waivedHighRiskPaths"], "owned and waived high-risk paths overlap"); + } + const resultsByScope = new Map>(); + artifact.results.forEach(({ scope, metric }, index) => { + const metrics = resultsByScope.get(scope) ?? new Set(); + if (metrics.has(metric)) { + fail(["results", index, "metric"], "threshold metric is duplicated within scope"); + } + metrics.add(metric); + resultsByScope.set(scope, metrics); + }); + for (const [scope, metrics] of resultsByScope) { + if (metrics.size !== 4) { + fail(["results"], `threshold scope must contain all four metrics: ${scope}`); + } + } + }); + +type ExecutableJsonSchemaId = Extract< + CiGateArtifactSchema, + Readonly<{ kind: "json" }> +>["executableSchemaId"]; + +const executableJsonSchemas: Readonly> = Object.freeze({ + "generic-json-object": jsonObjectSchema, + "coverage-summary-v8": coverageSummarySchema, + "risk-coverage-v3": riskCoverageArtifactSchema, + "build-manifest": buildManifestArtifactSchema, + "module-inventory": moduleInventoryArtifactSchema, + "dependency-inventory": dependencyInventoryArtifactSchema, + "registry-snapshot": registrySnapshotArtifactSchema, + "registry-governance-run": registryGovernanceRunArtifactSchema, + "bundle-performance": bundlePerformanceArtifactSchema, + sbom: sbomArtifactSchema, + provenance: provenanceArtifactSchema, + "dependency-diff": dependencyDiffArtifactSchema, + "license-report": licenseReportArtifactSchema, + "vulnerability-report": vulnerabilityReportArtifactSchema, + "field-web-vitals": fieldWebVitalsArtifactSchema, + "lab-performance": labPerformanceArtifactSchema, + "release-verification": releaseVerificationArtifactSchema, + "runbook-record": runbookRecordArtifactSchema, + "supply-chain-verification": supplyChainVerificationArtifactSchema, + "release-candidate": releaseCandidateManifestSchema, + "supply-chain-coherence": supplyChainCoherenceReportSchema, + "http-scenario-receipt": httpScenarioReceiptSchema, + "test-evidence-report": testEvidenceReportSchema, + "provider-vulnerability": vulnerabilityProviderReportSchema, + "provider-provenance": provenanceProviderAttestationSchema, + "provider-verification": providerVerificationArtifactSchema, + "ci-contract-report": ciContractReportSchema, +}); + +type ReadHandle = Readonly<{ + stat(): Promise; + read( + buffer: Buffer, + offset: number, + length: number, + position: number, + ): Promise>; + close(): Promise; +}>; +type ValidatorDependencies = Readonly<{ + lstatPath?: typeof lstat; + realpathPath?: typeof realpath; + openFile?: (target: string, flags: number) => Promise; +}>; + +export async function validateCiArtifact( + input: Readonly<{ + root: string; + artifact: CiGateArtifact; + schema: CiGateArtifactSchema; + }>, + dependencies: ValidatorDependencies = {}, +): Promise { + const relative = normalizeRepositoryRelativePath(input.artifact.path, "CI artifact path"); + assertExtensionCoherence(relative, input.schema.kind); + const bytes = await readBoundedRegularFile( + { root: input.root, relativePath: relative, maxBytes: input.schema.maxBytes }, + dependencies, + ); + if (input.schema.kind === "candidate-archive") return; + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + if (!text.trim()) throw new TypeError(`CI artifact is empty: ${relative}`); + switch (input.schema.kind) { + case "text": + return; + case "markdown": + if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`); + return; + case "html": + if (!/^\s*(?:\s*)?]*>[\s\S]*<\/html\s*>\s*$/iu.test(text)) { + throw new TypeError(`invalid HTML artifact: ${relative}`); + } + return; + case "junit": + assertWellFormedJUnitXml(text, relative); + return; + case "sarif": + secretScanSarifSchema.parse(JSON.parse(text) as unknown); + return; + case "json-schema": + jsonSchemaDocumentArtifactSchema.parse(JSON.parse(text) as unknown); + return; + case "json": { + const schema = executableJsonSchemas[input.schema.executableSchemaId]; + if (!schema) throw new TypeError(`unknown executable artifact schema: ${input.schema.executableSchemaId}`); + schema.parse(JSON.parse(text) as unknown); + return; + } + } +} + +export async function readBoundedRegularFile( + input: Readonly<{ root: string; relativePath: string; maxBytes: number }>, + dependencies: ValidatorDependencies = {}, +): Promise { + const root = path.resolve(input.root); + const relative = normalizeRepositoryRelativePath(input.relativePath, "bounded file path"); + const maxBytes = input.maxBytes; + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 268_435_456) { + throw new RangeError("bounded file maximum must be within 1..268435456"); + } + const lstatPath = dependencies.lstatPath ?? lstat; + const realpathPath = dependencies.realpathPath ?? realpath; + const openFile = dependencies.openFile ?? (async (target, flags) => open(target, flags)); + const rootMetadata = await lstatPath(root); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new TypeError("bounded file root is unsafe"); + } + const rootRealpath = await realpathPath(root); + let ancestor = root; + const segments = relative.split("/"); + for (const segment of segments.slice(0, -1)) { + ancestor = path.join(ancestor, segment); + const metadata = await lstatPath(ancestor); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new TypeError(`CI artifact ancestor is unsafe: ${relative}`); + } + } + const absolute = path.join(root, relative); + const before = await lstatPath(absolute); + if (before.isSymbolicLink() || !before.isFile()) { + throw new TypeError(`CI artifact is not a regular file: ${relative}`); + } + if (before.size <= 0 || before.size > maxBytes) { + throw new RangeError(`CI artifact size is outside 1..${maxBytes}: ${relative}`); + } + const resolved = await realpathPath(absolute); + const outside = path.relative(rootRealpath, resolved); + if (outside === ".." || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) { + throw new TypeError(`CI artifact escapes repository: ${relative}`); + } + const handle = await openFile(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = await handle.stat(); + assertSameIdentity(before, opened, relative); + const bytes = await readHandleBounded(handle, before.size, maxBytes, relative); + const after = await handle.stat(); + assertSameIdentity(opened, after, relative); + if (bytes.byteLength <= 0 || bytes.byteLength > maxBytes || after.size !== bytes.byteLength) { + throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`); + } + return bytes; + } finally { + await handle.close(); + } +} + +async function readHandleBounded( + handle: ReadHandle, + expectedSize: number, + maxBytes: number, + relative: string, +): Promise { + const captured = Buffer.allocUnsafe(Math.min(maxBytes + 1, expectedSize + 1)); + let offset = 0; + while (offset < captured.byteLength) { + const { bytesRead } = await handle.read( + captured, + offset, + captured.byteLength - offset, + offset, + ); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset !== expectedSize) { + throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`); + } + return captured.subarray(0, offset); +} + +function assertWellFormedJUnitXml(source: string, relative: string): void { + const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`); + if (/", open + 4); + if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid(); + cursor = close + 3; + continue; + } + if (source.startsWith("", open + 9); + if (stack.length === 0 || close < 0) throw invalid(); + cursor = close + 3; + continue; + } + if (source.startsWith("", open + 2); + if (root || close < 0) throw invalid(); + cursor = close + 2; + continue; + } + const close = source.indexOf(">", open + 1); + if (close < 0) throw invalid(); + const tag = source.slice(open, close + 1); + const closing = /^<\/([A-Za-z_][\w:.-]*)\s*>$/u.exec(tag); + if (closing) { + if (stack.pop() !== closing[1]) throw invalid(); + if (stack.length === 0) rootClosed = true; + cursor = close + 1; + continue; + } + const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag); + if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid(); + root ??= opening[1]; + if (opening[3] !== "/") stack.push(opening[1]!); + else if (stack.length === 0) rootClosed = true; + cursor = close + 1; + } + if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) { + throw invalid(); + } +} + +function hasValidXmlAttributes(source: string): boolean { + let remaining = source; + const names = new Set(); + while (remaining.length > 0) { + if (!remaining.trim()) return true; + const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"[^"<]*"|'[^'<]*')/u.exec(remaining); + if (!match || names.has(match[1]!)) return false; + names.add(match[1]!); + remaining = remaining.slice(match[0].length); + } + return true; +} + +function assertSameIdentity(before: Stats, after: Stats, relative: string): void { + if ( + !Number.isSafeInteger(before.dev) || + !Number.isSafeInteger(before.ino) || + before.dev <= 0 || + before.ino <= 0 || + before.dev !== after.dev || + before.ino !== after.ino || + !after.isFile() + ) { + throw new TypeError(`CI artifact file identity changed: ${relative}`); + } +} + +function assertExtensionCoherence(relative: string, kind: CiGateArtifactSchema["kind"]): void { + const valid = + kind === "json" || kind === "json-schema" + ? relative.endsWith(".json") + : kind === "sarif" + ? relative.endsWith(".sarif") + : kind === "junit" + ? relative.endsWith(".xml") + : kind === "html" + ? relative.endsWith(".html") + : kind === "markdown" + ? relative.endsWith(".md") + : kind === "candidate-archive" + ? relative.endsWith(".tar.gz") + : !/\.(?:json|sarif|xml|html|md|tar\.gz)$/u.test(relative); + if (!valid) throw new TypeError(`CI artifact extension/kind mismatch: ${relative} (${kind})`); +} diff --git a/scripts/lib/ci-candidate-archive-cli.ts b/scripts/lib/ci-candidate-archive-cli.ts new file mode 100644 index 0000000..3f5ace4 --- /dev/null +++ b/scripts/lib/ci-candidate-archive-cli.ts @@ -0,0 +1,26 @@ +export const CANDIDATE_ARCHIVE_USAGE = + "Usage: verify-ci-candidate-archive --archive [--extract-to ] [--github-output ]\n"; + +export function parseCandidateArchiveArguments(arguments_: readonly string[]): Readonly<{ + archivePath: string; + extractTo?: string; + githubOutput?: string; +}> | null { + const allowed = new Set(["--archive", "--extract-to", "--github-output"]); + const values = new Map(); + for (let index = 0; index < arguments_.length; index += 2) { + const flag = arguments_[index]; + const value = arguments_[index + 1]; + if (!flag || !allowed.has(flag) || values.has(flag) || !value || value.startsWith("--")) { + return null; + } + values.set(flag, value); + } + const archivePath = values.get("--archive"); + if (!archivePath) return null; + return Object.freeze({ + archivePath, + ...(values.has("--extract-to") ? { extractTo: values.get("--extract-to")! } : {}), + ...(values.has("--github-output") ? { githubOutput: values.get("--github-output")! } : {}), + }); +} diff --git a/scripts/lib/ci-candidate-archive.ts b/scripts/lib/ci-candidate-archive.ts new file mode 100644 index 0000000..5abfccf --- /dev/null +++ b/scripts/lib/ci-candidate-archive.ts @@ -0,0 +1,607 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; +import { + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + rename, + rm, + unlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + distSha256, + releaseCandidateManifestSchema, + type ReleaseCandidateManifest, +} from "./release-candidate.ts"; +import { supplyChainDigest } from "./supply-chain.ts"; +import { + assertSafePublishLeaf, + ensureSafePublishDirectory, +} from "./ci-gate-log.ts"; + +const MAX_ARCHIVE_BYTES = 268_435_456; +const MAX_CANDIDATE_FILES = 4_096; +const MAX_ARCHIVE_MEMBERS = 8_192; +const MAX_MEMBER_PATH_BYTES = 1_024; +const TAR_EXECUTABLE = "/usr/bin/tar"; +const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" }); + +export async function verifyCiCandidateArchive( + input: Readonly<{ + archivePath: string; + expectedSha256?: string; + extractTo?: string; + repositoryRoot?: string; + }>, + dependencies: Readonly<{ afterArchiveRead?: () => Promise }> = {}, +): Promise> { + if (input.expectedSha256 && !/^[a-f0-9]{64}$/u.test(input.expectedSha256)) { + throw new TypeError("expected candidate archive SHA-256 is invalid"); + } + const absolute = path.resolve(input.archivePath); + const before = await lstat(absolute); + if (!before.isFile() || before.isSymbolicLink()) { + throw new TypeError("candidate archive must be a regular non-symlink file"); + } + if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) { + throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`); + } + const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); + let archive: Buffer; + try { + assertSameIdentity(before, await handle.stat()); + archive = await readCapturedArchive(handle, before.size); + assertSameIdentity(before, await handle.stat()); + } finally { + await handle.close(); + } + if (archive.byteLength !== before.size) { + throw new Error("candidate archive changed size during capture"); + } + await dependencies.afterArchiveRead?.(); + const archiveSha256 = createHash("sha256").update(archive).digest("hex"); + if (input.expectedSha256 && archiveSha256 !== input.expectedSha256) { + throw new Error("candidate archive SHA-256 mismatch"); + } + + const extractionTarget = input.extractTo ? path.resolve(input.extractTo) : undefined; + let extractionRoot: string; + let extractionParentIdentity: Awaited> | undefined; + if (extractionTarget) { + if (!input.repositoryRoot) { + throw new TypeError("repositoryRoot is required when publishing an extracted candidate"); + } + const repositoryRoot = path.resolve(input.repositoryRoot); + extractionParentIdentity = await ensureSafePublishDirectory( + repositoryRoot, + path.dirname(extractionTarget), + ); + await assertSafePublishLeaf(extractionTarget, input.extractTo); + extractionRoot = await mkdtemp( + path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`), + ); + } else { + extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-")); + } + let published = false; + try { + const captured = await materializeCapturedArchive(archive); + try { + const preflightManifest = preflightArchiveHandle(captured.handle); + extractArchiveHandle(captured.handle, extractionRoot); + const verified = await verifyExtractedTree(extractionRoot, preflightManifest); + if (extractionTarget) { + const repositoryRoot = path.resolve(input.repositoryRoot!); + const currentParentIdentity = await ensureSafePublishDirectory( + repositoryRoot, + path.dirname(extractionTarget), + ); + if ( + !extractionParentIdentity || + extractionParentIdentity.dev <= 0 || + extractionParentIdentity.ino <= 0 || + currentParentIdentity.dev !== extractionParentIdentity.dev || + currentParentIdentity.ino !== extractionParentIdentity.ino + ) { + throw new Error("verified extraction parent identity changed"); + } + await assertSafePublishLeaf(extractionTarget, input.extractTo); + if (await pathExists(extractionTarget)) { + throw new Error(`verified extraction target already exists: ${input.extractTo}`); + } + await rename(extractionRoot, extractionTarget); + published = true; + } + return Object.freeze({ + archiveSha256, + memberCount: verified.memberCount, + manifest: verified.manifest, + }); + } finally { + await captured.handle.close(); + await rm(captured.root, { recursive: true, force: true }); + } + } finally { + if (!published) await rm(extractionRoot, { recursive: true, force: true }); + } +} + +export async function verifyCapturedCiCandidateArchive( + archive: Buffer, + expectedSha256: string, + dependencies: Readonly<{ + verifyExtracted?: ( + extractionRoot: string, + manifest: ReleaseCandidateManifest, + ) => Promise; + }> = {}, +): Promise> { + if (archive.byteLength <= 0 || archive.byteLength > MAX_ARCHIVE_BYTES) { + throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`); + } + if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) { + throw new TypeError("expected candidate archive SHA-256 is invalid"); + } + const archiveSha256 = createHash("sha256").update(archive).digest("hex"); + if (archiveSha256 !== expectedSha256) { + throw new Error("candidate archive SHA-256 mismatch"); + } + const captured = await materializeCapturedArchive(archive); + const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-")); + try { + const manifest = preflightArchiveHandle(captured.handle); + extractArchiveHandle(captured.handle, extractionRoot); + const verified = await verifyExtractedTree(extractionRoot, manifest); + await dependencies.verifyExtracted?.(extractionRoot, verified.manifest); + return Object.freeze({ + archiveSha256, + memberCount: verified.memberCount, + manifest: verified.manifest, + }); + } finally { + await rm(extractionRoot, { recursive: true, force: true }); + await captured.handle.close(); + await rm(captured.root, { recursive: true, force: true }); + } +} + +function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateManifest { + const listed = spawnSync( + TAR_EXECUTABLE, + ["--list", "--verbose", "--numeric-owner", "--full-time", "--gzip", "--file", "/proc/self/fd/3"], + { + encoding: "utf8", + maxBuffer: 16_777_216, + timeout: 10_000, + env: TAR_ENVIRONMENT, + stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], + }, + ); + if (listed.status !== 0 || listed.signal || listed.error) { + throw new Error( + `candidate archive listing failed: ${listed.stderr || listed.error?.message || listed.signal}`, + ); + } + const seen = new Set(); + const regularMembers = new Set(); + const directoryMembers = new Set(); + let totalBytes = 0; + const lines = listed.stdout.split(/\r?\n/u).filter(Boolean); + if (lines.length === 0 || lines.length > MAX_ARCHIVE_MEMBERS) { + throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`); + } + for (const line of lines) { + const match = /^(?.{10})\s+\d+\/\d+\s+(?\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:\s+[+-]\d{4})?\s+(?.+)$/u.exec(line); + if (!match?.groups) throw new Error(`candidate archive listing is unparseable: ${line}`); + const member = match.groups.path!.endsWith("/") + ? match.groups.path!.slice(0, -1) + : match.groups.path!; + assertSafeMemberPath(member); + if (seen.has(member)) throw new Error(`candidate archive duplicate member: ${member}`); + seen.add(member); + const mode = match.groups.mode!; + if (!mode.startsWith("-") && !mode.startsWith("d")) { + throw new Error(`candidate archive contains non-regular member: ${member}`); + } + if (mode.startsWith("-")) { + const memberBytes = Number(match.groups.bytes); + if ( + member === RELEASE_CANDIDATE_MANIFEST_PATH && + memberBytes > 8_388_608 + ) { + throw new RangeError("candidate manifest exceeds 8388608 bytes"); + } + totalBytes += memberBytes; + if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_ARCHIVE_BYTES) { + throw new RangeError("candidate archive expanded bytes exceed the bound"); + } + regularMembers.add(member); + } else { + directoryMembers.add(member); + } + } + const manifest = readManifestFromArchive(archiveHandle); + validateManifestSemantics(manifest); + const expectedFiles = new Set([ + ...manifest.files.map(({ path: member }) => member), + RELEASE_CANDIDATE_MANIFEST_PATH, + ]); + for (const member of expectedFiles) assertSafeMemberPath(member); + const expectedDirectories = new Set( + directoryAncestors([...expectedFiles]).filter( + (member) => member === "dist" || member.startsWith("dist/"), + ), + ); + if ( + JSON.stringify([...regularMembers].sort(asciiCompare)) !== + JSON.stringify([...expectedFiles].sort(asciiCompare)) || + JSON.stringify([...directoryMembers].sort(asciiCompare)) !== + JSON.stringify([...expectedDirectories].sort(asciiCompare)) + ) { + throw new Error("candidate archive exact member set drift before extraction"); + } + return manifest; +} + +function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void { + const extracted = spawnSync( + TAR_EXECUTABLE, + [ + "--extract", + "--gzip", + "--file", + "/proc/self/fd/3", + "--directory", + extractionRoot, + "--no-same-owner", + "--no-same-permissions", + ], + { + encoding: "utf8", + maxBuffer: 1_048_576, + timeout: 30_000, + env: TAR_ENVIRONMENT, + stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], + }, + ); + if (extracted.status !== 0 || extracted.signal || extracted.error) { + throw new Error( + `candidate archive isolated extraction failed: ${extracted.stderr || extracted.error?.message || extracted.signal}`, + ); + } +} + +function validateManifestSemantics(manifest: ReleaseCandidateManifest): void { + if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) { + throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`); + } + const canonicalFiles = [...manifest.files].sort((left, right) => + asciiCompare(left.path, right.path), + ); + if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) { + throw new Error("candidate manifest files are not in canonical ASCII order"); + } + const expectedFiles = new Map>(); + let declaredBytes = 0; + for (const file of manifest.files) { + assertSafeMemberPath(file.path); + if (expectedFiles.has(file.path)) { + throw new Error(`candidate manifest duplicate file: ${file.path}`); + } + declaredBytes += file.bytes; + if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) { + throw new RangeError("candidate manifest declared bytes exceed the archive bound"); + } + expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 }); + } + const evidencePaths = [...expectedFiles.keys()] + .filter((member) => !member.startsWith("dist/")) + .sort(asciiCompare); + if ( + JSON.stringify(evidencePaths) !== + JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare)) + ) { + throw new Error("candidate manifest evidence member set drift"); + } + const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/")); + if (distFiles.length === 0) throw new Error("candidate manifest has no dist files"); + const lockfile = expectedFiles.get("pnpm-lock.yaml"); + if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) { + throw new Error("candidate manifest lockfile digest summary mismatch"); + } + if ( + distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !== + manifest.distSha256 + ) { + throw new Error("candidate manifest dist digest summary mismatch"); + } + if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) { + throw new Error("candidate manifest bundle digest summary mismatch"); + } +} + +async function verifyExtractedTree( + extractionRoot: string, + preflightManifest: ReleaseCandidateManifest, +): Promise> { + const entries = await walkExtractedTree(extractionRoot); + if (entries.length === 0 || entries.length > MAX_ARCHIVE_MEMBERS) { + throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`); + } + const manifest = releaseCandidateManifestSchema.parse( + JSON.parse( + await readFile(path.join(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"), + ) as unknown, + ); + if (JSON.stringify(manifest) !== JSON.stringify(preflightManifest)) { + throw new Error("candidate manifest changed between preflight and extraction"); + } + if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) { + throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`); + } + const canonicalFiles = [...manifest.files].sort((left, right) => + asciiCompare(left.path, right.path), + ); + if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) { + throw new Error("candidate manifest files are not in canonical ASCII order"); + } + const expectedFiles = new Map>(); + let declaredBytes = 0; + for (const file of manifest.files) { + assertSafeMemberPath(file.path); + if (expectedFiles.has(file.path)) throw new Error(`candidate manifest duplicate file: ${file.path}`); + declaredBytes += file.bytes; + if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) { + throw new RangeError("candidate manifest declared bytes exceed the archive bound"); + } + expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 }); + } + const evidencePaths = [...expectedFiles.keys()] + .filter((member) => !member.startsWith("dist/")) + .sort(asciiCompare); + if ( + JSON.stringify(evidencePaths) !== + JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare)) + ) { + throw new Error("candidate manifest evidence member set drift"); + } + const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/")); + if (distFiles.length === 0) throw new Error("candidate manifest has no dist files"); + const lockfile = expectedFiles.get("pnpm-lock.yaml"); + if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) { + throw new Error("candidate manifest lockfile digest summary mismatch"); + } + if ( + distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !== + manifest.distSha256 + ) { + throw new Error("candidate manifest dist digest summary mismatch"); + } + if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) { + throw new Error("candidate manifest bundle digest summary mismatch"); + } + + const expectedFilePaths = new Set([ + ...expectedFiles.keys(), + RELEASE_CANDIDATE_MANIFEST_PATH, + ]); + const expectedDirectories = new Set(directoryAncestors([...expectedFilePaths])); + for (const entry of entries) { + assertSafeMemberPath(entry.path); + if (entry.type === "directory") { + if (!expectedDirectories.has(entry.path)) { + throw new Error(`candidate archive contains unexpected directory: ${entry.path}`); + } + } else if (!expectedFilePaths.has(entry.path)) { + throw new Error(`candidate archive contains unexpected file: ${entry.path}`); + } + } + const actualFiles = new Set( + entries.filter(({ type }) => type === "file").map(({ path: member }) => member), + ); + for (const expected of expectedFilePaths) { + if (!actualFiles.has(expected)) throw new Error(`candidate archive is missing file: ${expected}`); + } + for (const [member, expected] of expectedFiles) { + const bytes = await readFile(path.join(extractionRoot, member)); + if (bytes.byteLength !== expected.bytes) { + throw new Error(`candidate archive member size mismatch: ${member}`); + } + if (createHash("sha256").update(bytes).digest("hex") !== expected.sha256) { + throw new Error(`candidate archive member digest mismatch: ${member}`); + } + } + return Object.freeze({ memberCount: entries.length, manifest }); +} + +async function walkExtractedTree( + root: string, + relativeDirectory = "", +): Promise>> { + const children = await readdir(path.join(root, relativeDirectory), { + withFileTypes: true, + }); + const entries: Array> = []; + for (const child of children.sort((left, right) => asciiCompare(left.name, right.name))) { + const relative = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + assertSafeMemberPath(relative); + const metadata = await lstat(path.join(root, relative)); + if (metadata.isSymbolicLink()) { + throw new Error(`candidate archive contains non-regular member: ${relative}`); + } + if (metadata.isDirectory() && child.isDirectory()) { + entries.push(Object.freeze({ path: relative, type: "directory" })); + entries.push(...(await walkExtractedTree(root, relative))); + } else if (metadata.isFile() && child.isFile()) { + if (metadata.nlink !== 1) { + throw new Error(`candidate archive contains hard-linked member: ${relative}`); + } + entries.push(Object.freeze({ path: relative, type: "file" })); + } else { + throw new Error(`candidate archive contains non-regular member: ${relative}`); + } + if (entries.length > MAX_ARCHIVE_MEMBERS) { + throw new RangeError(`candidate archive exceeds ${MAX_ARCHIVE_MEMBERS} members`); + } + } + return entries; +} + +function assertSameIdentity( + before: Awaited>, + after: Awaited>, +): void { + if ( + !after.isFile() || + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size + ) { + throw new Error("candidate archive file identity changed"); + } +} + +async function readCapturedArchive( + handle: FileHandle, + expectedSize: number, +): Promise { + const captured = Buffer.allocUnsafe(expectedSize + 1); + let offset = 0; + while (offset < captured.byteLength) { + const { bytesRead } = await handle.read( + captured, + offset, + captured.byteLength - offset, + offset, + ); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset !== expectedSize) { + throw new Error("candidate archive changed size during bounded capture"); + } + return captured.subarray(0, offset); +} + +function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateManifest { + const extracted = spawnSync( + TAR_EXECUTABLE, + [ + "--extract", + "--gzip", + "--to-stdout", + "--file", + "/proc/self/fd/3", + "--", + RELEASE_CANDIDATE_MANIFEST_PATH, + ], + { + maxBuffer: 8_388_609, + timeout: 10_000, + env: TAR_ENVIRONMENT, + stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], + }, + ); + if (extracted.status !== 0 || extracted.signal || extracted.error) { + throw new Error( + `candidate manifest preflight failed: ${String(extracted.stderr) || extracted.error?.message || extracted.signal}`, + ); + } + const bytes = Buffer.from(extracted.stdout); + if (bytes.byteLength === 0 || bytes.byteLength > 8_388_608) { + throw new RangeError("candidate manifest preflight size is outside 1..8388608"); + } + const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return releaseCandidateManifestSchema.parse(JSON.parse(source) as unknown); +} + +async function materializeCapturedArchive( + archive: Buffer, +): Promise> { + const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-")); + const file = path.join(root, "candidate.tar.gz"); + let handle: FileHandle | undefined; + try { + handle = await open( + file, + constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + await handle.writeFile(archive); + await handle.sync(); + await unlink(file); + return Object.freeze({ root, handle }); + } catch (error) { + if (handle) await handle.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + throw error; + } +} + +function assertSafeMemberPath(member: string): void { + if ( + !member || + member.startsWith("-") || + Buffer.byteLength(member, "utf8") > MAX_MEMBER_PATH_BYTES || + member.includes("\\") || + [...member].some((character) => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f || codePoint === 0x7f; + }) || + path.posix.isAbsolute(member) || + path.posix.normalize(member) !== member || + member === ".." || + member.startsWith("../") || + member.includes("/../") + ) { + throw new TypeError(`candidate archive contains unsafe member path: ${member}`); + } +} + +function directoryAncestors(files: readonly string[]): string[] { + const directories = new Set(); + for (const file of files) { + let directory = path.posix.dirname(file); + while (directory !== ".") { + directories.add(directory); + directory = path.posix.dirname(directory); + } + } + return [...directories]; +} + +function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} + +async function pathExists(target: string): Promise { + try { + await lstat(target); + return true; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false; + throw error; + } +} diff --git a/scripts/lib/ci-contract-report.ts b/scripts/lib/ci-contract-report.ts new file mode 100644 index 0000000..74490a9 --- /dev/null +++ b/scripts/lib/ci-contract-report.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +export const ciContractReportSchema = z + .object({ + schemaVersion: z.literal(2), + nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u), + gateCount: z.literal(26), + commandDefinitionCount: z.number().int().positive(), + commandReferenceCount: z.number().int().positive(), + artifactCount: z.number().int().positive(), + jobCount: z.literal(9), + workflowSha256: z.string().regex(/^[a-f0-9]{64}$/u), + durationStatus: z.string().min(1), + negativeFixtures: z.array( + z + .object({ + readiness: z.enum([ + "MERGE_READY", + "RELEASE_READY", + "PROD_PROMOTION_READY", + "FIELD_SLO_READY", + "DOCUMENTATION_READY", + ]), + failedGate: z.string().regex(/^FE-GATE-\d{3}$/u), + passed: z.boolean(), + }) + .strict(), + ), + failures: z.array(z.string()), + passed: z.boolean(), + }) + .strict() + .superRefine((report, context) => { + const fail = (path: PropertyKey[], message: string) => + context.addIssue({ code: "custom", path, message }); + if ((report.passed === true) !== (report.failures.length === 0)) { + fail(["passed"], "passed must agree with failures"); + } + if ( + report.negativeFixtures.length !== 5 || + report.negativeFixtures.some((fixture) => !fixture.passed) + ) { + fail(["negativeFixtures"], "every readiness negative fixture must pass"); + } + }); diff --git a/scripts/lib/ci-gate-log.ts b/scripts/lib/ci-gate-log.ts new file mode 100644 index 0000000..fae675e --- /dev/null +++ b/scripts/lib/ci-gate-log.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { lstat, mkdir, open, rename, rm } from "node:fs/promises"; +import path from "node:path"; + +import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"; + +export async function writeCiGateLogAtomic(input: Readonly<{ + root: string; + relativePath: string; + content: string; + maxBytes?: number; +}>): Promise { + const root = path.resolve(input.root); + const relative = normalizeRepositoryRelativePath(input.relativePath, "CI gate log path"); + const target = path.join(root, relative); + const maxBytes = input.maxBytes ?? 67_108_864; + const contentBytes = Buffer.byteLength(input.content, "utf8"); + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || contentBytes < 1 || contentBytes > maxBytes) { + throw new RangeError(`CI gate log size is outside 1..${maxBytes}: ${relative}`); + } + const parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target)); + await assertSafePublishLeaf(target, relative); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${randomUUID()}.tmp`, + ); + let ownsTemporary = false; + try { + const handle = await open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o644, + ); + ownsTemporary = true; + let failure: unknown; + try { + await handle.writeFile(input.content, "utf8"); + await handle.sync(); + } catch (error) { + failure = error; + } + try { + await handle.close(); + } catch (error) { + failure ??= error; + } + if (failure) throw failure; + await assertDirectoryIdentity(path.dirname(target), parentIdentity, relative); + await assertSafePublishLeaf(target, relative); + await rename(temporary, target); + ownsTemporary = false; + const directory = await open(path.dirname(target), constants.O_RDONLY); + try { + try { + await directory.sync(); + } catch (error) { + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error; + } + } finally { + await directory.close(); + } + } catch (error) { + if (ownsTemporary) { + try { + await rm(temporary, { force: true }); + } catch { + // Preserve the publication failure and clean only the owned sibling temp. + } + } + throw error; + } +} + +export async function ensureSafePublishDirectory( + rootInput: string, + directoryInput: string, +): Promise { + const root = path.resolve(rootInput); + const directory = path.resolve(directoryInput); + const relativeDirectory = path.relative(root, directory); + if ( + relativeDirectory === ".." || + relativeDirectory.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeDirectory) + ) { + throw new TypeError("CI publish directory escapes root"); + } + const rootMetadata = await lstat(root); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new TypeError("CI gate log root is unsafe"); + } + let ancestor = root; + for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) { + ancestor = path.join(ancestor, segment); + let metadata; + try { + metadata = await lstat(ancestor); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + try { + await mkdir(ancestor); + } catch (mkdirError) { + if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError; + } + metadata = await lstat(ancestor); + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new TypeError(`CI publish ancestor is unsafe: ${relativeDirectory}`); + } + } + return lstat(directory); +} + +export async function assertSafePublishLeaf( + target: string, + label = target, +): Promise { + try { + const metadata = await lstat(target); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new TypeError(`CI publish leaf is unsafe: ${label}`); + } + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } +} + +export async function assertSafeExistingPublishPath( + rootInput: string, + targetInput: string, +): Promise { + const root = path.resolve(rootInput); + const target = path.resolve(targetInput); + const relative = path.relative(root, target); + if ( + relative === "" || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new TypeError("CI publish target escapes root"); + } + const rootMetadata = await lstat(root); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new TypeError("CI publish root is unsafe"); + } + const segments = relative.split(path.sep).filter(Boolean); + let current = root; + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + let metadata: Stats; + try { + metadata = await lstat(current); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false; + throw error; + } + const leaf = index === segments.length - 1; + if (metadata.isSymbolicLink() || (leaf ? !metadata.isFile() : !metadata.isDirectory())) { + throw new TypeError(`CI publish path is unsafe: ${relative}`); + } + } + return true; +} + +async function assertDirectoryIdentity( + directory: string, + expected: Stats, + label: string, +): Promise { + const actual = await lstat(directory); + if ( + actual.isSymbolicLink() || + !actual.isDirectory() || + expected.dev <= 0 || + expected.ino <= 0 || + actual.dev !== expected.dev || + actual.ino !== expected.ino + ) { + throw new TypeError(`CI publish directory identity changed: ${label}`); + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/lib/package-script-graph.ts b/scripts/lib/package-script-graph.ts new file mode 100644 index 0000000..7d32772 --- /dev/null +++ b/scripts/lib/package-script-graph.ts @@ -0,0 +1,54 @@ +const packageScriptInvocation = /\b(?:(?:corepack\s+)?pnpm(?:\s+--?[A-Za-z][A-Za-z-]*(?:=[^\s;&|]+)?)*(?:\s+run)?|npm\s+run)\s+([A-Za-z0-9:_-]+)/gu; +const pnpmNonScriptCommands = new Set(["dlx", "exec", "install"]); + +export function validatePackageScriptGraph( + scripts: Readonly>, + entryScript: string, +): string[] { + const failures: string[] = []; + const visiting = new Set(); + const visited = new Set(); + const stack: string[] = []; + + const visit = (scriptName: string): void => { + if (visiting.has(scriptName)) { + const start = stack.indexOf(scriptName); + failures.push(`package script cycle: ${[...stack.slice(start), scriptName].join(" -> ")}`); + return; + } + if (visited.has(scriptName)) return; + const command = scripts[scriptName]; + if (command === undefined) { + failures.push(`package script missing: ${scriptName}`); + return; + } + visiting.add(scriptName); + stack.push(scriptName); + if (/\bscripts\/run-ci-gate(?:\.[cm]?[jt]s)?\b/u.test(command)) { + failures.push(`${scriptName} must not invoke the CI gate runner`); + } + if (/\bci:gate\b/u.test(command)) { + failures.push(`${scriptName} must not invoke ci:gate`); + } + packageScriptInvocation.lastIndex = 0; + const dependencies = Array.from( + command.matchAll(packageScriptInvocation), + (match) => match[1]!, + ).filter((dependency) => !pnpmNonScriptCommands.has(dependency)); + for (const dependency of dependencies) { + if (dependency !== "ci:gate") { + if (!(dependency in scripts)) { + failures.push(`package script missing: ${scriptName} -> ${dependency}`); + } else { + visit(dependency); + } + } + } + stack.pop(); + visiting.delete(scriptName); + visited.add(scriptName); + }; + + visit(entryScript); + return [...new Set(failures)]; +} diff --git a/scripts/lib/promotion-stager.ts b/scripts/lib/promotion-stager.ts new file mode 100644 index 0000000..70980a1 --- /dev/null +++ b/scripts/lib/promotion-stager.ts @@ -0,0 +1,293 @@ +import { createHash, createPublicKey, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdtemp, open, rename, rm } from "node:fs/promises"; +import path from "node:path"; + +import { + evaluatePromotionEvidence, + providerVerificationArtifactSchema, + provenanceProviderAttestationSchema, + vulnerabilityProviderReportSchema, +} from "./provider-evidence.ts"; +import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts"; +import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; +import { verifyReleaseCandidate } from "./release-candidate.ts"; +import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; +import { + assertSafePublishLeaf, + ensureSafePublishDirectory, +} from "./ci-gate-log.ts"; +import { PROMOTED_STAGING_PATHS } from "../contracts/promotion-artifacts.ts"; + +export { PROMOTED_STAGING_PATHS }; + +type PromotionSource = Readonly<{ + sourcePath: string; + destinationName: string; + maxBytes: number; + validate: (bytes: Buffer) => void; +}>; + +type StagedFile = Readonly<{ + destinationName: string; + bytes: Buffer; + digest: string; +}>; + +export async function stageVerifiedPromotion(input: Readonly<{ + repositoryRoot: string; + archivePath: string; + expectedArchiveSha256: string; + vulnerabilityReportPath: string; + provenanceAttestationPath: string; + vulnerabilityPublicKeyPath: string; + vulnerabilityKeyId: string; + provenancePublicKeyPath: string; + provenanceKeyId: string; +}>, dependencies: Readonly<{ + verifyLocalEvidence?: typeof verifyArchivedLocalEvidence; + afterCapture?: () => Promise; + beforePublishRename?: () => Promise; +}> = {}): Promise>> { + const root = path.resolve(input.repositoryRoot); + if (!/^[a-f0-9]{64}$/u.test(input.expectedArchiveSha256)) { + throw new TypeError("promotion archive SHA-256 is invalid"); + } + const sources: PromotionSource[] = [ + { + sourcePath: input.archivePath, + destinationName: "release-candidate.tar.gz", + maxBytes: 268_435_456, + validate: (bytes) => { + if (sha256(bytes) !== input.expectedArchiveSha256) { + throw new Error("promotion archive SHA-256 changed before staging"); + } + }, + }, + { + sourcePath: input.vulnerabilityReportPath, + destinationName: "vulnerability-report.json", + maxBytes: 16_777_216, + validate: (bytes) => vulnerabilityProviderReportSchema.parse(parseJson(bytes)), + }, + { + sourcePath: input.provenanceAttestationPath, + destinationName: "provenance-attestation.json", + maxBytes: 16_777_216, + validate: (bytes) => provenanceProviderAttestationSchema.parse(parseJson(bytes)), + }, + { + sourcePath: "artifacts/security/provider-verification.json", + destinationName: "provider-verification.json", + maxBytes: 4_194_304, + validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)), + }, + { + sourcePath: "artifacts/security/promotion-verification.json", + destinationName: "promotion-verification.json", + maxBytes: 4_194_304, + validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)), + }, + ]; + const [captured, vulnerabilityPublicKey, provenancePublicKey] = await Promise.all([ + Promise.all( + sources.map(async (source) => { + const relativePath = repositoryRelative(root, source.sourcePath); + const bytes = await readBoundedRegularFile({ + root, + relativePath, + maxBytes: source.maxBytes, + }); + source.validate(bytes); + return Object.freeze({ ...source, bytes, digest: sha256(bytes) }); + }), + ), + capture(root, input.vulnerabilityPublicKeyPath, 1_048_576), + capture(root, input.provenancePublicKeyPath, 1_048_576), + ]); + await dependencies.afterCapture?.(); + let capturedLocalStatus: "PASS" | "FAIL" = "FAIL"; + const archive = await verifyCapturedCiCandidateArchive( + captured[0]!.bytes, + input.expectedArchiveSha256, + { + verifyExtracted: async (extractionRoot, manifest) => { + const candidate = await verifyReleaseCandidate(manifest, extractionRoot); + if (candidate.failures.length > 0) { + throw new Error(`captured candidate failed final verification: ${candidate.failures.join(", ")}`); + } + const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({ + repositoryRoot: extractionRoot, + candidate: manifest, + }); + if (local.status !== "PASS" || local.failures.length > 0) { + throw new Error(`captured local evidence failed final verification: ${local.failures.join(", ")}`); + } + capturedLocalStatus = local.status; + }, + }, + ); + const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(captured[1]!.bytes)); + const provenance = provenanceProviderAttestationSchema.parse(parseJson(captured[2]!.bytes)); + const reevaluated = evaluatePromotionEvidence({ + candidate: archive.manifest, + currentDistSha256: archive.manifest.distSha256, + localStatus: capturedLocalStatus, + vulnerabilityReport: vulnerability, + provenanceAttestation: provenance, + vulnerabilityTrust: { + keyId: input.vulnerabilityKeyId, + publicKey: createPublicKey( + new TextDecoder("utf-8", { fatal: true }).decode(vulnerabilityPublicKey), + ), + }, + provenanceTrust: { + keyId: input.provenanceKeyId, + publicKey: createPublicKey( + new TextDecoder("utf-8", { fatal: true }).decode(provenancePublicKey), + ), + }, + }); + if (reevaluated.status !== "PASS" || reevaluated.failures.length > 0) { + throw new Error(`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`); + } + const expectedBindings = { + candidateArchiveSha256: captured[0]!.digest, + vulnerabilityReportSha256: captured[1]!.digest, + provenanceAttestationSha256: captured[2]!.digest, + }; + for (const [index, expectedArtifactType] of [ + [3, "provider-verification"], + [4, "promotion-verification"], + ] as const) { + const verification = providerVerificationArtifactSchema.parse(parseJson(captured[index]!.bytes)); + if (verification.artifactType !== expectedArtifactType) { + throw new Error( + `${captured[index]!.destinationName} artifactType role mismatch: expected ${expectedArtifactType}`, + ); + } + if ( + verification.status !== reevaluated.status || + verification.vulnerabilityStatus !== reevaluated.vulnerabilityStatus || + verification.provenanceAttestationStatus !== reevaluated.provenanceAttestationStatus || + verification.failures.length > 0 + ) { + throw new Error(`${captured[index]!.destinationName} status disagrees with trusted revalidation`); + } + if (verification.lockfileSha256 !== archive.manifest.lockfileSha256) { + throw new Error(`${captured[index]!.destinationName} lockfileSha256 digest mismatch`); + } + if (verification.distSha256 !== archive.manifest.distSha256) { + throw new Error(`${captured[index]!.destinationName} distSha256 digest mismatch`); + } + for (const [binding, expectedDigest] of Object.entries(expectedBindings) as ReadonlyArray< + readonly [keyof typeof expectedBindings, string] + >) { + if (verification[binding] !== expectedDigest) { + throw new Error(`${captured[index]!.destinationName} ${binding} digest mismatch`); + } + } + } + const stagedFiles: readonly StagedFile[] = captured; + + const releaseRoot = path.join(root, ".release"); + const releaseIdentity = await ensureSafePublishDirectory(root, releaseRoot); + const stagingRoot = path.join(releaseRoot, "promoted-staging"); + await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging"); + if (await exists(stagingRoot)) throw new Error("promotion staging target already exists"); + const temporary = await mkdtemp(path.join(root, `.promoted-staging.${randomUUID()}.`)); + let ownsTemporary = true; + try { + for (const source of stagedFiles) { + const handle = await open( + path.join(temporary, source.destinationName), + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + try { + await handle.writeFile(source.bytes); + await handle.sync(); + } finally { + await handle.close(); + } + } + await syncDirectory(temporary); + await dependencies.beforePublishRename?.(); + const currentReleaseIdentity = await ensureSafePublishDirectory(root, releaseRoot); + if ( + releaseIdentity.dev <= 0 || + releaseIdentity.ino <= 0 || + currentReleaseIdentity.dev !== releaseIdentity.dev || + currentReleaseIdentity.ino !== releaseIdentity.ino + ) { + throw new Error("promotion staging parent identity changed"); + } + await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging"); + if (await exists(stagingRoot)) throw new Error("promotion staging target already exists"); + await rename(temporary, stagingRoot); + ownsTemporary = false; + await syncDirectory(releaseRoot); + } finally { + if (ownsTemporary) await rm(temporary, { recursive: true, force: true }); + } + return Object.freeze( + stagedFiles.map(({ destinationName, digest }) => + Object.freeze({ path: `.release/promoted-staging/${destinationName}`, sha256: digest }), + ), + ); +} + +async function capture(root: string, configuredPath: string, maxBytes: number): Promise { + const absolute = path.resolve(root, configuredPath); + const relative = path.relative(root, absolute); + const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + return readBoundedRegularFile({ + root: outside ? path.dirname(absolute) : root, + relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"), + maxBytes, + }); +} + +function parseJson(bytes: Buffer): unknown { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; +} + +function repositoryRelative(root: string, configuredPath: string): string { + const absolute = path.resolve(root, configuredPath); + const relative = path.relative(root, absolute); + if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new TypeError(`promotion source escapes repository: ${configuredPath}`); + } + return relative.replaceAll(path.sep, "/"); +} + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function exists(target: string): Promise { + try { + await lstat(target); + return true; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false; + throw error; + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await open(directory, constants.O_RDONLY); + try { + try { + await handle.sync(); + } catch (error) { + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error; + } + } finally { + await handle.close(); + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/lib/promotion-verifier.ts b/scripts/lib/promotion-verifier.ts index b6c9ab8..66b176b 100644 --- a/scripts/lib/promotion-verifier.ts +++ b/scripts/lib/promotion-verifier.ts @@ -1,9 +1,9 @@ -import { createPublicKey } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { createHash, createPublicKey } from "node:crypto"; import path from "node:path"; import { evaluatePromotionEvidence, + type ProviderVerificationArtifactType, type ProviderTrust, } from "./provider-evidence.ts"; import { @@ -12,20 +12,58 @@ import { verifyReleaseCandidate, } from "./release-candidate.ts"; import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; +import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence; export type VerifyPromotionInputsOptions = Readonly<{ + artifactType: ProviderVerificationArtifactType; environment?: NodeJS.ProcessEnv; repositoryRoot?: string; + providerEvidenceRoot?: string; + trustRoot?: string; verifyLocalEvidence?: LocalEvidenceVerifier; }>; export async function verifyPromotionInputs( - options: VerifyPromotionInputsOptions = {}, + options: VerifyPromotionInputsOptions, ) { const environment = options.environment ?? process.env; const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd()); + const trustRoot = path.resolve(options.trustRoot ?? repositoryRoot); + const providerEvidenceRoot = path.resolve( + options.providerEvidenceRoot ?? repositoryRoot, + ); + const inputFailures: string[] = []; + const archive = await captureOptionalInput( + providerEvidenceRoot, + environment.CANDIDATE_ARCHIVE_PATH, + 268_435_456, + "candidate archive", + inputFailures, + ); + if (!environment.CANDIDATE_ARCHIVE_SHA256) { + inputFailures.push("candidate archive expected SHA-256 is missing"); + } else if ( + archive.sha256 && + archive.sha256 !== environment.CANDIDATE_ARCHIVE_SHA256 + ) { + inputFailures.push("candidate archive SHA-256 does not match immutable output"); + } + const vulnerabilityCapture = await captureOptionalInput( + providerEvidenceRoot, + environment.VULNERABILITY_REPORT_PATH, + 16_777_216, + "vulnerability report", + inputFailures, + ); + const provenanceCapture = await captureOptionalInput( + providerEvidenceRoot, + environment.PROVENANCE_ATTESTATION_PATH, + 16_777_216, + "provenance attestation", + inputFailures, + ); const manifestDocument = await requiredJson( repositoryRoot, RELEASE_CANDIDATE_MANIFEST_PATH, @@ -38,38 +76,34 @@ export async function verifyPromotionInputs( const localEvidence = await ( options.verifyLocalEvidence ?? verifyArchivedLocalEvidence )({ repositoryRoot, candidate: manifest }); - const vulnerabilityReport = await optionalJson( - repositoryRoot, - environment.VULNERABILITY_REPORT_PATH, - ); - const provenanceAttestation = await optionalJson( - repositoryRoot, - environment.PROVENANCE_ATTESTATION_PATH, - ); + const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes); + const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes); const result = evaluatePromotionEvidence({ candidate: manifest, currentDistSha256: candidate.currentDistSha256 ?? "", localStatus: localEvidence.status, vulnerabilityReport, provenanceAttestation, - vulnerabilityTrust: await readTrust( - repositoryRoot, + vulnerabilityTrust: await readProviderTrust( + trustRoot, environment.VULNERABILITY_PUBLIC_KEY_PATH, environment.VULNERABILITY_KEY_ID, ), - provenanceTrust: await readTrust( - repositoryRoot, + provenanceTrust: await readProviderTrust( + trustRoot, environment.PROVENANCE_PUBLIC_KEY_PATH, environment.PROVENANCE_KEY_ID, ), }); const failures = [ + ...inputFailures, ...candidate.failures, ...localEvidence.failures, ...result.failures, ]; return Object.freeze({ - schemaVersion: 1 as const, + schemaVersion: 2 as const, + artifactType: options.artifactType, status: failures.length === 0 && result.status === "PASS" ? ("PASS" as const) @@ -78,11 +112,14 @@ export async function verifyPromotionInputs( provenanceAttestationStatus: result.provenanceAttestationStatus, lockfileSha256: manifest.lockfileSha256, distSha256: manifest.distSha256, + candidateArchiveSha256: archive.sha256, + vulnerabilityReportSha256: vulnerabilityCapture.sha256, + provenanceAttestationSha256: provenanceCapture.sha256, failures: Object.freeze(failures), }); } -async function readTrust( +export async function readProviderTrust( repositoryRoot: string, publicKeyPath: string | undefined, keyId: string | undefined, @@ -92,7 +129,9 @@ async function readTrust( return Object.freeze({ keyId, publicKey: createPublicKey( - await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"), + new TextDecoder("utf-8", { fatal: true }).decode( + await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576), + ), ), }); } catch { @@ -100,15 +139,35 @@ async function readTrust( } } -async function optionalJson( - repositoryRoot: string, - file: string | undefined, -): Promise { - if (!file) return null; +async function captureOptionalInput( + root: string, + configuredPath: string | undefined, + maxBytes: number, + label: string, + failures: string[], +): Promise> { + if (!configuredPath) { + failures.push(`${label} path is missing`); + return Object.freeze({ bytes: null, sha256: null }); + } try { - return JSON.parse( - await readFile(path.resolve(repositoryRoot, file), "utf8"), - ) as unknown; + const bytes = await boundedConfiguredFile(root, configuredPath, maxBytes); + return Object.freeze({ + bytes, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } catch (error) { + failures.push( + `${label} capture failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return Object.freeze({ bytes: null, sha256: null }); + } +} + +function parseCapturedJson(bytes: Buffer | null): unknown { + if (!bytes) return null; + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; } catch { return null; } @@ -119,10 +178,28 @@ async function requiredJson( file: string, ): Promise> { const value: unknown = JSON.parse( - await readFile(path.join(repositoryRoot, file), "utf8"), + new TextDecoder("utf-8", { fatal: true }).decode( + await boundedConfiguredFile(repositoryRoot, file, 8_388_608), + ), ); if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`${file} must be a JSON object`); } return value as Record; } + +async function boundedConfiguredFile( + configuredRoot: string, + configuredPath: string, + maxBytes: number, +): Promise { + const root = path.resolve(configuredRoot); + const absolute = path.resolve(root, configuredPath); + const relative = path.relative(root, absolute); + const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + return readBoundedRegularFile({ + root: outside ? path.dirname(absolute) : root, + relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"), + maxBytes, + }); +} diff --git a/scripts/lib/provider-evidence.ts b/scripts/lib/provider-evidence.ts index af23aa4..3336c3c 100644 --- a/scripts/lib/provider-evidence.ts +++ b/scripts/lib/provider-evidence.ts @@ -44,15 +44,58 @@ export const provenanceProviderAttestationSchema = z export const providerVerificationArtifactSchema = z .object({ - schemaVersion: z.literal(1), + schemaVersion: z.literal(2), + artifactType: z.enum(["provider-verification", "promotion-verification"]), status: z.enum(["PASS", "FAIL_UNVERIFIED"]), vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), lockfileSha256: sha256, distSha256: sha256, + candidateArchiveSha256: sha256.nullable(), + vulnerabilityReportSha256: sha256.nullable(), + provenanceAttestationSha256: sha256.nullable(), failures: z.array(z.string()), }) - .strict(); + .strict() + .superRefine((artifact, context) => { + const passing = + artifact.status === "PASS" && + artifact.vulnerabilityStatus === "PASS" && + artifact.provenanceAttestationStatus === "PASS" && + artifact.failures.length === 0; + if ((artifact.status === "PASS") !== passing) { + context.addIssue({ + code: "custom", + path: ["status"], + message: "verification PASS must agree with provider statuses and failures", + }); + } + if ( + artifact.status === "PASS" && + [ + artifact.candidateArchiveSha256, + artifact.vulnerabilityReportSha256, + artifact.provenanceAttestationSha256, + ].some((digest) => digest === null) + ) { + context.addIssue({ + code: "custom", + path: ["candidateArchiveSha256"], + message: "passing verification requires every exact input digest", + }); + } + if (artifact.status === "FAIL_UNVERIFIED" && artifact.failures.length === 0) { + context.addIssue({ + code: "custom", + path: ["failures"], + message: "failed verification requires a failure diagnostic", + }); + } + }); + +export type ProviderVerificationArtifactType = z.infer< + typeof providerVerificationArtifactSchema +>["artifactType"]; export type ProviderTrust = Readonly<{ keyId: string; diff --git a/scripts/lib/provider-upload-validator.ts b/scripts/lib/provider-upload-validator.ts new file mode 100644 index 0000000..4dd95e8 --- /dev/null +++ b/scripts/lib/provider-upload-validator.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { + provenanceProviderAttestationSchema, + vulnerabilityProviderReportSchema, +} from "./provider-evidence.ts"; +import { + verifyReleaseCandidate, +} from "./release-candidate.ts"; +import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; +import { verifyCiCandidateArchive } from "./ci-candidate-archive.ts"; + +export async function validateProviderUpload(input: Readonly<{ + kind: "vulnerability" | "provenance"; + candidateRoot: string; + archivePath: string; + expectedArchiveSha256: string; + reportPath: string; + workspaceRoot?: string; + expectedDistSha256: string; +}>): Promise { + if (!/^[a-f0-9]{64}$/u.test(input.expectedDistSha256)) { + throw new TypeError("expected candidate dist SHA-256 is invalid"); + } + const archive = await verifyCiCandidateArchive({ + archivePath: input.archivePath, + expectedSha256: input.expectedArchiveSha256, + }); + const manifest = archive.manifest; + if (manifest.distSha256 !== input.expectedDistSha256) { + throw new Error("provider input candidate dist digest mismatch"); + } + const verifiedCandidate = await verifyReleaseCandidate(manifest, input.candidateRoot); + if (verifiedCandidate.failures.length > 0) { + throw new Error( + `provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`, + ); + } + const reportAbsolute = path.resolve(input.reportPath); + const reportRoot = path.resolve(input.workspaceRoot ?? process.cwd()); + const reportRelative = path.relative(reportRoot, reportAbsolute).replaceAll(path.sep, "/"); + const report = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + await readBoundedRegularFile({ + root: reportRoot, + relativePath: reportRelative, + maxBytes: 8_388_608, + }), + ), + ) as unknown; + if (input.kind === "vulnerability") { + const parsed = vulnerabilityProviderReportSchema.parse(report); + const lockfile = await readBoundedRegularFile({ + root: input.candidateRoot, + relativePath: "pnpm-lock.yaml", + maxBytes: 67_108_864, + }); + const lockfileSha256 = createHash("sha256").update(lockfile).digest("hex"); + if ( + parsed.scannedDistSha256 !== manifest.distSha256 || + parsed.scannedLockfileSha256 !== manifest.lockfileSha256 || + lockfileSha256 !== manifest.lockfileSha256 + ) { + throw new Error("vulnerability provider evidence candidate digest mismatch"); + } + return parsed; + } + const parsed = provenanceProviderAttestationSchema.parse(report); + if (parsed.subject.digest.sha256 !== manifest.distSha256) { + throw new Error("provenance provider evidence candidate digest mismatch"); + } + return parsed; +} diff --git a/scripts/lib/release-candidate.ts b/scripts/lib/release-candidate.ts index 657fe71..0bbd90f 100644 --- a/scripts/lib/release-candidate.ts +++ b/scripts/lib/release-candidate.ts @@ -107,7 +107,7 @@ export async function createReleaseCandidateManifest( sha256, })), ...evidence, - ].sort((left, right) => left.path.localeCompare(right.path)); + ].sort((left, right) => asciiCompare(left.path, right.path)); const dependencyInventory = JSON.parse( await readFile( path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"), @@ -134,6 +134,10 @@ export async function createReleaseCandidateManifest( }); } +function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + export async function verifyReleaseCandidate( value: unknown, repositoryRoot = process.cwd(), @@ -202,7 +206,7 @@ async function regularFilesWithin(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); const files: string[] = []; for (const entry of entries.sort((left, right) => - left.name.localeCompare(right.name), + asciiCompare(left.name, right.name), )) { const target = path.join(directory, entry.name); if (entry.isDirectory()) { diff --git a/scripts/lib/test-evidence-artifact.ts b/scripts/lib/test-evidence-artifact.ts new file mode 100644 index 0000000..1f86f3f --- /dev/null +++ b/scripts/lib/test-evidence-artifact.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +export const testEvidenceReportSchema = z + .object({ + schemaVersion: z.literal(2), + sourceRoot: z.string().min(1), + status: z.enum(["PASS", "FAIL"]), + facts: z + .object({ + scannedFiles: z.number().int().nonnegative(), + visualBaselines: z.number().int().nonnegative(), + sharedScenarios: z.number().int().nonnegative(), + declaredScenarioExecutions: z.number().int().nonnegative(), + executedScenarioExecutions: z.number().int().nonnegative(), + }) + .strict(), + failures: z.array(z.string()), + }) + .strict() + .superRefine((report, context) => { + const passed = report.status === "PASS"; + if (passed !== (report.failures.length === 0)) { + context.addIssue({ code: "custom", path: ["status"], message: "status must agree with failures" }); + } + if ( + passed && + report.facts.declaredScenarioExecutions !== + report.facts.executedScenarioExecutions + ) { + context.addIssue({ + code: "custom", + path: ["facts", "executedScenarioExecutions"], + message: "PASS requires exact declared/executed scenario agreement", + }); + } + }); diff --git a/scripts/run-and-validate-provider.ts b/scripts/run-and-validate-provider.ts new file mode 100644 index 0000000..483db15 --- /dev/null +++ b/scripts/run-and-validate-provider.ts @@ -0,0 +1,248 @@ +import { spawn } from "node:child_process"; +import { constants } from "node:fs"; +import { access, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + provenanceProviderAttestationSchema, + vulnerabilityProviderReportSchema, +} from "./lib/provider-evidence.ts"; +import { validateProviderUpload } from "./lib/provider-upload-validator.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { + assertSafePublishLeaf, + ensureSafePublishDirectory, +} from "./lib/ci-gate-log.ts"; + +const kind = process.argv[process.argv.indexOf("--kind") + 1]; +if (kind !== "vulnerability" && kind !== "provenance") { + process.stderr.write("Usage: run-and-validate-provider --kind vulnerability|provenance\n"); + process.exit(2); +} +const command = + kind === "vulnerability" + ? process.env.VULNERABILITY_PROVIDER_COMMAND + : process.env.PROVENANCE_PROVIDER_COMMAND; +const reportPath = + kind === "vulnerability" + ? process.env.VULNERABILITY_REPORT_PATH + : process.env.PROVENANCE_ATTESTATION_PATH; +const sealedPath = process.env.VALIDATED_PROVIDER_REPORT_PATH; +const candidateLockfile = process.env.CANDIDATE_LOCKFILE_PATH; +const archivePath = process.env.CANDIDATE_ARCHIVE_PATH; +const archiveSha256 = process.env.CANDIDATE_ARCHIVE_SHA256; +const candidateDistSha256 = process.env.CANDIDATE_DIST_SHA256; +if ( + !command || + !reportPath || + !sealedPath || + !candidateLockfile || + !archivePath || + !archiveSha256 || + !candidateDistSha256 +) { + process.stderr.write("Provider supervisor environment is incomplete\n"); + process.exit(2); +} + +const workspaceRoot = process.cwd(); +const reportAbsolute = path.resolve(reportPath); +const rawDirectory = path.dirname(reportAbsolute); +const sealedAbsolute = path.resolve(sealedPath); +if ( + path.basename(rawDirectory) !== "untrusted" || + path.dirname(rawDirectory) !== path.dirname(sealedAbsolute) || + reportAbsolute === sealedAbsolute +) { + throw new TypeError("provider raw and sealed evidence paths are not isolated"); +} +await prepareMissingProviderOutput(workspaceRoot, reportAbsolute, reportPath, "raw provider report"); +await prepareMissingProviderOutput(workspaceRoot, sealedAbsolute, sealedPath, "sealed provider report"); +await access("/usr/bin/bwrap", constants.X_OK).catch(() => { + throw new Error("provider sandbox unavailable: /usr/bin/bwrap is required"); +}); + +const childEnvironment = createProviderEnvironment(kind, reportPath, { + candidateLockfile, + archivePath, + archiveSha256, + candidateDistSha256, +}); +await runProviderInSandbox(command, childEnvironment, rawDirectory, workspaceRoot); +const parsed = await validateProviderUpload({ + kind, + candidateRoot: path.dirname(path.resolve(candidateLockfile)), + archivePath, + expectedArchiveSha256: archiveSha256, + reportPath, + workspaceRoot, + expectedDistSha256: candidateDistSha256, +}); +await assertSafePublishLeaf(sealedAbsolute, sealedPath); +await writeValidatedJsonArtifact({ + path: sealedPath, + schema: + kind === "vulnerability" + ? vulnerabilityProviderReportSchema + : provenanceProviderAttestationSchema, + value: parsed, +}); +process.stdout.write(`${kind} provider supervised validation: PASS\n`); + +function createProviderEnvironment( + providerKind: "vulnerability" | "provenance", + rawReportPath: string, + candidate: Readonly<{ + candidateLockfile: string; + archivePath: string; + archiveSha256: string; + candidateDistSha256: string; + }>, +): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = { + PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", + HOME: "/tmp/provider-home", + TMPDIR: "/tmp", + CI: "true", + GITHUB_ENV: "/tmp/github-env", + GITHUB_PATH: "/tmp/github-path", + CANDIDATE_LOCKFILE_PATH: candidate.candidateLockfile, + CANDIDATE_ARCHIVE_PATH: candidate.archivePath, + CANDIDATE_ARCHIVE_SHA256: candidate.archiveSha256, + CANDIDATE_DIST_SHA256: candidate.candidateDistSha256, + ...(providerKind === "vulnerability" + ? { VULNERABILITY_REPORT_PATH: rawReportPath } + : { PROVENANCE_ATTESTATION_PATH: rawReportPath }), + }; + for (const name of ["LANG", "LC_ALL", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"] as const) { + if (process.env[name]) environment[name] = process.env[name]; + } + const credentialPrefix = `${providerKind.toUpperCase()}_PROVIDER_`; + for (const [name, value] of Object.entries(process.env)) { + if (name.startsWith(credentialPrefix) && !name.endsWith("_COMMAND") && value) { + environment[name] = value; + } + } + return environment; +} + +async function runProviderInSandbox( + command: string, + environment: NodeJS.ProcessEnv, + rawDirectory: string, + workspaceRoot: string, +): Promise { + const scratch = await mkdtemp(path.join(tmpdir(), "ci-provider-sandbox-")); + try { + await mkdir(path.join(scratch, "provider-home")); + await writeFile(path.join(scratch, "node"), "", { mode: 0o500 }); + const arguments_ = [ + "--die-with-parent", + "--new-session", + "--as-pid-1", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--dev", "/dev", + "--proc", "/proc", + "--bind", scratch, "/tmp", + "--dir", "/etc", + ]; + for (const source of ["/usr", "/bin", "/lib", "/lib64"]) { + if (await exists(source)) arguments_.push("--ro-bind", source, source); + } + // setup-node commonly installs outside /usr. Expose only the exact trusted + // runtime binary, never its credential-bearing user/toolcache directory. + arguments_.push("--ro-bind", process.execPath, "/tmp/node"); + for (const source of [ + "/etc/ca-certificates", + "/etc/ssl", + "/etc/resolv.conf", + "/etc/hosts", + "/etc/nsswitch.conf", + "/etc/passwd", + "/etc/group", + ]) { + if (await exists(source)) arguments_.push("--ro-bind", source, source); + } + for (const directory of missingDestinationAncestors(workspaceRoot)) { + arguments_.push("--dir", directory); + } + arguments_.push( + "--ro-bind", workspaceRoot, workspaceRoot, + ); + if (await exists(path.join(workspaceRoot, ".git"))) { + arguments_.push("--tmpfs", path.join(workspaceRoot, ".git")); + } + arguments_.push( + "--bind", rawDirectory, rawDirectory, + "--chdir", workspaceRoot, + "/bin/sh", "-eu", "-c", command, + ); + await new Promise((resolve, reject) => { + const child = spawn("/usr/bin/bwrap", arguments_, { + env: { ...environment, PATH: `/tmp:${environment.PATH ?? ""}` }, + stdio: "inherit", + }); + let settled = false; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + error ? reject(error) : resolve(); + }; + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + finish(new Error("sandboxed external provider command timed out")); + }, 30 * 60 * 1_000); + child.once("error", (error) => finish(error)); + child.once("close", (code, signal) => { + if (code === 0 && signal === null) finish(); + else finish(new Error(`sandboxed external provider failed: exit=${code ?? "none"}, signal=${signal ?? "none"}`)); + }); + }); + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} + +function missingDestinationAncestors(target: string): string[] { + const ancestors: string[] = []; + let current = path.dirname(path.resolve(target)); + while (current !== path.parse(current).root && !["/usr", "/bin", "/lib", "/lib64", "/tmp"].includes(current)) { + ancestors.push(current); + current = path.dirname(current); + } + return ancestors.reverse(); +} + +async function exists(target: string): Promise { + try { + await lstat(target); + return true; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false; + throw error; + } +} + +async function prepareMissingProviderOutput( + root: string, + absolutePath: string, + configuredPath: string, + label: string, +): Promise { + await ensureSafePublishDirectory(root, path.dirname(absolutePath)); + await assertSafePublishLeaf(absolutePath, configuredPath); + try { + await lstat(absolutePath); + throw new Error(`${label} already exists: ${configuredPath}`); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/run-ci-gate.ts b/scripts/run-ci-gate.ts index 2bfe8b0..dea544a 100644 --- a/scripts/run-ci-gate.ts +++ b/scripts/run-ci-gate.ts @@ -1,6 +1,4 @@ import { spawnSync } from "node:child_process"; -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; import { ciCheckoutIdentityFailures, @@ -9,48 +7,45 @@ import { isValidSourceDateEpoch, } from "./lib/build-environment.ts"; import { classifyGateStepResult } from "./lib/ci-step-result.ts"; - -type GateStepBase = Readonly<{ - script: string; - args?: readonly string[]; - timeoutMs?: number; -}>; -type GateStep = - | (GateStepBase & Readonly<{ expect: "pass" }>) - | (GateStepBase & - Readonly<{ - expect: "fail"; - expectedExitCode: number; - expectedDiagnosticId: string; - }>); -type GateDefinition = Readonly<{ - name: string; - steps: readonly GateStep[]; - logPath: string; - evidence: readonly string[]; - retentionClass: string; - requiresEnvironment?: readonly string[]; -}>; -type GateDocument = Readonly<{ - gates: Readonly>; -}>; +import { + indexCiGateContract, + loadCiGateContract, +} from "./contracts/ci-gates.ts"; +import { validateCiArtifact } from "./lib/ci-artifact-validator.ts"; +import { writeCiGateLogAtomic } from "./lib/ci-gate-log.ts"; const gateId = process.argv .slice(2) .find((argument) => /^FE-GATE-\d{3}$/.test(argument)); -const document = parseGateDocument( - JSON.parse(await readFile("config/ci/gates.json", "utf8")), -); -const gate = gateId ? document.gates[gateId] : undefined; +const contract = await loadCiGateContract(process.cwd()); +const contractIndex = indexCiGateContract(contract); +const gate = gateId ? contractIndex.gates.get(gateId) : undefined; if (!gateId || !gate) { process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n"); process.exit(2); } +const logArtifact = contractIndex.artifacts.get(gate.logArtifactId); +if (!logArtifact) throw new TypeError(`CI gate log artifact disappeared: ${gate.logArtifactId}`); +const logSchema = contractIndex.artifactSchemas.get(logArtifact.schemaId); +if (!logSchema || logSchema.kind !== "text") { + throw new TypeError(`CI gate log schema must be bounded text: ${logArtifact.schemaId}`); +} const output: string[] = []; +let outputBytes = 0; let passed = true; const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000; const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024; +const LOG_DIAGNOSTIC_RESERVE_BYTES = 4_096; +const appendOutput = (...values: readonly string[]): boolean => { + for (const value of values.filter(Boolean)) { + const addedBytes = Buffer.byteLength(value, "utf8") + 1; + if (outputBytes + addedBytes > logSchema.maxBytes) return false; + output.push(value); + outputBytes += addedBytes; + } + return true; +}; const gateEnvironment = { ...process.env }; if (gateEnvironment.CI === "true") { @@ -68,17 +63,17 @@ if (gateEnvironment.CI === "true") { ) { if (!gateEnvironment.SOURCE_DATE_EPOCH?.trim()) { gateEnvironment.SOURCE_DATE_EPOCH = sourceDateEpoch; - output.push(`derived SOURCE_DATE_EPOCH=${sourceDateEpoch} from HEAD`); + appendOutput(`derived SOURCE_DATE_EPOCH=${sourceDateEpoch} from HEAD`); } for (const failure of ciCheckoutIdentityFailures(gateEnvironment, { commitSha, sourceDateEpoch, })) { - output.push(failure); + appendOutput(failure); passed = false; } } else { - output.push( + appendOutput( "unable to resolve the checked-out commit identity and timestamp", commitMetadata.stderr, ); @@ -87,19 +82,27 @@ if (gateEnvironment.CI === "true") { } for (const failure of ciBuildEnvironmentFailures(gateEnvironment)) { - output.push(failure); + appendOutput(failure); passed = false; } for (const variable of gate.requiresEnvironment ?? []) { if (!gateEnvironment[variable]) { - output.push(`missing required environment: ${variable}`); + appendOutput(`missing required environment: ${variable}`); passed = false; } } if (passed) { - for (const step of gate.steps) { + for (const commandId of gate.commandIds) { + const step = contractIndex.commands.get(commandId); + if (!step) throw new TypeError(`CI gate command disappeared after validation: ${commandId}`); + const commandLine = `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(); + if (!appendOutput(commandLine) || logSchema.maxBytes - outputBytes <= LOG_DIAGNOSTIC_RESERVE_BYTES) { + appendOutput("gate aggregate output budget exhausted before command execution"); + passed = false; + break; + } const result = spawnSync( "corepack", ["pnpm", step.script, ...(step.args ?? [])], @@ -107,24 +110,30 @@ if (passed) { encoding: "utf8", env: gateEnvironment, timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS, - maxBuffer: MAX_STEP_OUTPUT_BYTES, + maxBuffer: Math.min( + MAX_STEP_OUTPUT_BYTES, + logSchema.maxBytes - outputBytes - LOG_DIAGNOSTIC_RESERVE_BYTES, + ), }, ); const stdout = result.stdout ?? ""; const stderr = result.stderr ?? ""; - output.push( - `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(), - stdout, - stderr, - ); - const expectation = - step.expect === "pass" - ? ({ kind: "pass" } as const) - : ({ + if (!appendOutput(stdout, stderr)) { + appendOutput("gate aggregate output exceeded the bounded log schema"); + passed = false; + break; + } + const expectation = step.expect === "pass" + ? ({ kind: "pass" } as const) + : step.expectedExitCode !== undefined && step.expectedDiagnosticId !== undefined + ? ({ kind: "fail", expectedExitCode: step.expectedExitCode, expectedDiagnosticId: step.expectedDiagnosticId, - } as const); + } as const) + : (() => { + throw new TypeError(`negative command lost its validated identity: ${step.id}`); + })(); const classification = classifyGateStepResult(expectation, { status: result.status, signal: result.signal, @@ -134,9 +143,9 @@ if (passed) { ? { error: { code: (result.error as NodeJS.ErrnoException).code } } : {}), }); - output.push(`classification: ${classification.kind}`); + if (!appendOutput(`classification: ${classification.kind}`)) passed = false; if (!classification.expectationMet) { - output.push( + appendOutput( `expectation failed: expected ${step.expect}, exit=${result.status}, signal=${result.signal ?? "none"}`, ...(step.expect === "fail" ? [ @@ -153,20 +162,37 @@ if (passed) { } } -await mkdir(path.dirname(gate.logPath), { recursive: true }); -await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); +await writeCiGateLogAtomic({ + root: process.cwd(), + relativePath: logArtifact.path, + content: `${output.filter(Boolean).join("\n")}\n`, + maxBytes: logSchema.maxBytes, +}); if (passed) { - for (const evidencePath of gate.evidence) { + const validationIds = [...new Set([gate.logArtifactId, ...gate.evidenceArtifactIds])]; + for (const artifactId of validationIds) { + const artifact = contractIndex.artifacts.get(artifactId); + if (!artifact) throw new TypeError(`CI artifact disappeared: ${artifactId}`); + const schema = contractIndex.artifactSchemas.get(artifact.schemaId); + if (!schema) throw new TypeError(`CI artifact schema disappeared: ${artifact.schemaId}`); try { - await access(evidencePath); - } catch { - output.push(`missing evidence: ${evidencePath}`); + await validateCiArtifact({ root: process.cwd(), artifact, schema }); + if (!appendOutput(`validated evidence: ${artifact.path} (${schema.id})`)) passed = false; + } catch (error) { + appendOutput( + `invalid evidence: ${artifact.path}: ${error instanceof Error ? error.message : String(error)}`, + ); passed = false; } } if (!passed) { - await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); + await writeCiGateLogAtomic({ + root: process.cwd(), + relativePath: logArtifact.path, + content: `${output.filter(Boolean).join("\n")}\n`, + maxBytes: logSchema.maxBytes, + }); } } @@ -175,125 +201,5 @@ if (!passed) { process.exit(1); } process.stdout.write( - `${gateId} ${gate.name}: PASS (${gate.retentionClass})\n`, + `${gateId} ${gate.name}: PASS (${gate.retentionClassId})\n`, ); - -function parseGateDocument(value: unknown): GateDocument { - if (!isRecord(value) || !isRecord(value.gates)) { - throw new TypeError("CI gate registry must be an object"); - } - const gates: Record = {}; - for (const [gateId, candidate] of Object.entries(value.gates)) { - if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`); - const steps = parseGateSteps(candidate.steps, gateId); - const evidence = parseStringArray(candidate.evidence, `${gateId}.evidence`); - const requiresEnvironment = - candidate.requiresEnvironment === undefined - ? undefined - : parseStringArray( - candidate.requiresEnvironment, - `${gateId}.requiresEnvironment`, - ); - if ( - typeof candidate.name !== "string" || - typeof candidate.logPath !== "string" || - typeof candidate.retentionClass !== "string" - ) { - throw new TypeError(`CI gate metadata is invalid: ${gateId}`); - } - gates[gateId] = { - name: candidate.name, - steps, - logPath: candidate.logPath, - evidence, - retentionClass: candidate.retentionClass, - ...(requiresEnvironment ? { requiresEnvironment } : {}), - }; - } - return { gates }; -} - -function parseGateSteps(value: unknown, gateId: string): GateStep[] { - if (!Array.isArray(value)) { - throw new TypeError(`CI gate steps are invalid: ${gateId}`); - } - return value.map((candidate, index) => { - if ( - !isRecord(candidate) || - typeof candidate.script !== "string" || - (candidate.expect !== "pass" && candidate.expect !== "fail") - ) { - throw new TypeError(`Invalid CI gate step: ${gateId}[${index}]`); - } - const args = - candidate.args === undefined - ? undefined - : parseStringArray(candidate.args, `${gateId}[${index}].args`); - const timeoutMs = candidate.timeoutMs; - if ( - timeoutMs !== undefined && - (typeof timeoutMs !== "number" || - !Number.isSafeInteger(timeoutMs) || - timeoutMs < 1_000 || - timeoutMs > 3_600_000) - ) { - throw new TypeError(`Invalid CI gate step timeout: ${gateId}[${index}]`); - } - const base = { - script: candidate.script, - ...(args ? { args } : {}), - ...(typeof timeoutMs === "number" ? { timeoutMs } : {}), - }; - if (candidate.expect === "pass") { - if ( - candidate.expectedExitCode !== undefined || - candidate.expectedDiagnosticId !== undefined - ) { - throw new TypeError( - `Passing CI gate step cannot declare failure identity: ${gateId}[${index}]`, - ); - } - return { ...base, expect: "pass" as const }; - } - if ( - typeof candidate.expectedExitCode !== "number" || - !Number.isSafeInteger(candidate.expectedExitCode) || - candidate.expectedExitCode < 1 || - candidate.expectedExitCode > 255 - ) { - throw new TypeError( - `Invalid expected failure exit code: ${gateId}[${index}]`, - ); - } - const expectedDiagnosticId = candidate.expectedDiagnosticId; - if ( - typeof expectedDiagnosticId !== "string" || - expectedDiagnosticId.trim().length === 0 || - expectedDiagnosticId.length > 256 || - ["\r", "\n", "\0"].some((character) => - expectedDiagnosticId.includes(character), - ) - ) { - throw new TypeError( - `Invalid expected failure diagnostic: ${gateId}[${index}]`, - ); - } - return { - ...base, - expect: "fail" as const, - expectedExitCode: candidate.expectedExitCode, - expectedDiagnosticId, - }; - }); -} - -function parseStringArray(value: unknown, label: string): string[] { - if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { - throw new TypeError(`${label} must be a string array`); - } - return value; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} diff --git a/scripts/stage-verified-promotion.ts b/scripts/stage-verified-promotion.ts new file mode 100644 index 0000000..3660cb4 --- /dev/null +++ b/scripts/stage-verified-promotion.ts @@ -0,0 +1,22 @@ +import { stageVerifiedPromotion } from "./lib/promotion-stager.ts"; + +const required = (name: string): string => { + const value = process.env[name]; + if (!value) throw new TypeError(`promotion staging environment is missing ${name}`); + return value; +}; + +const staged = await stageVerifiedPromotion({ + repositoryRoot: process.cwd(), + archivePath: required("CANDIDATE_ARCHIVE_PATH"), + expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"), + vulnerabilityReportPath: required("VULNERABILITY_REPORT_PATH"), + provenanceAttestationPath: required("PROVENANCE_ATTESTATION_PATH"), + vulnerabilityPublicKeyPath: required("VULNERABILITY_PUBLIC_KEY_PATH"), + vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"), + provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"), + provenanceKeyId: required("PROVENANCE_KEY_ID"), +}); +process.stdout.write( + `Promotion staging: ${staged.map(({ path, sha256 }) => `${path}=${sha256}`).join(", ")} PASS\n`, +); diff --git a/scripts/test-browser-file-storage-runtime-removal.ts b/scripts/test-browser-file-storage-runtime-removal.ts index 7dc9f3b..68d5f1a 100644 --- a/scripts/test-browser-file-storage-runtime-removal.ts +++ b/scripts/test-browser-file-storage-runtime-removal.ts @@ -10,6 +10,9 @@ import { } from "node:fs/promises"; import path from "node:path"; +import { parseCiGateContract } from "./contracts/ci-gates.ts"; +import { generateCiWorkflow } from "./generate-ci-workflow.ts"; + const fixtureRoot = path.resolve( ".tmp/browser-file-storage-runtime-removal", ); @@ -29,6 +32,16 @@ const runtimePaths = [ const runtimeSourceRoots = runtimePaths.filter((entry) => entry.startsWith("src/"), ); +const removedScripts = new Set([ + "test:browser-capabilities", + "verify:browser-capability-evidence", + "check:browser-file-storage-boundaries", + "test:browser-file-storage-removal", +]); +const removedEvidencePathFragments = [ + "browser-capabilities", + "browser-file-storage-runtime-removal", +] as const; const copyTargets = [ "src", "tests", @@ -281,12 +294,7 @@ const packagePath = path.join(fixtureRoot, "package.json"); const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as { scripts: Record; }; -for (const script of [ - "test:browser-capabilities", - "verify:browser-capability-evidence", - "check:browser-file-storage-boundaries", - "test:browser-file-storage-removal", -]) { +for (const script of removedScripts) { delete packageDocument.scripts[script]; } await writeFile( @@ -308,39 +316,80 @@ await rm( path.join(fixtureRoot, "scripts/test-browser-file-storage-runtime-removal.ts"), { force: true }, ); +// The root snapshot locks the full repository inventory. This removal fixture +// validates its smaller registry through check:ci and its regenerated workflow. +await rm( + path.join(fixtureRoot, "tests/unit/ci-workflow-generation.test.ts"), + { force: true }, +); +await rm( + path.join( + fixtureRoot, + "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", + ), + { force: true }, +); const gatesPath = path.join(fixtureRoot, "config/ci/gates.json"); -const gatesDocument = JSON.parse( - await readFile(gatesPath, "utf8"), -) as { - gates: Record< - string, - { - steps: Array<{ script: string }>; - evidence: string[]; - } - >; -}; -for (const gate of Object.values(gatesDocument.gates)) { - gate.steps = gate.steps.filter( - ({ script }) => - ![ - "test:browser-capabilities", - "verify:browser-capability-evidence", - "check:browser-file-storage-boundaries", - "test:browser-file-storage-removal", - ].includes(script), +const gatesDocument = structuredClone( + parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))), +); +const removedCommandIds = new Set( + gatesDocument.commands + .filter(({ script }) => removedScripts.has(script)) + .map(({ id }) => id), +); +if (removedCommandIds.size !== removedScripts.size) { + throw new Error("Browser file/storage CI command removal set is incomplete"); +} +const removedArtifactIds = new Set( + gatesDocument.artifacts + .filter(({ path: artifactPath }) => + removedEvidencePathFragments.some((fragment) => + artifactPath.includes(fragment), + ), + ) + .map(({ id }) => id), +); +for (const fragment of removedEvidencePathFragments) { + if ( + !gatesDocument.artifacts.some(({ path: artifactPath }) => + artifactPath.includes(fragment), + ) + ) { + throw new Error(`Browser file/storage CI evidence is missing: ${fragment}`); + } +} +gatesDocument.commands = gatesDocument.commands.filter( + ({ id }) => !removedCommandIds.has(id), +); +gatesDocument.artifacts = gatesDocument.artifacts.filter( + ({ id }) => !removedArtifactIds.has(id), +); +for (const gate of gatesDocument.gates) { + gate.commandIds = gate.commandIds.filter( + (commandId) => !removedCommandIds.has(commandId), ); - gate.evidence = gate.evidence.filter( - (evidence) => - !evidence.includes("browser-capabilities") && - !evidence.includes("browser-file-storage-runtime-removal"), + gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter( + (artifactId) => !removedArtifactIds.has(artifactId), ); } +const referencedSchemaIds = new Set( + gatesDocument.artifacts.map(({ schemaId }) => schemaId), +); +gatesDocument.artifactSchemas = gatesDocument.artifactSchemas.filter( + ({ id }) => referencedSchemaIds.has(id), +); +const validatedGates = parseCiGateContract(gatesDocument); await writeFile( gatesPath, - `${JSON.stringify(gatesDocument, null, 2)}\n`, + `${JSON.stringify(validatedGates, null, 2)}\n`, ); +await generateCiWorkflow({ + root: fixtureRoot, + contract: validatedGates, + check: false, +}); await assertNoRuntimeImports(fixtureRoot); const checks: Array = [ diff --git a/scripts/test-optional-recipe-removal.ts b/scripts/test-optional-recipe-removal.ts index dce00c1..3fa1cee 100644 --- a/scripts/test-optional-recipe-removal.ts +++ b/scripts/test-optional-recipe-removal.ts @@ -17,8 +17,10 @@ const copyTargets = [ "tests", "recipes", "scripts", + "schemas", "config", "public", + ".gitea", ".storybook", "index.html", "package.json", @@ -39,6 +41,7 @@ const copyTargets = [ "playwright.visual.config.ts", "eslint.config.ts", ".dependency-cruiser.json", + ".nvmrc", ]; function requireEnvironment(name: string): string { diff --git a/scripts/test-realtime-runtime-removal.ts b/scripts/test-realtime-runtime-removal.ts index cf87c80..3d18b02 100644 --- a/scripts/test-realtime-runtime-removal.ts +++ b/scripts/test-realtime-runtime-removal.ts @@ -10,6 +10,9 @@ import { } from "node:fs/promises"; import path from "node:path"; +import { parseCiGateContract } from "./contracts/ci-gates.ts"; +import { generateCiWorkflow } from "./generate-ci-workflow.ts"; + const fixtureRoot = path.resolve(".tmp/realtime-runtime-removal"); const pnpmCli = requireEnvironment("npm_execpath"); const runtimePaths = [ @@ -31,6 +34,10 @@ const runtimeScripts = [ "check:realtime-boundaries:fixture", "test:realtime-removal", ] as const; +const removedEvidencePathFragments = [ + "realtime-boundaries", + "realtime-runtime-removal", +] as const; const copyTargets = [ "src", "tests", @@ -292,32 +299,81 @@ for (const scriptPath of [ ]) { await rm(path.join(fixtureRoot, scriptPath), { force: true }); } +// The root snapshot locks the full repository inventory. This removal fixture +// validates its smaller registry through check:ci and its regenerated workflow. +await rm( + path.join(fixtureRoot, "tests/unit/ci-workflow-generation.test.ts"), + { force: true }, +); +await rm( + path.join( + fixtureRoot, + "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", + ), + { force: true }, +); const gatesPath = path.join(fixtureRoot, "config/ci/gates.json"); -const gatesDocument = JSON.parse(await readFile(gatesPath, "utf8")) as { - gates: Record< - string, - { - steps: Array<{ script: string }>; - evidence: string[]; - } - >; -}; -for (const gate of Object.values(gatesDocument.gates)) { - gate.steps = gate.steps.filter( - ({ script }) => - !runtimeScripts.some((runtimeScript) => runtimeScript === script), +const gatesDocument = structuredClone( + parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))), +); +const removedScripts = new Set(runtimeScripts); +const removedCommandIds = new Set( + gatesDocument.commands + .filter(({ script }) => removedScripts.has(script)) + .map(({ id }) => id), +); +if (removedCommandIds.size !== runtimeScripts.length) { + throw new Error("Realtime CI command removal set is incomplete"); +} +const removedArtifactIds = new Set( + gatesDocument.artifacts + .filter(({ path: artifactPath }) => + removedEvidencePathFragments.some((fragment) => + artifactPath.includes(fragment), + ), + ) + .map(({ id }) => id), +); +for (const fragment of removedEvidencePathFragments) { + if ( + !gatesDocument.artifacts.some(({ path: artifactPath }) => + artifactPath.includes(fragment), + ) + ) { + throw new Error(`Realtime CI evidence is missing: ${fragment}`); + } +} +gatesDocument.commands = gatesDocument.commands.filter( + ({ id }) => !removedCommandIds.has(id), +); +gatesDocument.artifacts = gatesDocument.artifacts.filter( + ({ id }) => !removedArtifactIds.has(id), +); +for (const gate of gatesDocument.gates) { + gate.commandIds = gate.commandIds.filter( + (commandId) => !removedCommandIds.has(commandId), ); - gate.evidence = gate.evidence.filter( - (evidence) => - !evidence.includes("realtime-boundaries") && - !evidence.includes("realtime-runtime-removal"), + gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter( + (artifactId) => !removedArtifactIds.has(artifactId), ); } +const referencedSchemaIds = new Set( + gatesDocument.artifacts.map(({ schemaId }) => schemaId), +); +gatesDocument.artifactSchemas = gatesDocument.artifactSchemas.filter( + ({ id }) => referencedSchemaIds.has(id), +); +const validatedGates = parseCiGateContract(gatesDocument); await writeFile( gatesPath, - `${JSON.stringify(gatesDocument, null, 2)}\n`, + `${JSON.stringify(validatedGates, null, 2)}\n`, ); +await generateCiWorkflow({ + root: fixtureRoot, + contract: validatedGates, + check: false, +}); await assertNoRuntimeImports(fixtureRoot); const checks: Array = [ diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts index 3e5d0d9..15e1462 100644 --- a/scripts/test-sample-removal.ts +++ b/scripts/test-sample-removal.ts @@ -45,6 +45,7 @@ const copyTargets = [ "schemas", "config", "public", + ".gitea", ".storybook", "index.html", "package.json", @@ -65,6 +66,7 @@ const copyTargets = [ "playwright.visual.config.ts", "eslint.config.ts", ".dependency-cruiser.json", + ".nvmrc", ]; const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts"; diff --git a/scripts/verify-ci-candidate-archive.ts b/scripts/verify-ci-candidate-archive.ts new file mode 100644 index 0000000..3743acc --- /dev/null +++ b/scripts/verify-ci-candidate-archive.ts @@ -0,0 +1,37 @@ +import { appendFile } from "node:fs/promises"; + +import { verifyCiCandidateArchive } from "./lib/ci-candidate-archive.ts"; +import { + CANDIDATE_ARCHIVE_USAGE, + parseCandidateArchiveArguments, +} from "./lib/ci-candidate-archive-cli.ts"; + +const parsedArguments = parseCandidateArchiveArguments(process.argv.slice(2)); +if (!parsedArguments) { + process.stderr.write(CANDIDATE_ARCHIVE_USAGE); + process.exitCode = 2; +} else try { + const result = await verifyCiCandidateArchive({ + archivePath: parsedArguments.archivePath, + repositoryRoot: process.cwd(), + ...(process.env.CANDIDATE_ARCHIVE_SHA256 + ? { expectedSha256: process.env.CANDIDATE_ARCHIVE_SHA256 } + : {}), + ...(parsedArguments.extractTo ? { extractTo: parsedArguments.extractTo } : {}), + }); + if (parsedArguments.githubOutput) { + await appendFile( + parsedArguments.githubOutput, + `archive_sha256=${result.archiveSha256}\ndist_sha256=${result.manifest.distSha256}\n`, + "utf8", + ); + } + process.stdout.write( + `Candidate archive: ${result.memberCount} members, sha256=${result.archiveSha256} PASS\n`, + ); +} catch (error) { + process.stderr.write( + `Candidate archive failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +} diff --git a/scripts/verify-provider-evidence.ts b/scripts/verify-provider-evidence.ts index 38dd560..118a96e 100644 --- a/scripts/verify-provider-evidence.ts +++ b/scripts/verify-provider-evidence.ts @@ -1,10 +1,16 @@ import { mkdir } from "node:fs/promises"; +import path from "node:path"; import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts"; import { verifyPromotionInputs } from "./lib/promotion-verifier.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; -const report = await verifyPromotionInputs(); +const report = await verifyPromotionInputs({ + artifactType: "provider-verification", + repositoryRoot: path.resolve(process.env.CANDIDATE_ROOT ?? process.cwd()), + providerEvidenceRoot: process.cwd(), + trustRoot: process.cwd(), +}); await mkdir("artifacts/security", { recursive: true }); await writeValidatedJsonArtifact({ path: "artifacts/security/provider-verification.json", diff --git a/scripts/verify-supply-chain-promotion.ts b/scripts/verify-supply-chain-promotion.ts index 6cb6a26..fe0d740 100644 --- a/scripts/verify-supply-chain-promotion.ts +++ b/scripts/verify-supply-chain-promotion.ts @@ -1,10 +1,16 @@ import { mkdir } from "node:fs/promises"; +import path from "node:path"; import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts"; import { verifyPromotionInputs } from "./lib/promotion-verifier.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; -const report = await verifyPromotionInputs(); +const report = await verifyPromotionInputs({ + artifactType: "promotion-verification", + repositoryRoot: path.resolve(process.env.CANDIDATE_ROOT ?? process.cwd()), + providerEvidenceRoot: process.cwd(), + trustRoot: process.cwd(), +}); await mkdir("artifacts/security", { recursive: true }); await writeValidatedJsonArtifact({ path: "artifacts/security/promotion-verification.json", diff --git a/tests/fixtures/ci-contract/duplicate-gate-id.json b/tests/fixtures/ci-contract/duplicate-gate-id.json new file mode 100644 index 0000000..8cabfc9 --- /dev/null +++ b/tests/fixtures/ci-contract/duplicate-gate-id.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 2, + "providerAdapter": ".gitea/workflows/quality-gates.yml", + "commands": [], + "artifactSchemas": [{ "id": "text", "kind": "text", "maxBytes": 1024 }], + "artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "text" }], + "gates": [ + { "id": "FE-GATE-001", "name": "one", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" }, + { "id": "FE-GATE-001", "name": "duplicate", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" } + ], + "stages": [], + "jobs": [], + "retention": { "durationStatus": "UNSUPPORTED", "classes": [{ "id": "merge", "policy": "one cycle" }] } +} diff --git a/tests/fixtures/ci-contract/job-cycle.json b/tests/fixtures/ci-contract/job-cycle.json new file mode 100644 index 0000000..df87295 --- /dev/null +++ b/tests/fixtures/ci-contract/job-cycle.json @@ -0,0 +1,4 @@ +{ + "jobId": "merge_gate", + "needs": ["release_gate"] +} diff --git a/tests/fixtures/ci-contract/missing-artifact-schema.json b/tests/fixtures/ci-contract/missing-artifact-schema.json new file mode 100644 index 0000000..a76e6b5 --- /dev/null +++ b/tests/fixtures/ci-contract/missing-artifact-schema.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 2, + "providerAdapter": ".gitea/workflows/quality-gates.yml", + "commands": [], + "artifactSchemas": [], + "artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "missing" }], + "gates": [], + "stages": [], + "jobs": [], + "retention": { "durationStatus": "UNSUPPORTED", "classes": [] } +} diff --git a/tests/fixtures/ci-contract/multiply-owned-gate.json b/tests/fixtures/ci-contract/multiply-owned-gate.json new file mode 100644 index 0000000..10e3426 --- /dev/null +++ b/tests/fixtures/ci-contract/multiply-owned-gate.json @@ -0,0 +1,4 @@ +{ + "jobId": "release_gate", + "addGateId": "FE-GATE-001" +} diff --git a/tests/fixtures/ci-contract/unknown-field.json b/tests/fixtures/ci-contract/unknown-field.json new file mode 100644 index 0000000..3a3793e --- /dev/null +++ b/tests/fixtures/ci-contract/unknown-field.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 2, + "providerAdapter": ".gitea/workflows/quality-gates.yml", + "commands": [], + "artifactSchemas": [], + "artifacts": [], + "gates": [], + "stages": [], + "jobs": [], + "retention": { "durationStatus": "UNSUPPORTED", "classes": [] }, + "unexpected": true +} diff --git a/tests/fixtures/ci-contract/unknown-job-dependency.json b/tests/fixtures/ci-contract/unknown-job-dependency.json new file mode 100644 index 0000000..8a23f7a --- /dev/null +++ b/tests/fixtures/ci-contract/unknown-job-dependency.json @@ -0,0 +1,4 @@ +{ + "jobId": "merge_gate", + "needs": ["missing"] +} diff --git a/tests/fixtures/ci-contract/unowned-gate.json b/tests/fixtures/ci-contract/unowned-gate.json new file mode 100644 index 0000000..9803976 --- /dev/null +++ b/tests/fixtures/ci-contract/unowned-gate.json @@ -0,0 +1,4 @@ +{ + "jobId": "merge_gate", + "removeGateId": "FE-GATE-001" +} diff --git a/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap b/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap new file mode 100644 index 0000000..695700a --- /dev/null +++ b/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap @@ -0,0 +1,409 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CI workflow generation > renders the complete workflow deterministically with one final LF 1`] = ` +"# GENERATED FILE — edit config/ci/gates.json and run \`corepack pnpm generate:ci-workflow\`. +name: frontend-quality-gates + +on: + push: + branches: [develop] + tags: ["v*"] + pull_request: + workflow_dispatch: + inputs: + stage: + description: Highest promotion tier to evaluate + required: true + default: merge + type: choice + options: + - merge + - release + - production + - field + - documentation + +permissions: + contents: read + +env: + CI: "true" + VITE_BUILD_ID: "gitea-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + VITE_COMMIT_SHA: "\${{ gitea.sha }}" + RELEASE_ID: "\${{ gitea.ref }}-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + CI_RUNNER_IMAGE: "\${{ vars.RUNNER_IMAGE_DIGEST }}" + +jobs: + merge_gate: + name: "\${{ matrix.gate }} / \${{ matrix.name }}" + if: \${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - { gate: FE-GATE-001, name: manifest-lockfile, browser: false } + - { gate: FE-GATE-002, name: lint, browser: false } + - { gate: FE-GATE-003, name: typecheck, browser: false } + - { gate: FE-GATE-004, name: runtime-schema, browser: false } + - { gate: FE-GATE-005, name: unit, browser: false } + - { gate: FE-GATE-006, name: component, browser: false } + - { gate: FE-GATE-007, name: integration, browser: false } + - { gate: FE-GATE-008, name: e2e, browser: true } + - { gate: FE-GATE-009, name: accessibility, browser: true } + - { gate: FE-GATE-010, name: architecture, browser: false } + - { gate: FE-GATE-011, name: build, browser: false } + - { gate: FE-GATE-013, name: security, browser: false } + - { gate: FE-GATE-020, name: removability, browser: false } + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Install Playwright browsers + if: \${{ matrix.browser }} + run: corepack pnpm exec playwright install --with-deps chromium firefox webkit + - name: Run blocking gate + run: corepack pnpm ci:gate -- \${{ matrix.gate }} + - name: Upload merge gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "\${{ matrix.gate }}-\${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error + + release_gate: + name: "\${{ matrix.gate }} / \${{ matrix.name }}" + needs: merge_gate + if: \${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }} + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + HOSTING_BASE_URL: "\${{ vars.HOSTING_BASE_URL }}" + strategy: + fail-fast: false + matrix: + include: + - { gate: FE-GATE-012, name: bundle, browser: false } + - { gate: FE-GATE-014, name: config-compatibility, browser: false } + - { gate: FE-GATE-019, name: hosting-header, browser: false } + - { gate: FE-GATE-026, name: lab-performance, browser: true } + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Install Playwright browsers + if: \${{ matrix.browser }} + run: corepack pnpm exec playwright install --with-deps chromium firefox webkit + - name: Run blocking gate + run: corepack pnpm ci:gate -- \${{ matrix.gate }} + - name: Upload release gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "\${{ matrix.gate }}-\${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error + + immutable_build: + name: "FE-GATE-015 / immutable-release-candidate" + needs: release_gate + if: \${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }} + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + dist_sha256: \${{ steps.candidate.outputs.dist_sha256 }} + archive_sha256: \${{ steps.candidate.outputs.archive_sha256 }} + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Build candidate once and verify local evidence + run: corepack pnpm ci:gate -- FE-GATE-015 + - name: Archive and validate the exact candidate file set + id: candidate + run: | + mkdir -p .release + tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ".release/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" \\ + dist \\ + pnpm-lock.yaml \\ + artifacts/performance/bundle.json \\ + artifacts/quality/vite-module-inventory.json \\ + artifacts/release/build-manifest.json \\ + artifacts/release/checksums.txt \\ + artifacts/release/dependency-inventory.json \\ + artifacts/release/provenance.json \\ + artifacts/release/verification.json \\ + artifacts/release/sbom.cdx.json \\ + artifacts/security/dependency-diff.json \\ + artifacts/security/license-report.json \\ + artifacts/security/scan.sarif \\ + artifacts/security/supply-chain-coherence.json \\ + artifacts/security/supply-chain-verification.json \\ + artifacts/security/vulnerability-report.json \\ + artifacts/release/release-candidate.json + node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT" + - name: Upload release candidate + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + path: ".release/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" + if-no-files-found: error + + vulnerability_provider: + name: external-vulnerability-provider + needs: immutable_build + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}" + CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" + CANDIDATE_DIST_SHA256: "\${{ needs.immutable_build.outputs.dist_sha256 }}" + CANDIDATE_LOCKFILE_PATH: .release/verified-vulnerability/pnpm-lock.yaml + VULNERABILITY_PROVIDER_COMMAND: "\${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" + VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json + VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Download release candidate + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + path: .release/vulnerability-candidate + - name: Verify and extract the candidate through one inode-bound operation + run: node scripts/verify-ci-candidate-archive.ts --archive ".release/vulnerability-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-vulnerability" + - name: Run and validate external vulnerability provider in one trusted supervisor + run: node scripts/run-and-validate-provider.ts --kind vulnerability + - name: Confirm sealed vulnerability provider evidence + run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" + - name: Upload vulnerability provider evidence + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "vulnerability-provider-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + path: provider-evidence/vulnerability-report.json + if-no-files-found: error + + provenance_provider: + name: external-provenance-provider + needs: immutable_build + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}" + CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" + CANDIDATE_DIST_SHA256: "\${{ needs.immutable_build.outputs.dist_sha256 }}" + CANDIDATE_LOCKFILE_PATH: .release/verified-provenance/pnpm-lock.yaml + PROVENANCE_PROVIDER_COMMAND: "\${{ vars.PROVENANCE_PROVIDER_COMMAND }}" + PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json + VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Download release candidate + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + path: .release/provenance-candidate + - name: Verify and extract the candidate through one inode-bound operation + run: node scripts/verify-ci-candidate-archive.ts --archive ".release/provenance-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-provenance" + - name: Run and validate external provenance provider in one trusted supervisor + run: node scripts/run-and-validate-provider.ts --kind provenance + - name: Confirm sealed provenance provider evidence + run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" + - name: Upload provenance provider evidence + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "provenance-provider-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + path: provider-evidence/provenance-attestation.json + if-no-files-found: error + + promotion: + name: promote-verified-immutable-candidate + needs: [immutable_build, vulnerability_provider, provenance_provider] + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}" + CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" + CANDIDATE_ROOT: "\${{ gitea.workspace }}/.release/verified-candidate" + VULNERABILITY_REPORT_PATH: "\${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" + PROVENANCE_ATTESTATION_PATH: "\${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" + VULNERABILITY_PUBLIC_KEY_PATH: "\${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" + VULNERABILITY_KEY_ID: "\${{ vars.VULNERABILITY_KEY_ID }}" + PROVENANCE_PUBLIC_KEY_PATH: "\${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" + PROVENANCE_KEY_ID: "\${{ vars.PROVENANCE_KEY_ID }}" + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Download release candidate + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + path: .release/candidate + - name: Download vulnerability provider evidence + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "vulnerability-provider-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + path: .release/vulnerability + - name: Download provenance provider evidence + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "provenance-provider-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + path: .release/provenance + - name: Verify and extract the candidate through one inode-bound operation + run: node scripts/verify-ci-candidate-archive.ts --archive ".release/candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-candidate" + - name: Finalize verified promotion from inode-bound captured inputs + run: node scripts/stage-verified-promotion.ts + - name: Upload promoted release + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "promoted-release-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" + path: | + .release/promoted-staging/release-candidate.tar.gz + .release/promoted-staging/vulnerability-report.json + .release/promoted-staging/provenance-attestation.json + .release/promoted-staging/provider-verification.json + .release/promoted-staging/promotion-verification.json + if-no-files-found: error + + production_gate: + name: "\${{ matrix.gate }} / \${{ matrix.name }}" + needs: promotion + if: \${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - { gate: FE-GATE-016, name: rollback-drill } + - { gate: FE-GATE-021, name: runbook-boot-config } + - { gate: FE-GATE-022, name: runbook-chunk-mismatch } + - { gate: FE-GATE-023, name: runbook-api-degradation } + - { gate: FE-GATE-024, name: runbook-telemetry } + - { gate: FE-GATE-025, name: runbook-release-rollback } + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Run blocking gate + run: corepack pnpm ci:gate -- \${{ matrix.gate }} + - name: Upload production gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "\${{ matrix.gate }}-\${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error + + field_gate: + name: "FE-GATE-018 / field-web-vitals" + needs: production_gate + if: \${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + FIELD_WEB_VITALS_INPUT: "\${{ vars.FIELD_WEB_VITALS_INPUT }}" + MIN_ELIGIBLE_SAMPLES: "\${{ vars.MIN_ELIGIBLE_SAMPLES }}" + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Run blocking gate + run: corepack pnpm ci:gate -- FE-GATE-018 + - name: Upload field gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "FE-GATE-018-\${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error + + documentation_gate: + name: "FE-GATE-017 / diagram-review" + if: \${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Run documentation gate + run: corepack pnpm ci:gate -- FE-GATE-017 + - name: Upload documentation gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "FE-GATE-017-\${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error +" +`; diff --git a/tests/unit/ci-artifact-contract.test.ts b/tests/unit/ci-artifact-contract.test.ts new file mode 100644 index 0000000..4c86a7c --- /dev/null +++ b/tests/unit/ci-artifact-contract.test.ts @@ -0,0 +1,1050 @@ +import { spawnSync } from "node:child_process"; +import { createHash, generateKeyPairSync, sign } from "node:crypto"; +import { link, mkdir, mkdtemp, open, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { afterEach, describe, expect, it } from "vitest"; + +import type { + CiGateArtifact, + CiGateArtifactSchema, +} from "../../scripts/contracts/ci-gates.ts"; +import { readBoundedRegularFile, validateCiArtifact } from "../../scripts/lib/ci-artifact-validator.ts"; +import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts"; +import { verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts"; +import { + CANDIDATE_ARCHIVE_USAGE, + parseCandidateArchiveArguments, +} from "../../scripts/lib/ci-candidate-archive-cli.ts"; +import { validateProviderUpload } from "../../scripts/lib/provider-upload-validator.ts"; +import { + PROMOTED_STAGING_PATHS, + stageVerifiedPromotion, +} from "../../scripts/lib/promotion-stager.ts"; +import { + providerEvidenceSignaturePayload, + providerVerificationArtifactSchema, +} from "../../scripts/lib/provider-evidence.ts"; +import { + createReleaseCandidateManifest, + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, +} from "../../scripts/lib/release-candidate.ts"; + +const temporaryRoots: string[] = []; +const sha256 = (value: Buffer | string) => + createHash("sha256").update(value).digest("hex"); + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function temporaryRoot(prefix: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), prefix)); + temporaryRoots.push(root); + return root; +} + +async function writeArtifact(root: string, relative: string, value: string | Buffer) { + await mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await writeFile(path.join(root, relative), value); +} + +function artifact(pathname: string, schemaId: string): CiGateArtifact { + return { id: `artifact-${schemaId}`, path: pathname, schemaId }; +} + +describe("CI artifact validator", () => { + it("does not create directories through a pre-existing log ancestor symlink", async () => { + const root = await temporaryRoot("ci-log-root-"); + const outside = await temporaryRoot("ci-log-outside-"); + await symlink(outside, path.join(root, "linked")); + await expect( + writeCiGateLogAtomic({ + root, + relativePath: "linked/new/report.txt", + content: "blocked\n", + }), + ).rejects.toThrow(/ancestor is unsafe/i); + await expect( + import("node:fs/promises").then(({ lstat }) => lstat(path.join(outside, "new"))), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it.each([ + ["report.txt", { id: "text", kind: "text", maxBytes: 1_024 }, "gate output\n"], + ["report.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, '\n'], + ["report.html", { id: "html", kind: "html", maxBytes: 1_024 }, "\n"], + ["report.md", { id: "markdown", kind: "markdown", maxBytes: 1_024 }, "# Review\n"], + ["schema.json", { id: "json-schema", kind: "json-schema", maxBytes: 1_024 }, '{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}\n'], + ["scan.sarif", { id: "sarif", kind: "sarif", maxBytes: 4_096 }, JSON.stringify({ version: "2.1.0", $schema: "https://json.schemastore.org/sarif-2.1.0.json", runs: [{ tool: { driver: { name: "ca-frontend-secret-scan", rules: [] } }, results: [] }] })], + ] as const)("accepts a valid %s artifact", async (relative, schema, content) => { + const root = await temporaryRoot("ci-artifact-kind-"); + await writeArtifact(root, relative, content); + await expect( + validateCiArtifact({ + root, + artifact: artifact(relative, schema.id), + schema: schema as CiGateArtifactSchema, + }), + ).resolves.toBeUndefined(); + }); + + it("accepts schema-valid negative evidence without treating status as command authority", async () => { + const root = await temporaryRoot("ci-artifact-negative-"); + const relative = "negative.json"; + await writeArtifact( + root, + relative, + `${JSON.stringify({ schemaVersion: 2, sourceRoot: "src", status: "FAIL", facts: { scannedFiles: 0, visualBaselines: 0, sharedScenarios: 0, declaredScenarioExecutions: 0, executedScenarioExecutions: 0 }, failures: ["fixture"] })}\n`, + ); + await expect( + validateCiArtifact({ + root, + artifact: artifact(relative, "test-evidence"), + schema: { + id: "test-evidence", + kind: "json", + maxBytes: 4_096, + executableSchemaId: "test-evidence-report", + }, + }), + ).resolves.toBeUndefined(); + }); + + const invalidFixtures: ReadonlyArray< + readonly [string, (root: string) => Promise, RegExp] + > = [ + ["missing", async (_root: string): Promise => undefined, /not a regular file|ENOENT/i], + ["empty", async (root: string): Promise => { await writeArtifact(root, "report.json", ""); }, /size is outside/i], + ["directory", async (root: string): Promise => { await mkdir(path.join(root, "report.json")); }, /not a regular file/i], + ["oversized", async (root: string): Promise => { await writeArtifact(root, "report.json", "12345"); }, /size is outside/i], + ["invalid UTF-8", async (root: string): Promise => { await writeArtifact(root, "report.json", Buffer.from([0xc3, 0x28])); }, /encoded data was not valid|UTF-8/i], + ["primitive JSON", async (root: string): Promise => { await writeArtifact(root, "report.json", "1\n"); }, /record|object/i], + ["array JSON", async (root: string): Promise => { await writeArtifact(root, "report.json", "[]\n"); }, /record|object/i], + ]; + it.each(invalidFixtures)("rejects %s artifacts", async (_name, setup, diagnostic) => { + const root = await temporaryRoot("ci-artifact-invalid-"); + await setup(root); + await expect( + validateCiArtifact({ + root, + artifact: artifact("report.json", "generic"), + schema: { + id: "generic", + kind: "json", + maxBytes: _name === "oversized" ? 4 : 4_096, + executableSchemaId: "generic-json-object", + }, + }), + ).rejects.toThrow(diagnostic); + }); + + it("rejects leaf and ancestor symlinks before opening evidence", async () => { + const root = await temporaryRoot("ci-artifact-symlink-"); + await writeArtifact(root, "real.json", "{\"ok\":true}\n"); + await symlink("real.json", path.join(root, "leaf.json")); + await mkdir(path.join(root, "real-directory")); + await writeArtifact(root, "real-directory/report.json", "{\"ok\":true}\n"); + await symlink("real-directory", path.join(root, "linked-directory")); + const schema = { + id: "generic", + kind: "json", + maxBytes: 4_096, + executableSchemaId: "generic-json-object", + } as const; + await expect( + validateCiArtifact({ root, artifact: artifact("leaf.json", "generic"), schema }), + ).rejects.toThrow(/not a regular file/i); + await expect( + validateCiArtifact({ + root, + artifact: artifact("linked-directory/report.json", "generic"), + schema, + }), + ).rejects.toThrow(/ancestor is unsafe/i); + }); + + it("rejects strict JSON evidence with unknown fields", async () => { + const root = await temporaryRoot("ci-artifact-strict-"); + await writeArtifact( + root, + "report.json", + `${JSON.stringify({ schemaVersion: 2, nodeVersion: "24.14.0", gateCount: 26, commandDefinitionCount: 81, commandReferenceCount: 93, artifactCount: 105, jobCount: 9, workflowSha256: "a".repeat(64), durationStatus: "UNSUPPORTED", negativeFixtures: [], failures: [], passed: true, unknown: true })}\n`, + ); + await expect( + validateCiArtifact({ + root, + artifact: artifact("report.json", "ci-contract"), + schema: { + id: "ci-contract", + kind: "json", + maxBytes: 8_192, + executableSchemaId: "ci-contract-report", + }, + }), + ).rejects.toThrow(/unrecognized|unknown/i); + }); + + it.each([ + ["broken.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, ""], + ["mismatched.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, ""], + ["trailing.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, "garbage"], + ["doctype.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, "]>"], + ["broken.html", { id: "html", kind: "html", maxBytes: 1_024 }, ""], + ] as const)("rejects structurally incomplete %s", async (relative, schema, content) => { + const root = await temporaryRoot("ci-artifact-structure-"); + await writeArtifact(root, relative, content); + await expect( + validateCiArtifact({ + root, + artifact: artifact(relative, schema.id), + schema: schema as CiGateArtifactSchema, + }), + ).rejects.toThrow(/invalid (?:JUnit|HTML) artifact/u); + }); + + it("fails closed when a regular artifact grows after its bounded lstat", async () => { + const root = await temporaryRoot("ci-artifact-growth-"); + await writeArtifact(root, "report.txt", "1234"); + const realHandle = await open(path.join(root, "report.txt"), "r"); + await expect( + readBoundedRegularFile( + { root, relativePath: "report.txt", maxBytes: 4 }, + { + openFile: async () => ({ + stat: async () => realHandle.stat(), + read: async (buffer, offset) => { + Buffer.from("12345").copy(buffer, offset); + return { bytesRead: 5 }; + }, + close: async () => realHandle.close(), + }), + }, + ), + ).rejects.toThrow(/changed size or exceeds bound/i); + }); + + it("rejects cross-field tampering in risk coverage evidence", async () => { + const root = await temporaryRoot("ci-artifact-risk-"); + const risk = { + schemaVersion: 3, + policy: "config/testing/risk-coverage.json", + summary: "artifacts/tests/coverage/coverage-summary.json", + status: "PASS", + selectedTotal: 2, + repositoryTotal: 2, + counterBearingTotal: 1, + instrumentedCounterBearingTotal: 1, + counterlessTotal: 1, + counterlessModules: ["src/types.ts"], + preExclusionTotal: 2, + generatedExclusionCount: 0, + generatedExclusions: [], + ownershipScope: "ALL_POLICY_HIGH_RISK", + ownedHighRiskPaths: ["src/runtime.ts"], + waivedHighRiskPaths: [], + uncoveredModules: [], + results: ["lines", "statements", "functions", "branches"].map((metric) => ({ + scope: "summary", + metric, + threshold: 80, + received: 90, + passed: true, + })), + failures: [], + }; + const schema = { + id: "risk", + kind: "json", + maxBytes: 16_384, + executableSchemaId: "risk-coverage-v3", + } as const; + await writeArtifact(root, "risk.json", `${JSON.stringify(risk)}\n`); + await expect( + validateCiArtifact({ root, artifact: artifact("risk.json", "risk"), schema }), + ).resolves.toBeUndefined(); + await writeArtifact( + root, + "risk.json", + `${JSON.stringify({ ...risk, counterlessTotal: 0 })}\n`, + ); + await expect( + validateCiArtifact({ root, artifact: artifact("risk.json", "risk"), schema }), + ).rejects.toThrow(/counter partition|counterless list length/u); + + for (const [mutation, diagnostic] of [ + [{ preExclusionTotal: 3 }, /pre-exclusion inventory total drift/u], + [{ status: "PASS", failures: [], results: risk.results.map((entry, index) => index === 0 ? { ...entry, received: 70, passed: false } : entry) }, /status must agree with failures and threshold results/u], + [{ waivedHighRiskPaths: ["src/runtime.ts"] }, /owned and waived high-risk paths overlap/u], + [{ results: risk.results.slice(0, 3) }, /all four metrics/u], + [{ results: [...risk.results, risk.results[0]] }, /duplicated within scope/u], + [{ results: [] }, /too small|at least 4/iu], + ] as const) { + await writeArtifact(root, "risk.json", `${JSON.stringify({ ...risk, ...mutation })}\n`); + await expect( + validateCiArtifact({ root, artifact: artifact("risk.json", "risk"), schema }), + ).rejects.toThrow(diagnostic); + } + }); + + it("rejects coverage counters whose covered and skipped partitions exceed total", async () => { + const root = await temporaryRoot("ci-artifact-coverage-"); + const counter = { total: 10, covered: 8, skipped: 3, pct: 80 }; + await writeArtifact( + root, + "coverage.json", + `${JSON.stringify({ total: { lines: counter, statements: counter, functions: counter, branches: counter } })}\n`, + ); + await expect( + validateCiArtifact({ + root, + artifact: artifact("coverage.json", "coverage"), + schema: { + id: "coverage", + kind: "json", + maxBytes: 4_096, + executableSchemaId: "coverage-summary-v8", + }, + }), + ).rejects.toThrow(/coverage counter exceeds total/u); + }); +}); + +describe("candidate archive and provider upload boundaries", () => { + it("accepts only the manifest-bound candidate member set and bytes", async () => { + const fixture = await createCandidateArchiveFixture(); + await expect( + verifyCiCandidateArchive({ archivePath: fixture.archivePath }), + ).resolves.toEqual( + expect.objectContaining({ archiveSha256: sha256(await readFile(fixture.archivePath)) }), + ); + }); + + it("rejects an extra candidate member before extraction", async () => { + const fixture = await createCandidateArchiveFixture({ extraMember: true }); + await expect( + verifyCiCandidateArchive({ archivePath: fixture.archivePath }), + ).rejects.toThrow(/exact member set drift before extraction/i); + }); + + it("rejects duplicate archive members before extraction", async () => { + const fixture = await createCandidateArchiveFixture({ duplicateMember: true }); + await expect(verifyCiCandidateArchive({ archivePath: fixture.archivePath })) + .rejects.toThrow(/duplicate member/i); + }); + + it.each(["symlink", "hardlink"] as const)("rejects a %s archive member without touching an outside canary", async (kind) => { + const root = await temporaryRoot(`ci-candidate-${kind}-`); + const outside = await temporaryRoot(`ci-candidate-${kind}-outside-`); + const canary = path.join(outside, "canary"); + await writeFile(canary, "unchanged\n"); + await writeArtifact(root, "target", "target\n"); + if (kind === "symlink") await symlink("target", path.join(root, "unsafe")); + else await link(path.join(root, "target"), path.join(root, "unsafe")); + const archivePath = path.join(root, "unsafe.tar.gz"); + const tar = spawnSync("/usr/bin/tar", ["-czf", archivePath, ...(kind === "hardlink" ? ["target"] : []), "unsafe"], { cwd: root, encoding: "utf8" }); + if (tar.status !== 0) throw new Error(tar.stderr); + await expect(verifyCiCandidateArchive({ archivePath })).rejects.toThrow(/non-regular member/i); + await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n"); + }); + + it("rejects traversal members and preserves the outside canary", async () => { + const root = await temporaryRoot("ci-candidate-traversal-"); + const canary = path.join(root, "outside-canary"); + await writeArtifact(root, "safe", "safe\n"); + await writeFile(canary, "unchanged\n"); + const archivePath = path.join(root, "traversal.tar.gz"); + const tar = spawnSync("/usr/bin/tar", ["-czf", archivePath, "--transform=s|safe|../outside-canary|", "safe"], { cwd: root, encoding: "utf8" }); + if (tar.status !== 0) throw new Error(tar.stderr); + await expect(verifyCiCandidateArchive({ archivePath })).rejects.toThrow(/unsafe member path/i); + await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n"); + }); + + it("rejects an oversized manifest from tar headers before full extraction", async () => { + const fixture = await createCandidateArchiveFixture({ oversizedManifest: true }); + await expect(verifyCiCandidateArchive({ archivePath: fixture.archivePath })) + .rejects.toThrow(/manifest exceeds 8388608 bytes/i); + }); + + it("rejects an expanded-byte bomb before extraction and preserves its canary", async () => { + const root = await temporaryRoot("ci-candidate-expanded-bomb-"); + const huge = path.join(root, "huge.bin"); + const handle = await open(huge, "w"); + await handle.truncate(268_435_457); + await handle.close(); + const canary = path.join(root, "canary"); + await writeFile(canary, "unchanged\n"); + const archivePath = path.join(root, "bomb.tar.gz"); + const tar = spawnSync("/usr/bin/tar", ["-czf", archivePath, "huge.bin"], { + cwd: root, + encoding: "utf8", + timeout: 30_000, + }); + if (tar.status !== 0) throw new Error(tar.stderr || String(tar.error)); + await expect(verifyCiCandidateArchive({ archivePath })) + .rejects.toThrow(/expanded bytes exceed the bound/i); + await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n"); + }, 40_000); + + it.each([ + ["missing value", ["--archive"]], + [ + "option-like value", + ["--archive", "missing.tar.gz", "--extract-to", "--github-output", "out"], + ], + ])("maps a %s to the deterministic CLI Usage result", (_label, arguments_) => { + expect(parseCandidateArchiveArguments(arguments_)).toBeNull(); + expect(CANDIDATE_ARCHIVE_USAGE).toBe( + "Usage: verify-ci-candidate-archive --archive [--extract-to ] [--github-output ]\n", + ); + }); + + it("rejects archive digest mismatch and symlink substitution", async () => { + const fixture = await createCandidateArchiveFixture(); + await expect( + verifyCiCandidateArchive({ + archivePath: fixture.archivePath, + expectedSha256: "0".repeat(64), + }), + ).rejects.toThrow(/SHA-256 mismatch/u); + const linked = `${fixture.archivePath}.link`; + await symlink(path.basename(fixture.archivePath), linked); + await expect( + verifyCiCandidateArchive({ archivePath: linked }), + ).rejects.toThrow(/regular non-symlink/u); + }); + + it("rejects an excessive archive member universe before per-member reads", async () => { + const fixture = await createCandidateArchiveFixture({ repeatedExtraMembers: 8_200 }); + await expect( + verifyCiCandidateArchive({ archivePath: fixture.archivePath }), + ).rejects.toThrow(/member count is outside 1\.\.8192|exceeds 8192 members/u); + }); + + it("uses the captured archive inode when the pathname is replaced mid-verification", async () => { + const original = await createCandidateArchiveFixture(); + const replacement = await createCandidateArchiveFixture({ extraMember: true }); + const originalArchiveSha256 = sha256(await readFile(original.archivePath)); + const displaced = `${original.archivePath}.displaced`; + const extractTo = path.join(path.dirname(original.archivePath), "verified-candidate"); + await expect( + verifyCiCandidateArchive( + { + archivePath: original.archivePath, + extractTo, + repositoryRoot: path.dirname(original.archivePath), + }, + { + afterArchiveRead: async () => { + await rename(original.archivePath, displaced); + await rename(replacement.archivePath, original.archivePath); + }, + }, + ), + ).resolves.toEqual(expect.objectContaining({ archiveSha256: originalArchiveSha256 })); + await expect(readFile(displaced)).resolves.toBeDefined(); + await expect(readFile(path.join(extractTo, "dist/app.js"), "utf8")).resolves.toBe("app\n"); + }); + + it("validates provider JSON against candidate dist and lockfile digests", async () => { + const fixture = await createProviderFixture(); + await expect( + validateProviderUpload({ + kind: "vulnerability", + candidateRoot: fixture.candidateRoot, + archivePath: fixture.archivePath, + expectedArchiveSha256: fixture.archiveSha256, + workspaceRoot: fixture.root, + reportPath: fixture.reportPath, + expectedDistSha256: fixture.distSha256, + }), + ).resolves.toEqual(expect.objectContaining({ provider: "fixture" })); + const report = JSON.parse(await readFile(fixture.reportPath, "utf8")) as Record; + report.scannedDistSha256 = "f".repeat(64); + await writeFile(fixture.reportPath, `${JSON.stringify(report)}\n`); + await expect( + validateProviderUpload({ + kind: "vulnerability", + candidateRoot: fixture.candidateRoot, + archivePath: fixture.archivePath, + expectedArchiveSha256: fixture.archiveSha256, + workspaceRoot: fixture.root, + reportPath: fixture.reportPath, + expectedDistSha256: fixture.distSha256, + }), + ).rejects.toThrow(/candidate digest mismatch/i); + }); + + it("uses the reverified archive manifest when extracted candidate files are mutated", async () => { + const fixture = await createProviderFixture(); + await writeArtifact(fixture.candidateRoot, "dist/app.js", "mutated\n"); + const mutableManifest = JSON.parse( + await import("node:fs/promises").then(({ readFile }) => + readFile(path.join(fixture.candidateRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"), + ), + ) as Record; + mutableManifest.distSha256 = "e".repeat(64); + await writeFile( + path.join(fixture.candidateRoot, RELEASE_CANDIDATE_MANIFEST_PATH), + `${JSON.stringify(mutableManifest)}\n`, + ); + await expect( + validateProviderUpload({ + kind: "vulnerability", + candidateRoot: fixture.candidateRoot, + archivePath: fixture.archivePath, + expectedArchiveSha256: fixture.archiveSha256, + workspaceRoot: fixture.root, + reportPath: fixture.reportPath, + expectedDistSha256: fixture.distSha256, + }), + ).rejects.toThrow(/candidate root changed/i); + }); + + it("rejects symlinked provider reports at the bounded file boundary", async () => { + const fixture = await createProviderFixture(); + const real = path.join(fixture.root, "real-report.json"); + await writeFile(real, await readFile(fixture.reportPath)); + await rm(fixture.reportPath); + await symlink(path.relative(path.dirname(fixture.reportPath), real), fixture.reportPath); + await expect( + validateProviderUpload({ + kind: "vulnerability", + candidateRoot: fixture.candidateRoot, + archivePath: fixture.archivePath, + expectedArchiveSha256: fixture.archiveSha256, + workspaceRoot: fixture.root, + reportPath: fixture.reportPath, + expectedDistSha256: fixture.distSha256, + }), + ).rejects.toThrow(/not a regular file/i); + }); + + it("rejects oversized provider reports before JSON parsing", async () => { + const fixture = await createProviderFixture(); + await writeFile(fixture.reportPath, Buffer.alloc(8_388_609, 0x20)); + await expect( + validateProviderUpload({ + kind: "vulnerability", + candidateRoot: fixture.candidateRoot, + archivePath: fixture.archivePath, + expectedArchiveSha256: fixture.archiveSha256, + workspaceRoot: fixture.root, + reportPath: fixture.reportPath, + expectedDistSha256: fixture.distSha256, + }), + ).rejects.toThrow(/size is outside/u); + }); + + it("rejects a stale raw provider report before starting the provider", async () => { + const fixture = await createProviderFixture(); + const markerPath = path.join(fixture.root, "provider-started"); + const result = runProviderSupervisor(fixture, { + command: `node -e 'require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "started")'`, + sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"), + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/raw provider report already exists/i); + await expect(readFile(markerPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("drains and kills provider background processes before sealing evidence", async () => { + const fixture = await createProviderFixture(); + const rawReport = await readFile(fixture.reportPath, "utf8"); + await rm(fixture.reportPath); + const sealedPath = path.join( + fixture.root, + "provider-evidence/vulnerability-report.json", + ); + const mutatorMarker = path.join(fixture.root, "background-mutator-ran"); + const providerScript = path.join(fixture.root, "provider.mjs"); + const mutator = [ + "process.on('SIGTERM', () => {});", + "setTimeout(() => {", + ` require('node:fs').writeFileSync(${JSON.stringify(path.join(fixture.candidateRoot, "dist/app.js"))}, 'mutated\\n');`, + ` require('node:fs').writeFileSync(${JSON.stringify(sealedPath)}, '{"mutated":true}\\n');`, + ` require('node:fs').writeFileSync(${JSON.stringify(mutatorMarker)}, 'ran\\n');`, + "}, 1200);", + ].join("\n"); + await writeFile( + providerScript, + [ + "import { spawn } from 'node:child_process';", + "import { writeFileSync } from 'node:fs';", + `writeFileSync(${JSON.stringify(fixture.reportPath)}, ${JSON.stringify(rawReport)});`, + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(mutator)}], { stdio: 'ignore' });`, + "child.unref();", + ].join("\n"), + ); + const result = runProviderSupervisor(fixture, { + command: `node ${JSON.stringify(providerScript)}`, + sealedPath, + }); + expect(result.status, result.stderr).toBe(0); + await delay(1_500); + await expect(readFile(path.join(fixture.candidateRoot, "dist/app.js"), "utf8")).resolves.toBe("app\n"); + await expect(readFile(mutatorMarker)).rejects.toMatchObject({ code: "ENOENT" }); + expect(JSON.parse(await readFile(sealedPath, "utf8"))).toEqual( + expect.objectContaining({ provider: "fixture" }), + ); + }, 10_000); + + it("does not expose or mutate a host path outside the sandboxed workspace", async () => { + const fixture = await createProviderFixture(); + const rawReport = await readFile(fixture.reportPath, "utf8"); + await rm(fixture.reportPath); + const outside = await temporaryRoot("provider-host-canary-"); + const canary = path.join(outside, "secret-canary"); + await writeFile(canary, "host-secret\n"); + const providerScript = path.join(fixture.root, "provider-host-boundary.mjs"); + await writeFile(providerScript, [ + "import { readFileSync, writeFileSync } from 'node:fs';", + `try { readFileSync(${JSON.stringify(canary)}); process.exit(9); } catch {}`, + `try { writeFileSync(${JSON.stringify(canary)}, 'mutated\\n'); } catch {}`, + `writeFileSync(${JSON.stringify(fixture.reportPath)}, ${JSON.stringify(rawReport)});`, + ].join("\n")); + const result = runProviderSupervisor(fixture, { + command: `node ${JSON.stringify(providerScript)}`, + sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"), + }); + expect(result.status, result.stderr).toBe(0); + await expect(readFile(canary, "utf8")).resolves.toBe("host-secret\n"); + }, 10_000); +}); + +describe("verified promotion staging", () => { + it("publishes the exact five captured promotion inputs", async () => { + const fixture = await createPromotionStagingFixture(); + const staged = await stageVerifiedPromotion(fixture.input, fixture.dependencies); + expect(staged.map(({ path: stagedPath }) => stagedPath)).toEqual(PROMOTED_STAGING_PATHS); + for (const [stagedPath, expected] of fixture.expectedStagedBytes) { + await expect(readFile(path.join(fixture.root, stagedPath))).resolves.toEqual(expected); + expect(staged.find(({ path: candidate }) => candidate === stagedPath)?.sha256).toBe( + sha256(expected), + ); + } + expect(staged).toHaveLength(5); + await writeFile(fixture.input.vulnerabilityReportPath, "mutated after capture\n"); + await expect( + readFile(path.join(fixture.root, ".release/promoted-staging/vulnerability-report.json")), + ).resolves.toEqual( + fixture.expectedStagedBytes.get( + ".release/promoted-staging/vulnerability-report.json", + ), + ); + }); + + it("rejects archive digest drift before publishing staging", async () => { + const fixture = await createPromotionStagingFixture(); + await expect( + stageVerifiedPromotion({ + ...fixture.input, + expectedArchiveSha256: "0".repeat(64), + }, fixture.dependencies), + ).rejects.toThrow(/archive SHA-256/u); + await expect( + readFile(path.join(fixture.root, ".release/promoted-staging/release-candidate.tar.gz")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects a byte-different signed report whose producer digest is stale", async () => { + const fixture = await createPromotionStagingFixture(); + const report = JSON.parse( + await readFile(fixture.input.vulnerabilityReportPath, "utf8"), + ) as unknown; + const replacement = Buffer.from(`${JSON.stringify(report, null, 2)}\n`); + await writeFile(fixture.input.vulnerabilityReportPath, replacement); + await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow( + /vulnerabilityReportSha256 digest mismatch/i, + ); + }); + + it.each([ + ["provider-verification.json", "promotion-verification.json"], + ["promotion-verification.json", "provider-verification.json"], + ] as const)("rejects %s copied into the %s role", async (sourceName, targetName) => { + const fixture = await createPromotionStagingFixture(); + const sourcePath = path.join(fixture.root, "artifacts/security", sourceName); + const targetPath = path.join(fixture.root, "artifacts/security", targetName); + await writeFile(targetPath, await readFile(sourcePath)); + await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow( + /artifactType role mismatch/i, + ); + }); + + it("stages captured bytes and uses captured trust keys after source mutation", async () => { + const fixture = await createPromotionStagingFixture(); + const sourceMappings = [ + [fixture.input.archivePath, ".release/promoted-staging/release-candidate.tar.gz"], + [fixture.input.vulnerabilityReportPath, ".release/promoted-staging/vulnerability-report.json"], + [fixture.input.provenanceAttestationPath, ".release/promoted-staging/provenance-attestation.json"], + [path.join(fixture.root, "artifacts/security/provider-verification.json"), ".release/promoted-staging/provider-verification.json"], + [path.join(fixture.root, "artifacts/security/promotion-verification.json"), ".release/promoted-staging/promotion-verification.json"], + ] as const; + const originalSources = new Map(await Promise.all( + sourceMappings.map(async ([sourcePath]) => [sourcePath, await readFile(sourcePath)] as const), + )); + const staged = await stageVerifiedPromotion(fixture.input, { + ...fixture.dependencies, + afterCapture: async () => { + await Promise.all([ + writeFile(fixture.input.archivePath, "replaced archive\n"), + writeFile(fixture.input.vulnerabilityReportPath, "replaced vulnerability\n"), + writeFile(fixture.input.provenanceAttestationPath, "replaced provenance\n"), + writeFile(path.join(fixture.root, "artifacts/security/provider-verification.json"), "replaced provider verification\n"), + writeFile(path.join(fixture.root, "artifacts/security/promotion-verification.json"), "replaced promotion verification\n"), + writeFile(fixture.input.vulnerabilityPublicKeyPath, "replaced key\n"), + writeFile(fixture.input.provenancePublicKeyPath, "replaced key\n"), + ]); + }, + }); + for (const [sourcePath, stagedPath] of sourceMappings) { + const original = originalSources.get(sourcePath)!; + await expect(readFile(path.join(fixture.root, stagedPath))).resolves.toEqual(original); + expect(staged.find(({ path: candidate }) => candidate === stagedPath)?.sha256) + .toBe(sha256(original)); + } + }); + + it("reruns archived local evidence instead of trusting a pre-existing PASS JSON", async () => { + const fixture = await createPromotionStagingFixture(); + await expect(stageVerifiedPromotion(fixture.input)).rejects.toThrow( + /captured local evidence failed final verification/i, + ); + }); + + it("rejects symlinked and oversized promotion sources", async () => { + const linked = await createPromotionStagingFixture(); + const realReport = path.join(linked.root, "real-vulnerability-report.json"); + await writeFile(realReport, await readFile(linked.input.vulnerabilityReportPath)); + await rm(linked.input.vulnerabilityReportPath); + await symlink(realReport, linked.input.vulnerabilityReportPath); + await expect(stageVerifiedPromotion(linked.input, linked.dependencies)).rejects.toThrow(/regular file/i); + + const oversized = await createPromotionStagingFixture(); + await writeFile( + oversized.input.vulnerabilityReportPath, + Buffer.alloc(16_777_217, 0x20), + ); + await expect(stageVerifiedPromotion(oversized.input, oversized.dependencies)).rejects.toThrow(/size is outside/i); + }); + + it.each(["directory", "symlink"] as const)( + "rejects a pre-existing %s staging target", + async (targetKind) => { + const fixture = await createPromotionStagingFixture(); + const target = path.join(fixture.root, ".release/promoted-staging"); + if (targetKind === "directory") { + await mkdir(target, { recursive: true }); + } else { + const outside = await temporaryRoot("promotion-staging-outside-"); + await symlink(outside, target); + } + await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow( + /publish leaf is unsafe|target already exists/i, + ); + }, + ); + + it("rejects a .release ancestor symlink introduced after capture", async () => { + const fixture = await createPromotionStagingFixture(); + const original = path.join(fixture.root, ".release-original"); + const outside = await temporaryRoot("promotion-release-symlink-"); + await expect( + stageVerifiedPromotion(fixture.input, { + ...fixture.dependencies, + afterCapture: async () => { + await rename(path.join(fixture.root, ".release"), original); + await symlink(outside, path.join(fixture.root, ".release")); + }, + }), + ).rejects.toThrow(/publish ancestor is unsafe|publish directory/u); + await expect(readdir(outside)).resolves.toEqual([]); + }); + + it("detects a staging parent identity swap and cleans its owned temporary", async () => { + const fixture = await createPromotionStagingFixture(); + const displaced = path.join(fixture.root, ".release-displaced"); + const dependencies = { + ...fixture.dependencies, + beforePublishRename: async () => { + await rename(path.join(fixture.root, ".release"), displaced); + await mkdir(path.join(fixture.root, ".release")); + }, + } as Parameters[1]; + await expect(stageVerifiedPromotion(fixture.input, dependencies)).rejects.toThrow( + /parent identity changed/u, + ); + expect((await readdir(displaced)).filter((entry) => entry.startsWith(".promoted-staging."))).toEqual([]); + }); +}); + +function runProviderSupervisor( + fixture: Awaited>, + input: Readonly<{ command: string; sealedPath: string }>, +) { + return spawnSync( + process.execPath, + [path.resolve("scripts/run-and-validate-provider.ts"), "--kind", "vulnerability"], + { + cwd: fixture.root, + encoding: "utf8", + timeout: 8_000, + env: { + ...process.env, + VULNERABILITY_PROVIDER_COMMAND: input.command, + VULNERABILITY_REPORT_PATH: fixture.reportPath, + VALIDATED_PROVIDER_REPORT_PATH: input.sealedPath, + CANDIDATE_LOCKFILE_PATH: path.join(fixture.candidateRoot, "pnpm-lock.yaml"), + CANDIDATE_ARCHIVE_PATH: fixture.archivePath, + CANDIDATE_ARCHIVE_SHA256: fixture.archiveSha256, + CANDIDATE_DIST_SHA256: fixture.distSha256, + }, + }, + ); +} + +async function createCandidateArchiveFixture( + options: Readonly<{ extraMember?: boolean; repeatedExtraMembers?: number; duplicateMember?: boolean; oversizedManifest?: boolean }> = {}, +): Promise> { + const root = await temporaryRoot("ci-candidate-archive-"); + const files = new Map(); + files.set("dist/app.js", Buffer.from("app\n")); + for (const evidencePath of RELEASE_CANDIDATE_EVIDENCE_PATHS) { + files.set(evidencePath, Buffer.from(`${evidencePath}\n`)); + } + for (const [relative, content] of files) await writeArtifact(root, relative, content); + await writeArtifact( + root, + "artifacts/release/dependency-inventory.json", + `${JSON.stringify({ lockfileSha256: sha256(files.get("pnpm-lock.yaml")!) })}\n`, + ); + const manifest = await createReleaseCandidateManifest(root); + await writeArtifact( + root, + RELEASE_CANDIDATE_MANIFEST_PATH, + `${JSON.stringify(manifest)}${options.oversizedManifest ? " ".repeat(8_388_609) : "\n"}`, + ); + if (options.extraMember || options.repeatedExtraMembers) { + await writeArtifact(root, "extra.txt", "extra\n"); + } + const archivePath = path.join(root, "candidate.tar.gz"); + const members = [ + "dist", + ...RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + ...(options.duplicateMember ? ["pnpm-lock.yaml"] : []), + ...(options.extraMember ? ["extra.txt"] : []), + ...Array.from({ length: options.repeatedExtraMembers ?? 0 }, () => "extra.txt"), + ]; + const tar = spawnSync("tar", [...(options.duplicateMember ? ["--hard-dereference"] : []), "-czf", archivePath, ...members], { + cwd: root, + encoding: "utf8", + }); + if (tar.status !== 0) throw new Error(tar.stderr); + return { root, archivePath }; +} + +async function createProviderFixture() { + const root = await temporaryRoot("ci-provider-upload-"); + const candidateRoot = path.join(root, "candidate"); + const lockfile = Buffer.from("lockfileVersion: '9.0'\n"); + const lockfileSha256 = sha256(lockfile); + await writeArtifact(candidateRoot, "pnpm-lock.yaml", lockfile); + await writeArtifact(candidateRoot, "dist/app.js", "app\n"); + for (const evidencePath of RELEASE_CANDIDATE_EVIDENCE_PATHS) { + if (evidencePath === "pnpm-lock.yaml") continue; + const content = evidencePath === "artifacts/release/dependency-inventory.json" + ? `${JSON.stringify({ lockfileSha256 })}\n` + : `${evidencePath}\n`; + await writeArtifact(candidateRoot, evidencePath, content); + } + const candidate = await createReleaseCandidateManifest(candidateRoot); + await writeArtifact(candidateRoot, RELEASE_CANDIDATE_MANIFEST_PATH, `${JSON.stringify(candidate)}\n`); + const distSha256 = candidate.distSha256; + const archivePath = path.join(root, "candidate.tar.gz"); + const tar = spawnSync( + "tar", + ["-czf", archivePath, "dist", ...RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH], + { cwd: candidateRoot, encoding: "utf8" }, + ); + if (tar.status !== 0) throw new Error(tar.stderr); + const archiveSha256 = sha256( + await import("node:fs/promises").then(({ readFile }) => readFile(archivePath)), + ); + const reportPath = path.join( + root, + "provider-evidence/untrusted/vulnerability-report.json", + ); + await writeArtifact( + root, + "provider-evidence/untrusted/vulnerability-report.json", + `${JSON.stringify({ schemaVersion: 1, provider: "fixture", generatedAt: "2026-08-02T00:00:00.000Z", scannedLockfileSha256: lockfileSha256, scannedDistSha256: distSha256, findings: [], signature: { algorithm: "Ed25519", keyId: "fixture", value: "AA==" } })}\n`, + ); + return { root, candidateRoot, archivePath, archiveSha256, reportPath, distSha256 }; +} + +async function createPromotionStagingFixture() { + const candidateFixture = await createCandidateArchiveFixture(); + const root = path.dirname(candidateFixture.archivePath); + const candidate = await verifyCiCandidateArchive({ + archivePath: candidateFixture.archivePath, + }); + const vulnerabilityKeys = generateKeyPairSync("ed25519"); + const provenanceKeys = generateKeyPairSync("ed25519"); + const vulnerabilityKeyId = "fixture-vulnerability"; + const provenanceKeyId = "fixture-provenance"; + const vulnerabilityUnsigned = { + schemaVersion: 1 as const, + provider: "fixture-vulnerability", + generatedAt: "2026-08-02T00:00:00.000Z", + scannedLockfileSha256: candidate.manifest.lockfileSha256, + scannedDistSha256: candidate.manifest.distSha256, + findings: [], + }; + const vulnerabilityReport = { + ...vulnerabilityUnsigned, + signature: { + algorithm: "Ed25519" as const, + keyId: vulnerabilityKeyId, + value: sign( + null, + providerEvidenceSignaturePayload(vulnerabilityUnsigned), + vulnerabilityKeys.privateKey, + ).toString("base64"), + }, + }; + const provenanceUnsigned = { + schemaVersion: 1 as const, + provider: "fixture-provenance", + signer: "fixture-signer", + generatedAt: "2026-08-02T00:00:00.000Z", + subject: { + name: "dist" as const, + digest: { sha256: candidate.manifest.distSha256 }, + }, + }; + const provenanceAttestation = { + ...provenanceUnsigned, + signature: { + algorithm: "Ed25519" as const, + keyId: provenanceKeyId, + value: sign( + null, + providerEvidenceSignaturePayload(provenanceUnsigned), + provenanceKeys.privateKey, + ).toString("base64"), + }, + }; + const candidateArchiveBytes = await readFile(candidateFixture.archivePath); + const vulnerabilityReportBytes = Buffer.from(`${JSON.stringify(vulnerabilityReport)}\n`); + const provenanceAttestationBytes = Buffer.from(`${JSON.stringify(provenanceAttestation)}\n`); + const verificationBindings = { + candidateArchiveSha256: sha256(candidateArchiveBytes), + vulnerabilityReportSha256: sha256(vulnerabilityReportBytes), + provenanceAttestationSha256: sha256(provenanceAttestationBytes), + }; + const verificationBase = { + schemaVersion: 2 as const, + status: "PASS" as const, + vulnerabilityStatus: "PASS" as const, + provenanceAttestationStatus: "PASS" as const, + lockfileSha256: candidate.manifest.lockfileSha256, + distSha256: candidate.manifest.distSha256, + ...verificationBindings, + failures: [], + }; + const providerVerificationBytes = Buffer.from(`${JSON.stringify({ + ...verificationBase, + artifactType: "provider-verification", + })}\n`); + const promotionVerificationBytes = Buffer.from(`${JSON.stringify({ + ...verificationBase, + artifactType: "promotion-verification", + })}\n`); + const vulnerabilityReportPath = path.join( + root, + ".release/vulnerability/vulnerability-report.json", + ); + const provenanceAttestationPath = path.join( + root, + ".release/provenance/provenance-attestation.json", + ); + const vulnerabilityPublicKeyPath = path.join(root, "keys/vulnerability.pem"); + const provenancePublicKeyPath = path.join(root, "keys/provenance.pem"); + await writeArtifact( + root, + ".release/vulnerability/vulnerability-report.json", + vulnerabilityReportBytes, + ); + await writeArtifact( + root, + ".release/provenance/provenance-attestation.json", + provenanceAttestationBytes, + ); + await writeArtifact( + root, + "keys/vulnerability.pem", + vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }), + ); + await writeArtifact( + root, + "keys/provenance.pem", + provenanceKeys.publicKey.export({ type: "spki", format: "pem" }), + ); + await writeArtifact( + root, + "artifacts/security/provider-verification.json", + providerVerificationBytes, + ); + await writeArtifact( + root, + "artifacts/security/promotion-verification.json", + promotionVerificationBytes, + ); + const expectedStagedBytes = new Map([ + [ + ".release/promoted-staging/release-candidate.tar.gz", + candidateArchiveBytes, + ], + [ + ".release/promoted-staging/vulnerability-report.json", + await readFile(vulnerabilityReportPath), + ], + [ + ".release/promoted-staging/provenance-attestation.json", + await readFile(provenanceAttestationPath), + ], + [ + ".release/promoted-staging/provider-verification.json", + await readFile(path.join(root, "artifacts/security/provider-verification.json")), + ], + [ + ".release/promoted-staging/promotion-verification.json", + await readFile(path.join(root, "artifacts/security/promotion-verification.json")), + ], + ]); + return { + root, + dependencies: { + verifyLocalEvidence: async () => ({ status: "PASS" as const, failures: [] }), + }, + expectedStagedBytes, + input: { + repositoryRoot: root, + archivePath: candidateFixture.archivePath, + expectedArchiveSha256: sha256(await readFile(candidateFixture.archivePath)), + vulnerabilityReportPath, + provenanceAttestationPath, + vulnerabilityPublicKeyPath, + vulnerabilityKeyId, + provenancePublicKeyPath, + provenanceKeyId, + }, + }; +} diff --git a/tests/unit/ci-workflow-generation.test.ts b/tests/unit/ci-workflow-generation.test.ts new file mode 100644 index 0000000..7247a42 --- /dev/null +++ b/tests/unit/ci-workflow-generation.test.ts @@ -0,0 +1,462 @@ +import { readFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + CI_ACTION_REGISTRY, + indexCiGateContract, + loadCiGateContract, + parseCiActionRegistry, + parseCiGateContract, + resolveCiActionUses, +} from "../../scripts/contracts/ci-gates.ts"; +import { + createCiWorkflowGenerator, + generateCiWorkflow, + renderCiWorkflow, + type CiWorkflowFileSystem, +} from "../../scripts/generate-ci-workflow.ts"; +import { validatePackageScriptGraph } from "../../scripts/lib/package-script-graph.ts"; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("CI gate contract", () => { + it.each([ + ["unknown-field.json", /unrecognized|unknown/i], + ["duplicate-gate-id.json", /duplicate gate id/i], + ["missing-artifact-schema.json", /unknown artifact schema/i], + ])("rejects %s before projection", async (fixture, diagnostic) => { + const candidate = JSON.parse( + await readFile(`tests/fixtures/ci-contract/${fixture}`, "utf8"), + ); + expect(() => parseCiGateContract(candidate)).toThrow(diagnostic); + }); + + it.each([ + ["unknown-job-dependency.json", /unknown job dependency/i], + ["job-cycle.json", /job dependency cycle/i], + ["unowned-gate.json", /unowned gate: FE-GATE-001/i], + ["multiply-owned-gate.json", /multiply owned gate: FE-GATE-001/i], + ])("applies and rejects physical semantic fixture %s", async (fixture, diagnostic) => { + const mutation = JSON.parse( + await readFile(`tests/fixtures/ci-contract/${fixture}`, "utf8"), + ) as Readonly<{ + jobId: string; + needs?: string[]; + removeGateId?: string; + addGateId?: string; + }>; + const candidate = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + const job = candidate.jobs.find( + (entry: Record) => entry.id === mutation.jobId, + ); + if (!job) throw new Error(`fixture job does not exist: ${mutation.jobId}`); + if (mutation.needs) job.needs = mutation.needs; + if (mutation.removeGateId) { + job.gateIds = job.gateIds.filter((id: string) => id !== mutation.removeGateId); + } + if (mutation.addGateId) job.gateIds.push(mutation.addGateId); + expect(() => parseCiGateContract(candidate)).toThrow(diagnostic); + }); + + it("loads the strict v2 registry and preserves the reviewed baseline", async () => { + const contract = await loadCiGateContract(process.cwd()); + const index = indexCiGateContract(contract); + expect(contract.schemaVersion).toBe(2); + expect(contract.gates.map(({ id }) => id)).toEqual( + Array.from({ length: 26 }, (_, index) => + `FE-GATE-${String(index + 1).padStart(3, "0")}`, + ), + ); + expect(contract.jobs).toHaveLength(9); + expect(contract.commands).toHaveLength(81); + expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(93); + expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23); + expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(85); + expect(contract.artifacts).toHaveLength(105); + expect(contract.stages).toHaveLength(5); + expect(contract.retention.classes).toHaveLength(5); + expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2); + expect(index.gates.get("FE-GATE-020")?.name).toBe("removability"); + }); + + it("keeps the action registry recursively immutable and resolves only known actions", () => { + expect(Object.isFrozen(CI_ACTION_REGISTRY)).toBe(true); + expect(Object.values(CI_ACTION_REGISTRY).every((action) => Object.isFrozen(action))).toBe(true); + expect(resolveCiActionUses("checkout")).toBe( + "https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5", + ); + expect(() => resolveCiActionUses("actions/checkout@v4" as never)).toThrow(/unknown CI action/i); + }); + + it.each([ + ["unknown action", (registry: Record) => { registry.unknown = registry.checkout; }, /unrecognized|unknown/i], + ["movable branch", (registry: Record) => { registry.checkout.revision = "v4"; }, /full 40-hex commit SHA/i], + ["short SHA", (registry: Record) => { registry.checkout.revision = "34e114876b0b"; }, /full 40-hex commit SHA/i], + ["relative repository", (registry: Record) => { registry.checkout.repository = "actions/checkout"; }, /absolute upstream GitHub URL/i], + ])("rejects an unsafe CI action registry mutation: %s", (_name, mutate, diagnostic) => { + const candidate = JSON.parse(JSON.stringify(CI_ACTION_REGISTRY)) as Record; + mutate(candidate); + expect(() => parseCiActionRegistry(candidate)).toThrow(diagnostic); + }); + + it.each([ + ["unknown nested field", (value: Record) => (value.commands[0].unknown = true), /unrecognized|unknown/i], + ["duplicate command id", (value: Record) => value.commands.push({ ...value.commands[0] }), /duplicate command id/i], + ["duplicate command tuple", (value: Record) => value.commands.push({ ...value.commands[0], id: "duplicate-tuple" }), /duplicate command tuple/i], + ["duplicate command reference", (value: Record) => value.gates[0].commandIds.push(value.gates[0].commandIds[0]), /duplicate command reference within gate/i], + ["duplicate artifact id", (value: Record) => value.artifacts.push({ ...value.artifacts[0] }), /duplicate artifact id/i], + ["duplicate artifact schema id", (value: Record) => value.artifactSchemas.push({ ...value.artifactSchemas[0] }), /duplicate artifact schema id/i], + ["duplicate stage id", (value: Record) => value.stages.push({ ...value.stages[0] }), /duplicate stage id/i], + ["duplicate job id", (value: Record) => value.jobs.push({ ...value.jobs[0] }), /duplicate job id/i], + ["duplicate artifact path", (value: Record) => value.artifacts.push({ ...value.artifacts[0], id: "duplicate-path" }), /duplicate artifact path/i], + ["unknown command reference", (value: Record) => value.gates[0].commandIds.push("missing-command"), /unknown command missing-command/i], + ["unknown artifact reference", (value: Record) => (value.gates[0].logArtifactId = "missing-artifact"), /unknown artifact missing-artifact/i], + ["unknown schema reference", (value: Record) => (value.artifacts[0].schemaId = "missing-schema"), /unknown artifact schema missing-schema/i], + ["unknown retention reference", (value: Record) => (value.gates[0].retentionClassId = "missing-retention"), /unknown retention class missing-retention/i], + ["unsafe artifact path", (value: Record) => (value.artifacts[0].path = "../escape"), /unsafe repository path/i], + ["artifact path controls", (value: Record) => (value.artifacts[0].path = "artifacts/bad\n.json"), /control and Unicode line-break characters/i], + ["empty commands", (value: Record) => (value.commands = []), /too small|at least 1/i], + ["empty artifacts", (value: Record) => (value.artifacts = []), /too small|at least 1/i], + ["empty gates", (value: Record) => (value.gates = []), /too small|at least 1/i], + ["empty gate command refs", (value: Record) => (value.gates[0].commandIds = []), /too small|at least 1/i], + ["empty gate evidence refs", (value: Record) => (value.gates[0].evidenceArtifactIds = []), /too small|at least 1/i], + ["unowned gate", (value: Record) => (value.jobs[0].gateIds = value.jobs[0].gateIds.filter((id: string) => id !== "FE-GATE-001")), /unowned gate: FE-GATE-001/i], + ["multiply owned gate", (value: Record) => value.jobs[1].gateIds.push("FE-GATE-001"), /multiply owned gate: FE-GATE-001/i], + ["duplicated immutable gate", (value: Record) => value.jobs[1].gateIds.push("FE-GATE-015"), /release matrix duplicates FE-GATE-015/i], + ["unreachable producer", (value: Record) => (value.jobs.find((job: any) => job.id === "promotion").needs = ["immutable_build", "provenance_provider"]), /download producer vulnerability_provider is unreachable/i], + ["missing producer", (value: Record) => { const producer = value.jobs.find((job: any) => job.id === "immutable_build"); producer.steps = producer.steps.filter((step: any) => step.kind !== "upload"); }, /unknown download transfer release-candidate|job step sequence drift/i], + ["promotion rebuild authority", (value: Record) => value.jobs.find((job: any) => job.id === "promotion").steps.splice(3, 0, { kind: "archive-candidate", stepId: "bad", archivePath: ".release/bad.tar.gz", members: ["dist"], archiveOutputName: "bad", distOutputName: "bad-dist" }), /promotion job must not build|step kind archive-candidate is forbidden/i], + ["browser gate drift", (value: Record) => (value.jobs[0].browserGateIds = ["FE-GATE-008"]), /browser gate set drift/i], + ["missing gate execution step", (value: Record) => (value.jobs[0].steps = value.jobs[0].steps.filter((step: any) => step.kind !== "run-gate")), /canonical job step sequence drift/i], + ["browser install drift", (value: Record) => (value.jobs[0].steps = value.jobs[0].steps.filter((step: any) => step.kind !== "browser-install")), /browser install step drift|job step sequence drift/i], + ["job environment drift", (value: Record) => value.jobs[0].environment.push({ name: "UNOWNED", value: "x" }), /job environment binding drift/i], + ["job kind drift", (value: Record) => (value.jobs[0].kind = "gate-single"), /job graph drift/i], + ["job needs drift", (value: Record) => (value.jobs[1].needs = []), /job graph drift/i], + ["job condition drift", (value: Record) => (value.jobs[1].condition = "always"), /job graph drift/i], + ["candidate output identity drift", (value: Record) => { const job = value.jobs.find((candidate: any) => candidate.id === "immutable_build"); job.steps.find((step: any) => step.kind === "archive-candidate").archiveOutputName = "renamed"; }, /candidate output identity drift/i], + ["stage cycle", (value: Record) => (value.stages[0].needs = ["release"]), /stage dependency cycle/i], + ["provider adapter target drift", (value: Record) => (value.providerAdapter = "package.json"), /canonical generated workflow/i], + ["workflow root extraction", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "extract").targetRoot = ".."), /unsafe workflow path/i], + ["normalized upload root", (value: Record) => (value.jobs[0].steps.find((step: any) => step.kind === "upload").paths = ["foo/.."]), /unsafe workflow path/i], + ["immutable archive field drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "immutable_build").steps.find((step: any) => step.kind === "archive-candidate").archivePath = ".release/other.tar.gz"), /candidate output identity drift|archive and upload fields must remain linked/i], + ["provider role drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "run-provider").provider = "provenance"), /provider archive, extraction, evidence, and upload fields must remain linked/i], + ["provider archive SHA environment drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").environment.find((entry: any) => entry.name === "CANDIDATE_ARCHIVE_SHA256").value = "0".repeat(64)), /job environment binding drift/i], + ["promotion transfer swap", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "download").transferId = "vulnerability-provider-evidence"), /promotion download and extraction fields must remain linked|duplicate.*download/i], + ["raw provider upload", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "upload").paths = ["provider-evidence/untrusted/vulnerability-report.json"]), /provider archive, extraction, evidence, and upload fields must remain linked/i], + ["intervening promotion step", (value: Record) => value.jobs.find((candidate: any) => candidate.id === "promotion").steps.splice(-1, 0, { kind: "frozen-install" }), /promotion verification and upload must be immediately adjacent/i], + ["promotion upload path drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.at(-1).paths[0] = ".release/promoted-staging/replaced.tar.gz"), /exact five typed paths/i], + ["always promotion upload", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.at(-1).always = true), /promotion upload must not use always/i], + ])("rejects semantic mutation: %s", async (_name, mutate, diagnostic) => { + const contract = await loadCiGateContract(process.cwd()); + const candidate = JSON.parse(JSON.stringify(contract)) as Record; + mutate(candidate); + expect(() => parseCiGateContract(candidate)).toThrow(diagnostic); + }); + + it("rejects package scripts missing from the shared command registry", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-contract-package-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const contract = await loadCiGateContract(process.cwd()); + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), '{"scripts":{}}\n'); + await expect(loadCiGateContract(root)).rejects.toThrow(/missing package scripts/i); + }); + + it.each(["check:artifact-schemas", "check:ci-workflow"])( + "rejects a missing nested check:ci dependency: %s", + async (removedScript) => { + const root = await mkdtemp(path.join(tmpdir(), "ci-contract-script-graph-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const contract = await loadCiGateContract(process.cwd()); + const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { + scripts: Record; + }; + delete packageDocument.scripts[removedScript]; + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); + await expect(loadCiGateContract(root)).rejects.toThrow( + /missing package scripts|package script graph invalid/i, + ); + }, + ); + + it.each([ + ["true bypass", "true"], + ["direct self recursion", "corepack pnpm check:ci"], + ["alias cycle", "corepack pnpm check:ci-alias"], + ["option-form gate alias", "corepack pnpm --silent ci:gate"], + ])("rejects non-canonical check:ci orchestration: %s", async (_name, command) => { + const root = await mkdtemp(path.join(tmpdir(), "ci-contract-closed-script-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const contract = await loadCiGateContract(process.cwd()); + const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { + scripts: Record; + }; + packageDocument.scripts["check:ci"] = command; + packageDocument.scripts["check:ci-alias"] = "corepack pnpm check:ci"; + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); + await expect(loadCiGateContract(root)).rejects.toThrow(/exact canonical non-recursive orchestration/i); + }); + + it("detects reachable alias cycles and option-form ci:gate invocations", () => { + expect(validatePackageScriptGraph({ "check:ci": "pnpm alias", alias: "pnpm check:ci" }, "check:ci")) + .toEqual(expect.arrayContaining([expect.stringMatching(/cycle/i)])); + expect(validatePackageScriptGraph({ "check:ci": "pnpm alias", alias: "pnpm --silent ci:gate", "ci:gate": "node scripts/run-ci-gate.ts" }, "check:ci")) + .toEqual(expect.arrayContaining([expect.stringMatching(/must not invoke ci:gate/i)])); + }); + + it("rejects an invalid package graph before the gate runner can spawn it", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-contract-runner-preflight-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const marker = path.join(root, "spawned"); + const contract = await loadCiGateContract(process.cwd()); + const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { scripts: Record }; + packageDocument.scripts["check:ci"] = `node -e 'require("node:fs").writeFileSync(${JSON.stringify(marker)}, "spawned")'`; + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); + const result = spawnSync(process.execPath, [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-010"], { cwd: root, encoding: "utf8" }); + expect(result.status).not.toBe(0); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("records checked-in workflow drift as a failing typed report", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-contract-workflow-drift-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + await mkdir(path.join(root, ".gitea/workflows"), { recursive: true }); + await mkdir(path.join(root, "artifacts/quality"), { recursive: true }); + const contract = await loadCiGateContract(process.cwd()); + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), await readFile("package.json")); + await writeFile(path.join(root, ".nvmrc"), await readFile(".nvmrc")); + const drift = Buffer.from("drifted workflow\n", "utf8"); + await writeFile(path.join(root, ".gitea/workflows/quality-gates.yml"), drift); + const result = spawnSync(process.execPath, [path.resolve("scripts/check-ci-contract.ts")], { + cwd: root, + encoding: "utf8", + }); + expect(result.status).toBe(1); + const report = JSON.parse( + await readFile(path.join(root, "artifacts/quality/ci-contract.json"), "utf8"), + ) as { passed: boolean; failures: string[]; workflowSha256: string }; + expect(report.passed).toBe(false); + expect(report.failures).toEqual(expect.arrayContaining([expect.stringMatching(/workflow drift/i)])); + expect(report.workflowSha256).toBe( + await import("node:crypto").then(({ createHash }) => createHash("sha256").update(drift).digest("hex")), + ); + }); + + it("caps aggregate gate output at the log schema before later commands can accumulate", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-gate-output-budget-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const contract = JSON.parse(JSON.stringify(await loadCiGateContract(process.cwd()))) as Record; + const gate = contract.gates.find((entry: any) => entry.id === "FE-GATE-001"); + const command = contract.commands.find((entry: any) => entry.id === gate.commandIds[0]); + command.script = "test:huge-output"; + const logArtifact = contract.artifacts.find((entry: any) => entry.id === gate.logArtifactId); + const logSchema = contract.artifactSchemas.find((entry: any) => entry.id === logArtifact.schemaId); + logSchema.maxBytes = 8_192; + const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { scripts: Record }; + packageDocument.scripts["test:huge-output"] = "node -e \"process.stdout.write('x'.repeat(20000))\""; + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); + const environment = { ...process.env, CI: "false" }; + const result = spawnSync(process.execPath, [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-001"], { + cwd: root, + encoding: "utf8", + env: environment, + timeout: 15_000, + }); + expect(result.status).toBe(1); + const log = await readFile(path.join(root, logArtifact.path)); + expect(log.byteLength).toBeLessThanOrEqual(8_192); + expect(log.toString("utf8")).toMatch(/aggregate output|INFRASTRUCTURE_FAILURE/i); + }, 20_000); +}); + +describe("CI workflow generation", () => { + it("renders the complete workflow deterministically with one final LF", async () => { + const contract = await loadCiGateContract(process.cwd()); + const first = renderCiWorkflow(contract); + const second = renderCiWorkflow(contract); + expect(second).toBe(first); + expect(first).toMatchSnapshot(); + expect(first).toMatch(/^# GENERATED FILE/u); + expect(first.endsWith("\n")).toBe(true); + expect(first.endsWith("\n\n")).toBe(false); + expect(first).not.toContain("\r"); + expect(first).not.toMatch(/\\\$\{\{/u); + expect(first).toContain( + 'node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT"', + ); + expect(first).not.toContain("process_dist_sha256"); + expect(first).toContain("persist-credentials: false"); + expect(first).toContain("verify-ci-candidate-archive.ts --archive"); + expect(first).toContain("--extract-to"); + expect(first).not.toMatch(/\btar\s+[^\n]*--extract/u); + expect(first).toContain("node scripts/stage-verified-promotion.ts"); + expect(first).toContain(".release/promoted-staging/release-candidate.tar.gz"); + const actionUses = [...first.matchAll(/^\s+-?\s*uses: (.+)$/gmu)].map((match) => match[1]); + expect(actionUses).toHaveLength(32); + expect(new Set(actionUses)).toEqual( + new Set([ + "https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5", + "https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020", + "https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7", + "https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7", + ]), + ); + expect(actionUses.every((uses) => /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[0-9a-f]{40}$/u.test(uses!))).toBe(true); + }); + + it("derives download artifact names from the typed upload producer", async () => { + const contract = await loadCiGateContract(process.cwd()); + const candidate = JSON.parse(JSON.stringify(contract)) as Record; + const producer = candidate.jobs.find( + (job: Record) => job.id === "immutable_build", + ); + const upload = producer.steps.find( + (step: Record) => step.kind === "upload", + ); + upload.name = "renamed-candidate-${{ gitea.run_id }}"; + const rendered = renderCiWorkflow( + candidate as unknown as Awaited>, + ); + expect(rendered.match(/name: "renamed-candidate-\$\{\{ gitea\.run_id \}\}"/gu)).toHaveLength(4); + }); + + it("check mode reports missing and byte-level drift without writing", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-workflow-check-")); + temporaryRoots.push(root); + await writeFile(path.join(root, "package.json"), '{"scripts":{}}\n'); + const contract = await loadCiGateContract(process.cwd()); + const missing = await generateCiWorkflow({ root, contract, check: true }); + expect(missing).toEqual( + expect.objectContaining({ written: false, matches: false, firstDifferenceLine: 1 }), + ); + + const target = path.join(root, ".gitea/workflows/quality-gates.yml"); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, renderCiWorkflow(contract).replace("permissions:", "permissions: ")); + const drift = await generateCiWorkflow({ root, contract, check: true }); + expect(drift.written).toBe(false); + expect(drift.matches).toBe(false); + expect(drift.firstDifferenceByte).toBeGreaterThan(0); + expect(await readFile(target, "utf8")).toContain("permissions: "); + }); + + it("check mode rejects CRLF and extra final newlines as byte drift", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-workflow-newline-")); + temporaryRoots.push(root); + const contract = await loadCiGateContract(process.cwd()); + const target = path.join(root, ".gitea/workflows/quality-gates.yml"); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, `${renderCiWorkflow(contract).replaceAll("\n", "\r\n")}\r\n`); + const result = await generateCiWorkflow({ root, contract, check: true }); + expect(result.matches).toBe(false); + expect(result.firstDifferenceLine).toBeGreaterThan(0); + }); + + it("writes a missing workflow and then passes byte-for-byte check mode", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-workflow-write-")); + temporaryRoots.push(root); + const contract = await loadCiGateContract(process.cwd()); + const written = await generateCiWorkflow({ root, contract, check: false }); + expect(written).toEqual(expect.objectContaining({ written: true, matches: true })); + const checked = await generateCiWorkflow({ root, contract, check: true }); + expect(checked).toEqual(expect.objectContaining({ written: false, matches: true })); + }); + + it.each(["ancestor", "leaf"])("rejects a %s symlink in check and write modes without touching its canary", async (kind) => { + const root = await mkdtemp(path.join(tmpdir(), "ci-workflow-symlink-root-")); + const outside = await mkdtemp(path.join(tmpdir(), "ci-workflow-symlink-outside-")); + temporaryRoots.push(root, outside); + const contract = await loadCiGateContract(process.cwd()); + const canary = path.join(outside, "canary"); + await writeFile(canary, "unchanged\n"); + if (kind === "ancestor") { + await mkdir(path.join(outside, "workflows"), { recursive: true }); + await writeFile(path.join(outside, "workflows/quality-gates.yml"), renderCiWorkflow(contract)); + await symlink(outside, path.join(root, ".gitea")); + } else { + await mkdir(path.join(root, ".gitea/workflows"), { recursive: true }); + await writeFile(path.join(outside, "quality-gates.yml"), renderCiWorkflow(contract)); + await symlink(path.join(outside, "quality-gates.yml"), path.join(root, ".gitea/workflows/quality-gates.yml")); + } + await expect(generateCiWorkflow({ root, contract, check: true })).rejects.toThrow(/unsafe/i); + await expect(generateCiWorkflow({ root, contract, check: false })).rejects.toThrow(/unsafe/i); + await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n"); + }); + + it("preserves the destination and cleans only its owned temp when atomic write fails", async () => { + const contract = await loadCiGateContract(process.cwd()); + const removed: string[] = []; + const flags: Array<{ flags: number; mode: number }> = []; + let renamed = false; + const fileSystem: CiWorkflowFileSystem = { + mkdir: async () => undefined, + readFile: async () => Buffer.from("existing workflow\n"), + open: async (_target, openFlags, mode) => { + flags.push({ flags: openFlags, mode }); + return { + writeFile: async () => { + throw new Error("injected write failure"); + }, + sync: async () => undefined, + close: async () => undefined, + }; + }, + openDirectory: async () => ({ + sync: async () => undefined, + close: async () => undefined, + }), + rename: async () => { + renamed = true; + }, + rm: async (target) => { + removed.push(target); + }, + }; + const generate = createCiWorkflowGenerator({ + fileSystem, + createNonce: () => "owned", + }); + await expect( + generate({ root: "/tmp/ci-workflow-atomic", contract, check: false }), + ).rejects.toThrow(/injected write failure/u); + expect(renamed).toBe(false); + expect(removed).toEqual([ + "/tmp/ci-workflow-atomic/.gitea/workflows/.quality-gates.yml.owned.tmp", + ]); + expect(flags).toEqual([ + { + flags: + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + mode: 0o644, + }, + ]); + }); +}); diff --git a/tests/unit/supply-chain.test.ts b/tests/unit/supply-chain.test.ts index 2c07065..2d52e36 100644 --- a/tests/unit/supply-chain.test.ts +++ b/tests/unit/supply-chain.test.ts @@ -23,6 +23,7 @@ import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check. import { evaluatePromotionEvidence, providerEvidenceSignaturePayload, + providerVerificationArtifactSchema, } from "../../scripts/lib/provider-evidence.ts"; import { createReleaseCandidateManifest, @@ -31,6 +32,10 @@ import { } from "../../scripts/lib/release-candidate.ts"; import { deterministicSupplyChainGeneratedAt } from "../../scripts/lib/supply-chain-time.ts"; import { verifyPromotionInputs } from "../../scripts/lib/promotion-verifier.ts"; +import { + indexCiGateContract, + loadCiGateContract, +} from "../../scripts/contracts/ci-gates.ts"; const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; const dependency = { @@ -125,6 +130,7 @@ async function writeProviderEnvironment( ); await mkdir(path.join(root, "provider"), { recursive: true }); await Promise.all([ + writeFile(path.join(root, "provider/candidate.tar.gz"), "fixture archive\n"), writeFile( path.join(root, "provider/vulnerability.json"), `${JSON.stringify(vulnerabilityReport)}\n`, @@ -147,6 +153,10 @@ async function writeProviderEnvironment( ), ]); return { + CANDIDATE_ARCHIVE_PATH: "provider/candidate.tar.gz", + CANDIDATE_ARCHIVE_SHA256: createHash("sha256") + .update("fixture archive\n") + .digest("hex"), VULNERABILITY_REPORT_PATH: "provider/vulnerability.json", PROVENANCE_ATTESTATION_PATH: "provider/provenance.json", VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem", @@ -157,6 +167,39 @@ async function writeProviderEnvironment( } describe("supply-chain policy", () => { + it("emits a strict role-bound v2 verification record from exact input bytes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v2-")); + try { + const manifest = await createMinimalCandidateTree(root); + const environment = await writeProviderEnvironment( + root, + manifest.distSha256, + manifest.lockfileSha256, + ); + const report = await verifyPromotionInputs({ + artifactType: "provider-verification", + repositoryRoot: root, + environment, + verifyLocalEvidence: async () => ({ status: "PASS" as const, failures: [] }), + } as Parameters[0]); + expect(providerVerificationArtifactSchema.parse(report)).toEqual( + expect.objectContaining({ + schemaVersion: 2, + artifactType: "provider-verification", + candidateArchiveSha256: environment.CANDIDATE_ARCHIVE_SHA256, + vulnerabilityReportSha256: createHash("sha256") + .update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!))) + .digest("hex"), + provenanceAttestationSha256: createHash("sha256") + .update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!))) + .digest("hex"), + }), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("wires candidate files, PEM trust, env report paths, and mutation checks", async () => { const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-")); try { @@ -171,11 +214,13 @@ describe("supply-chain policy", () => { failures: [] as const, }); const valid = await verifyPromotionInputs({ + artifactType: "provider-verification", repositoryRoot: root, environment: validEnvironment, verifyLocalEvidence: acceptLocalEvidence, }); const absent = await verifyPromotionInputs({ + artifactType: "provider-verification", repositoryRoot: root, environment: {}, verifyLocalEvidence: acceptLocalEvidence, @@ -186,12 +231,14 @@ describe("supply-chain policy", () => { manifest.lockfileSha256, ); const wrongDigest = await verifyPromotionInputs({ + artifactType: "provider-verification", repositoryRoot: root, environment: wrongEnvironment, verifyLocalEvidence: acceptLocalEvidence, }); await writeFile(path.join(root, "dist/app.js"), "mutated\n"); const postAttestationMutation = await verifyPromotionInputs({ + artifactType: "provider-verification", repositoryRoot: root, environment: validEnvironment, verifyLocalEvidence: acceptLocalEvidence, @@ -228,6 +275,7 @@ describe("supply-chain policy", () => { ); const before = await readFile(localVerificationPath, "utf8"); const result = await verifyPromotionInputs({ + artifactType: "provider-verification", repositoryRoot: root, environment, }); @@ -656,20 +704,28 @@ describe("supply-chain policy", () => { }); it("wires the exact security fixture checker as a passing CI gate", async () => { - const gates = JSON.parse(await readFile("config/ci/gates.json", "utf8")) as { - gates: Record; - }; - const securityGate = gates.gates["FE-GATE-013"]!; - expect(securityGate.steps).toContainEqual({ - script: "check:security:fixtures", - expect: "pass", - }); - expect(securityGate.steps).not.toEqual( + const contract = await loadCiGateContract(process.cwd()); + const index = indexCiGateContract(contract); + const securityGate = index.gates.get("FE-GATE-013"); + expect(securityGate).toBeDefined(); + const commands = securityGate!.commandIds.map((commandId) => + index.commands.get(commandId), + ); + expect(commands).toContainEqual( + expect.objectContaining({ + script: "check:security:fixtures", + expect: "pass", + }), + ); + expect(commands).not.toEqual( expect.arrayContaining([ expect.objectContaining({ script: "scan:security:fixture" }), ]), ); - expect(securityGate.evidence).not.toContain( + const evidence = securityGate!.evidenceArtifactIds.map( + (artifactId) => index.artifacts.get(artifactId)?.path, + ); + expect(evidence).not.toContain( "artifacts/security/scan-fixture.sarif", ); });