refactor: generate CI workflow from gate contracts

This commit is contained in:
DongHyeonka
2026-08-02 13:53:25 +09:00
parent 777ce5c9ed
commit 1bb2cc4a20
45 changed files with 8599 additions and 1177 deletions
+149 -110
View File
@@ -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
+2189 -414
View File
File diff suppressed because it is too large Load Diff
+65 -9
View File
@@ -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-* / <name>` check required for its
declared tier. This repository cannot configure server-side protection by
committing a file.
+63 -6
View File
@@ -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
@@ -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:**
+3 -1
View File
@@ -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",
+81 -329
View File
@@ -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<Record<string, Readonly<{ gates?: readonly string[] }>>>;
gates: Readonly<Record<string, GateDefinition>>;
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<string, string>;
};
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<string, string> }),
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<Record<string, readonly string[]>> = {
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(?<body>[\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");
}
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}`);
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");
}
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<string, GateResult> = {};
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<string, GateResult> = 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<keyof typeof PROMOTION_FORMULA>) {
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<string, { gates?: readonly string[] }> = {};
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<string, GateDefinition> = {};
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<string, unknown> {
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");
@@ -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`,
+2 -19
View File
@@ -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) {
+993
View File
@@ -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<typeof ciActionRegistrySchema>;
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<Record<string, CiActionId>>);
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<typeof ciGateContractSchema>;
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<string, CiGateCommand>;
artifactSchemas: ReadonlyMap<string, CiGateArtifactSchema>;
artifacts: ReadonlyMap<string, CiGateArtifact>;
gates: ReadonlyMap<string, CiGate>;
stages: ReadonlyMap<string, CiGateContract["stages"][number]>;
jobs: ReadonlyMap<string, CiWorkflowJob>;
retentionClasses: ReadonlyMap<string, CiGateContract["retention"]["classes"][number]>;
}>;
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<CiGateContract> {
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<typeof ciGateContractBaseSchema>,
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<string>();
for (const entry of entries) {
if (seen.has(entry.id)) issue(`duplicate ${label} id: ${entry.id}`);
seen.add(entry.id);
}
}
const commandTuples = new Map<string, string>();
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<string, string>();
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<readonly [string, string, readonly string[], readonly string[]]> = [
["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<string, string[]>();
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<string, string[]>();
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<Record<string, readonly string[]>> = {
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<Record<string, readonly [CiWorkflowJob["kind"], readonly string[], CiWorkflowJob["condition"]]>> = {
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<Record<string, readonly CiWorkflowStep["kind"][]>> = {
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<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
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<string, { producer: string; step: z.infer<typeof uploadStep> }>();
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<typeof ciGateContractBaseSchema>,
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<typeof jobSchema>,
issue: (message: string, path?: PropertyKey[]) => void,
): void {
const allowed: Readonly<Record<z.infer<typeof jobSchema>["kind"], ReadonlySet<string>>> = {
"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<string>();
const visited = new Set<string>();
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<string, readonly string[]>,
): boolean {
const pending = [...(graph.get(consumer) ?? [])];
const visited = new Set<string>();
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;
}
+7
View File
@@ -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);
+466
View File
@@ -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<unknown>;
readFile(target: string): Promise<Buffer>;
open(target: string, flags: number, mode: number): Promise<{
writeFile(content: string, encoding: "utf8"): Promise<unknown>;
sync(): Promise<unknown>;
close(): Promise<unknown>;
}>;
openDirectory(target: string): Promise<{ sync(): Promise<unknown>; close(): Promise<unknown> }>;
rename(source: string, destination: string): Promise<unknown>;
rm(target: string): Promise<unknown>;
}>;
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<string, { name: string }>();
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<typeof indexCiGateContract>,
transfers: ReadonlyMap<string, Readonly<{ name: string }>>,
): 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, Readonly<{ name: string }>>,
): 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<CiWorkflowJob["condition"], string | null> = {
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<string, Readonly<{ name: string }>>,
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<GenerateCiWorkflowResult> {
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;
}
}
+453
View File
@@ -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<string, Set<string>>();
artifact.results.forEach(({ scope, metric }, index) => {
const metrics = resultsByScope.get(scope) ?? new Set<string>();
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<Record<ExecutableJsonSchemaId, ZodType>> = 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<Stats>;
read(
buffer: Buffer,
offset: number,
length: number,
position: number,
): Promise<Readonly<{ bytesRead: number }>>;
close(): Promise<unknown>;
}>;
type ValidatorDependencies = Readonly<{
lstatPath?: typeof lstat;
realpathPath?: typeof realpath;
openFile?: (target: string, flags: number) => Promise<ReadHandle>;
}>;
export async function validateCiArtifact(
input: Readonly<{
root: string;
artifact: CiGateArtifact;
schema: CiGateArtifactSchema;
}>,
dependencies: ValidatorDependencies = {},
): Promise<void> {
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*(?:<!doctype\s+html\s*>\s*)?<html\b[^>]*>[\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<Buffer> {
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<Buffer> {
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 (/<!DOCTYPE\b|<!ENTITY\b/iu.test(source)) throw invalid();
const stack: string[] = [];
let root: string | undefined;
let rootClosed = false;
let cursor = 0;
while (cursor < source.length) {
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<![CDATA[", open)) {
const close = source.indexOf("]]>", open + 9);
if (stack.length === 0 || close < 0) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<?", open)) {
const close = source.indexOf("?>", 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<string>();
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})`);
}
+26
View File
@@ -0,0 +1,26 @@
export const CANDIDATE_ARCHIVE_USAGE =
"Usage: verify-ci-candidate-archive --archive <path> [--extract-to <path>] [--github-output <path>]\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<string, string>();
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")! } : {}),
});
}
+607
View File
@@ -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<void> }> = {},
): Promise<Readonly<{
archiveSha256: string;
memberCount: number;
manifest: ReleaseCandidateManifest;
}>> {
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<ReturnType<typeof ensureSafePublishDirectory>> | 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<void>;
}> = {},
): Promise<Readonly<{
archiveSha256: string;
memberCount: number;
manifest: ReleaseCandidateManifest;
}>> {
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<string>();
const regularMembers = new Set<string>();
const directoryMembers = new Set<string>();
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 = /^(?<mode>.{10})\s+\d+\/\d+\s+(?<bytes>\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:\s+[+-]\d{4})?\s+(?<path>.+)$/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<string, Readonly<{ bytes: number; sha256: string }>>();
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<Readonly<{ memberCount: number; manifest: ReleaseCandidateManifest }>> {
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<string, Readonly<{ bytes: number; sha256: string }>>();
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<ReadonlyArray<Readonly<{ path: string; type: "file" | "directory" }>>> {
const children = await readdir(path.join(root, relativeDirectory), {
withFileTypes: true,
});
const entries: Array<Readonly<{ path: string; type: "file" | "directory" }>> = [];
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<ReturnType<typeof lstat>>,
after: Awaited<ReturnType<typeof lstat>>,
): 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<Buffer> {
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<Readonly<{ root: string; handle: FileHandle }>> {
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<string>();
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<boolean> {
try {
await lstat(target);
return true;
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false;
throw error;
}
}
+45
View File
@@ -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");
}
});
+187
View File
@@ -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<void> {
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<Stats> {
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<void> {
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<boolean> {
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<void> {
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);
}
+54
View File
@@ -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<Record<string, string>>,
entryScript: string,
): string[] {
const failures: string[] = [];
const visiting = new Set<string>();
const visited = new Set<string>();
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)];
}
+293
View File
@@ -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<void>;
beforePublishRename?: () => Promise<void>;
}> = {}): Promise<ReadonlyArray<Readonly<{ path: string; sha256: string }>>> {
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<Buffer> {
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<boolean> {
try {
await lstat(target);
return true;
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false;
throw error;
}
}
async function syncDirectory(directory: string): Promise<void> {
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);
}
+104 -27
View File
@@ -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<unknown> {
if (!file) return null;
async function captureOptionalInput(
root: string,
configuredPath: string | undefined,
maxBytes: number,
label: string,
failures: string[],
): Promise<Readonly<{ bytes: Buffer | null; sha256: string | null }>> {
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<Record<string, unknown>> {
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<string, unknown>;
}
async function boundedConfiguredFile(
configuredRoot: string,
configuredPath: string,
maxBytes: number,
): Promise<Buffer> {
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,
});
}
+45 -2
View File
@@ -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;
+74
View File
@@ -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<unknown> {
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;
}
+6 -2
View File
@@ -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<string[]> {
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()) {
+36
View File
@@ -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",
});
}
});
+248
View File
@@ -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<void> {
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<void>((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<boolean> {
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<void> {
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);
}
+83 -177
View File
@@ -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<Record<string, GateDefinition>>;
}>;
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"
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<string, GateDefinition> = {};
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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
+22
View File
@@ -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`,
);
@@ -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<string, string>;
};
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"))),
);
gate.evidence = gate.evidence.filter(
(evidence) =>
!evidence.includes("browser-capabilities") &&
!evidence.includes("browser-file-storage-runtime-removal"),
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.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<readonly [string, boolean]> = [
+3
View File
@@ -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 {
+74 -18
View File
@@ -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"))),
);
gate.evidence = gate.evidence.filter(
(evidence) =>
!evidence.includes("realtime-boundaries") &&
!evidence.includes("realtime-runtime-removal"),
const removedScripts = new Set<string>(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.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<readonly [string, boolean]> = [
+2
View File
@@ -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";
+37
View File
@@ -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;
}
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+14
View File
@@ -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" }] }
}
+4
View File
@@ -0,0 +1,4 @@
{
"jobId": "merge_gate",
"needs": ["release_gate"]
}
+11
View File
@@ -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": [] }
}
+4
View File
@@ -0,0 +1,4 @@
{
"jobId": "release_gate",
"addGateId": "FE-GATE-001"
}
+12
View File
@@ -0,0 +1,12 @@
{
"schemaVersion": 2,
"providerAdapter": ".gitea/workflows/quality-gates.yml",
"commands": [],
"artifactSchemas": [],
"artifacts": [],
"gates": [],
"stages": [],
"jobs": [],
"retention": { "durationStatus": "UNSUPPORTED", "classes": [] },
"unexpected": true
}
@@ -0,0 +1,4 @@
{
"jobId": "merge_gate",
"needs": ["missing"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"jobId": "merge_gate",
"removeGateId": "FE-GATE-001"
}
@@ -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
"
`;
File diff suppressed because it is too large Load Diff
+462
View File
@@ -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<string, any>;
const job = candidate.jobs.find(
(entry: Record<string, any>) => 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<string, any>) => { registry.unknown = registry.checkout; }, /unrecognized|unknown/i],
["movable branch", (registry: Record<string, any>) => { registry.checkout.revision = "v4"; }, /full 40-hex commit SHA/i],
["short SHA", (registry: Record<string, any>) => { registry.checkout.revision = "34e114876b0b"; }, /full 40-hex commit SHA/i],
["relative repository", (registry: Record<string, any>) => { 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<string, any>;
mutate(candidate);
expect(() => parseCiActionRegistry(candidate)).toThrow(diagnostic);
});
it.each([
["unknown nested field", (value: Record<string, any>) => (value.commands[0].unknown = true), /unrecognized|unknown/i],
["duplicate command id", (value: Record<string, any>) => value.commands.push({ ...value.commands[0] }), /duplicate command id/i],
["duplicate command tuple", (value: Record<string, any>) => value.commands.push({ ...value.commands[0], id: "duplicate-tuple" }), /duplicate command tuple/i],
["duplicate command reference", (value: Record<string, any>) => value.gates[0].commandIds.push(value.gates[0].commandIds[0]), /duplicate command reference within gate/i],
["duplicate artifact id", (value: Record<string, any>) => value.artifacts.push({ ...value.artifacts[0] }), /duplicate artifact id/i],
["duplicate artifact schema id", (value: Record<string, any>) => value.artifactSchemas.push({ ...value.artifactSchemas[0] }), /duplicate artifact schema id/i],
["duplicate stage id", (value: Record<string, any>) => value.stages.push({ ...value.stages[0] }), /duplicate stage id/i],
["duplicate job id", (value: Record<string, any>) => value.jobs.push({ ...value.jobs[0] }), /duplicate job id/i],
["duplicate artifact path", (value: Record<string, any>) => value.artifacts.push({ ...value.artifacts[0], id: "duplicate-path" }), /duplicate artifact path/i],
["unknown command reference", (value: Record<string, any>) => value.gates[0].commandIds.push("missing-command"), /unknown command missing-command/i],
["unknown artifact reference", (value: Record<string, any>) => (value.gates[0].logArtifactId = "missing-artifact"), /unknown artifact missing-artifact/i],
["unknown schema reference", (value: Record<string, any>) => (value.artifacts[0].schemaId = "missing-schema"), /unknown artifact schema missing-schema/i],
["unknown retention reference", (value: Record<string, any>) => (value.gates[0].retentionClassId = "missing-retention"), /unknown retention class missing-retention/i],
["unsafe artifact path", (value: Record<string, any>) => (value.artifacts[0].path = "../escape"), /unsafe repository path/i],
["artifact path controls", (value: Record<string, any>) => (value.artifacts[0].path = "artifacts/bad\n.json"), /control and Unicode line-break characters/i],
["empty commands", (value: Record<string, any>) => (value.commands = []), /too small|at least 1/i],
["empty artifacts", (value: Record<string, any>) => (value.artifacts = []), /too small|at least 1/i],
["empty gates", (value: Record<string, any>) => (value.gates = []), /too small|at least 1/i],
["empty gate command refs", (value: Record<string, any>) => (value.gates[0].commandIds = []), /too small|at least 1/i],
["empty gate evidence refs", (value: Record<string, any>) => (value.gates[0].evidenceArtifactIds = []), /too small|at least 1/i],
["unowned gate", (value: Record<string, any>) => (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<string, any>) => value.jobs[1].gateIds.push("FE-GATE-001"), /multiply owned gate: FE-GATE-001/i],
["duplicated immutable gate", (value: Record<string, any>) => value.jobs[1].gateIds.push("FE-GATE-015"), /release matrix duplicates FE-GATE-015/i],
["unreachable producer", (value: Record<string, any>) => (value.jobs.find((job: any) => job.id === "promotion").needs = ["immutable_build", "provenance_provider"]), /download producer vulnerability_provider is unreachable/i],
["missing producer", (value: Record<string, any>) => { 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<string, any>) => 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<string, any>) => (value.jobs[0].browserGateIds = ["FE-GATE-008"]), /browser gate set drift/i],
["missing gate execution step", (value: Record<string, any>) => (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<string, any>) => (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<string, any>) => value.jobs[0].environment.push({ name: "UNOWNED", value: "x" }), /job environment binding drift/i],
["job kind drift", (value: Record<string, any>) => (value.jobs[0].kind = "gate-single"), /job graph drift/i],
["job needs drift", (value: Record<string, any>) => (value.jobs[1].needs = []), /job graph drift/i],
["job condition drift", (value: Record<string, any>) => (value.jobs[1].condition = "always"), /job graph drift/i],
["candidate output identity drift", (value: Record<string, any>) => { 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<string, any>) => (value.stages[0].needs = ["release"]), /stage dependency cycle/i],
["provider adapter target drift", (value: Record<string, any>) => (value.providerAdapter = "package.json"), /canonical generated workflow/i],
["workflow root extraction", (value: Record<string, any>) => (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<string, any>) => (value.jobs[0].steps.find((step: any) => step.kind === "upload").paths = ["foo/.."]), /unsafe workflow path/i],
["immutable archive field drift", (value: Record<string, any>) => (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<string, any>) => (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<string, any>) => (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<string, any>) => (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<string, any>) => (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<string, any>) => 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<string, any>) => (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<string, any>) => (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<string, any>;
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<string, string>;
};
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<string, string>;
};
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<string, string> };
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<string, any>;
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<string, string> };
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<string, any>;
const producer = candidate.jobs.find(
(job: Record<string, any>) => job.id === "immutable_build",
);
const upload = producer.steps.find(
(step: Record<string, any>) => step.kind === "upload",
);
upload.name = "renamed-candidate-${{ gitea.run_id }}";
const rendered = renderCiWorkflow(
candidate as unknown as Awaited<ReturnType<typeof loadCiGateContract>>,
);
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,
},
]);
});
});
+64 -8
View File
@@ -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<typeof verifyPromotionInputs>[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<string, { steps: unknown[]; evidence: string[] }>;
};
const securityGate = gates.gates["FE-GATE-013"]!;
expect(securityGate.steps).toContainEqual({
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(securityGate.steps).not.toEqual(
}),
);
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",
);
});