refactor: generate CI workflow from gate contracts
This commit is contained in:
+149
-110
@@ -1,3 +1,4 @@
|
|||||||
|
# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`.
|
||||||
name: frontend-quality-gates
|
name: frontend-quality-gates
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -31,7 +32,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
merge_gate:
|
merge_gate:
|
||||||
name: ${{ matrix.gate }} / ${{ matrix.name }}
|
name: "${{ matrix.gate }} / ${{ matrix.name }}"
|
||||||
if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}
|
if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
@@ -51,10 +52,12 @@ jobs:
|
|||||||
- { gate: FE-GATE-010, name: architecture, browser: false }
|
- { gate: FE-GATE-010, name: architecture, browser: false }
|
||||||
- { gate: FE-GATE-011, name: build, browser: false }
|
- { gate: FE-GATE-011, name: build, browser: false }
|
||||||
- { gate: FE-GATE-013, name: security, 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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
- uses: actions/setup-node@v4
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Frozen install
|
- name: Frozen install
|
||||||
@@ -66,22 +69,22 @@ jobs:
|
|||||||
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
|
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
|
||||||
- name: Run blocking gate
|
- name: Run blocking gate
|
||||||
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
|
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
|
||||||
- name: Upload gate evidence
|
- name: Upload merge gate evidence
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.gate }}-${{ gitea.run_id }}
|
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
|
||||||
path: artifacts/
|
path: artifacts/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
release_gate:
|
release_gate:
|
||||||
name: ${{ matrix.gate }} / ${{ matrix.name }}
|
name: "${{ matrix.gate }} / ${{ matrix.name }}"
|
||||||
needs: merge_gate
|
needs: merge_gate
|
||||||
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
HOSTING_BASE_URL: ${{ vars.HOSTING_BASE_URL }}
|
HOSTING_BASE_URL: "${{ vars.HOSTING_BASE_URL }}"
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -91,8 +94,10 @@ jobs:
|
|||||||
- { gate: FE-GATE-019, name: hosting-header, browser: false }
|
- { gate: FE-GATE-019, name: hosting-header, browser: false }
|
||||||
- { gate: FE-GATE-026, name: lab-performance, browser: true }
|
- { gate: FE-GATE-026, name: lab-performance, browser: true }
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
- uses: actions/setup-node@v4
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Frozen install
|
- name: Frozen install
|
||||||
@@ -104,25 +109,28 @@ jobs:
|
|||||||
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
|
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
|
||||||
- name: Run blocking gate
|
- name: Run blocking gate
|
||||||
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
|
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
|
||||||
- name: Upload gate evidence
|
- name: Upload release gate evidence
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.gate }}-${{ gitea.run_id }}
|
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
|
||||||
path: artifacts/
|
path: artifacts/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
immutable_build:
|
immutable_build:
|
||||||
name: FE-GATE-015 / immutable-release-candidate
|
name: "FE-GATE-015 / immutable-release-candidate"
|
||||||
needs: release_gate
|
needs: release_gate
|
||||||
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
outputs:
|
outputs:
|
||||||
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
|
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
|
||||||
|
archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
- uses: actions/setup-node@v4
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Frozen install
|
- name: Frozen install
|
||||||
@@ -131,7 +139,7 @@ jobs:
|
|||||||
corepack pnpm install --frozen-lockfile
|
corepack pnpm install --frozen-lockfile
|
||||||
- name: Build candidate once and verify local evidence
|
- name: Build candidate once and verify local evidence
|
||||||
run: corepack pnpm ci:gate -- FE-GATE-015
|
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
|
id: candidate
|
||||||
run: |
|
run: |
|
||||||
mkdir -p .release
|
mkdir -p .release
|
||||||
@@ -144,7 +152,6 @@ jobs:
|
|||||||
artifacts/release/checksums.txt \
|
artifacts/release/checksums.txt \
|
||||||
artifacts/release/dependency-inventory.json \
|
artifacts/release/dependency-inventory.json \
|
||||||
artifacts/release/provenance.json \
|
artifacts/release/provenance.json \
|
||||||
artifacts/release/release-candidate.json \
|
|
||||||
artifacts/release/verification.json \
|
artifacts/release/verification.json \
|
||||||
artifacts/release/sbom.cdx.json \
|
artifacts/release/sbom.cdx.json \
|
||||||
artifacts/security/dependency-diff.json \
|
artifacts/security/dependency-diff.json \
|
||||||
@@ -152,13 +159,14 @@ jobs:
|
|||||||
artifacts/security/scan.sarif \
|
artifacts/security/scan.sarif \
|
||||||
artifacts/security/supply-chain-coherence.json \
|
artifacts/security/supply-chain-coherence.json \
|
||||||
artifacts/security/supply-chain-verification.json \
|
artifacts/security/supply-chain-verification.json \
|
||||||
artifacts/security/vulnerability-report.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"
|
artifacts/release/release-candidate.json
|
||||||
- name: Upload immutable candidate archive
|
node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT"
|
||||||
uses: actions/upload-artifact@v4
|
- name: Upload release candidate
|
||||||
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||||
with:
|
with:
|
||||||
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
path: .release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz
|
path: ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
vulnerability_provider:
|
vulnerability_provider:
|
||||||
@@ -167,28 +175,39 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
|
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||||
CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml
|
CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
VULNERABILITY_PROVIDER_COMMAND: ${{ vars.VULNERABILITY_PROVIDER_COMMAND }}
|
CANDIDATE_DIST_SHA256: "${{ needs.immutable_build.outputs.dist_sha256 }}"
|
||||||
VULNERABILITY_REPORT_PATH: provider-evidence/vulnerability-report.json
|
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:
|
steps:
|
||||||
- name: Download immutable candidate archive
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
persist-credentials: false
|
||||||
path: .release/vulnerability-candidate
|
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
- name: Extract the provider input candidate
|
with:
|
||||||
run: tar -xzf ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
node-version-file: .nvmrc
|
||||||
- name: Run configured external vulnerability provider
|
- name: Frozen install
|
||||||
run: |
|
run: |
|
||||||
test -n "$VULNERABILITY_PROVIDER_COMMAND"
|
corepack enable
|
||||||
mkdir -p provider-evidence
|
corepack pnpm install --frozen-lockfile
|
||||||
sh -eu -c "$VULNERABILITY_PROVIDER_COMMAND"
|
- name: Download release candidate
|
||||||
test -s "$VULNERABILITY_REPORT_PATH"
|
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||||
- name: Upload external vulnerability report
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
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
|
path: provider-evidence/vulnerability-report.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
@@ -198,28 +217,39 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
|
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||||
CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml
|
CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
PROVENANCE_PROVIDER_COMMAND: ${{ vars.PROVENANCE_PROVIDER_COMMAND }}
|
CANDIDATE_DIST_SHA256: "${{ needs.immutable_build.outputs.dist_sha256 }}"
|
||||||
PROVENANCE_ATTESTATION_PATH: provider-evidence/provenance-attestation.json
|
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:
|
steps:
|
||||||
- name: Download immutable candidate archive
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
persist-credentials: false
|
||||||
path: .release/provenance-candidate
|
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
- name: Extract the provider input candidate
|
with:
|
||||||
run: tar -xzf ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
node-version-file: .nvmrc
|
||||||
- name: Run configured external provenance provider
|
- name: Frozen install
|
||||||
run: |
|
run: |
|
||||||
test -n "$PROVENANCE_PROVIDER_COMMAND"
|
corepack enable
|
||||||
mkdir -p provider-evidence
|
corepack pnpm install --frozen-lockfile
|
||||||
sh -eu -c "$PROVENANCE_PROVIDER_COMMAND"
|
- name: Download release candidate
|
||||||
test -s "$PROVENANCE_ATTESTATION_PATH"
|
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||||
- name: Upload external provenance attestation
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
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
|
path: provider-evidence/provenance-attestation.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
@@ -229,56 +259,59 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
VULNERABILITY_REPORT_PATH: .release/vulnerability/vulnerability-report.json
|
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||||
PROVENANCE_ATTESTATION_PATH: .release/provenance/provenance-attestation.json
|
CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
VULNERABILITY_PUBLIC_KEY_PATH: ${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}
|
CANDIDATE_ROOT: "${{ gitea.workspace }}/.release/verified-candidate"
|
||||||
VULNERABILITY_KEY_ID: ${{ vars.VULNERABILITY_KEY_ID }}
|
VULNERABILITY_REPORT_PATH: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json"
|
||||||
PROVENANCE_PUBLIC_KEY_PATH: ${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}
|
PROVENANCE_ATTESTATION_PATH: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json"
|
||||||
PROVENANCE_KEY_ID: ${{ vars.PROVENANCE_KEY_ID }}
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
- uses: actions/setup-node@v4
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Frozen install
|
- name: Frozen install
|
||||||
run: |
|
run: |
|
||||||
corepack enable
|
corepack enable
|
||||||
corepack pnpm install --frozen-lockfile
|
corepack pnpm install --frozen-lockfile
|
||||||
- name: Download immutable candidate archive
|
- name: Download release candidate
|
||||||
uses: actions/download-artifact@v4
|
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||||
with:
|
with:
|
||||||
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
path: .release/candidate
|
path: .release/candidate
|
||||||
- name: Download vulnerability provider evidence
|
- name: Download vulnerability provider evidence
|
||||||
uses: actions/download-artifact@v4
|
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||||
with:
|
with:
|
||||||
name: vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
path: .release/vulnerability
|
path: .release/vulnerability
|
||||||
- name: Download provenance provider evidence
|
- name: Download provenance provider evidence
|
||||||
uses: actions/download-artifact@v4
|
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||||
with:
|
with:
|
||||||
name: provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
path: .release/provenance
|
path: .release/provenance
|
||||||
- name: Extract unchanged candidate
|
- name: Verify and extract the candidate through one inode-bound operation
|
||||||
run: tar -xzf ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
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: Verify provider signatures and candidate digests
|
- name: Finalize verified promotion from inode-bound captured inputs
|
||||||
run: |
|
run: node scripts/stage-verified-promotion.ts
|
||||||
corepack pnpm verify:provider-evidence
|
- name: Upload promoted release
|
||||||
corepack pnpm verify:promotion
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||||
- name: Upload promoted unchanged candidate
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
name: "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
path: |
|
path: |
|
||||||
.release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz
|
.release/promoted-staging/release-candidate.tar.gz
|
||||||
.release/vulnerability/vulnerability-report.json
|
.release/promoted-staging/vulnerability-report.json
|
||||||
.release/provenance/provenance-attestation.json
|
.release/promoted-staging/provenance-attestation.json
|
||||||
artifacts/security/provider-verification.json
|
.release/promoted-staging/provider-verification.json
|
||||||
artifacts/security/promotion-verification.json
|
.release/promoted-staging/promotion-verification.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
production_gate:
|
production_gate:
|
||||||
name: ${{ matrix.gate }} / ${{ matrix.name }}
|
name: "${{ matrix.gate }} / ${{ matrix.name }}"
|
||||||
needs: promotion
|
needs: promotion
|
||||||
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
|
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -294,8 +327,10 @@ jobs:
|
|||||||
- { gate: FE-GATE-024, name: runbook-telemetry }
|
- { gate: FE-GATE-024, name: runbook-telemetry }
|
||||||
- { gate: FE-GATE-025, name: runbook-release-rollback }
|
- { gate: FE-GATE-025, name: runbook-release-rollback }
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
- uses: actions/setup-node@v4
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Frozen install
|
- name: Frozen install
|
||||||
@@ -304,26 +339,28 @@ jobs:
|
|||||||
corepack pnpm install --frozen-lockfile
|
corepack pnpm install --frozen-lockfile
|
||||||
- name: Run blocking gate
|
- name: Run blocking gate
|
||||||
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
|
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
|
||||||
- name: Upload gate evidence
|
- name: Upload production gate evidence
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.gate }}-${{ gitea.run_id }}
|
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
|
||||||
path: artifacts/
|
path: artifacts/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
field_gate:
|
field_gate:
|
||||||
name: FE-GATE-018 / field-web-vitals
|
name: "FE-GATE-018 / field-web-vitals"
|
||||||
needs: production_gate
|
needs: production_gate
|
||||||
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}
|
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
FIELD_WEB_VITALS_INPUT: ${{ vars.FIELD_WEB_VITALS_INPUT }}
|
FIELD_WEB_VITALS_INPUT: "${{ vars.FIELD_WEB_VITALS_INPUT }}"
|
||||||
MIN_ELIGIBLE_SAMPLES: ${{ vars.MIN_ELIGIBLE_SAMPLES }}
|
MIN_ELIGIBLE_SAMPLES: "${{ vars.MIN_ELIGIBLE_SAMPLES }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
- uses: actions/setup-node@v4
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Frozen install
|
- name: Frozen install
|
||||||
@@ -332,22 +369,24 @@ jobs:
|
|||||||
corepack pnpm install --frozen-lockfile
|
corepack pnpm install --frozen-lockfile
|
||||||
- name: Run blocking gate
|
- name: Run blocking gate
|
||||||
run: corepack pnpm ci:gate -- FE-GATE-018
|
run: corepack pnpm ci:gate -- FE-GATE-018
|
||||||
- name: Upload gate evidence
|
- name: Upload field gate evidence
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||||
with:
|
with:
|
||||||
name: FE-GATE-018-${{ gitea.run_id }}
|
name: "FE-GATE-018-${{ gitea.run_id }}"
|
||||||
path: artifacts/
|
path: artifacts/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
documentation_gate:
|
documentation_gate:
|
||||||
name: FE-GATE-017 / diagram-review
|
name: "FE-GATE-017 / diagram-review"
|
||||||
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}
|
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
- uses: actions/setup-node@v4
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Frozen install
|
- name: Frozen install
|
||||||
@@ -356,10 +395,10 @@ jobs:
|
|||||||
corepack pnpm install --frozen-lockfile
|
corepack pnpm install --frozen-lockfile
|
||||||
- name: Run documentation gate
|
- name: Run documentation gate
|
||||||
run: corepack pnpm ci:gate -- FE-GATE-017
|
run: corepack pnpm ci:gate -- FE-GATE-017
|
||||||
- name: Upload gate evidence
|
- name: Upload documentation gate evidence
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||||
with:
|
with:
|
||||||
name: FE-GATE-017-${{ gitea.run_id }}
|
name: "FE-GATE-017-${{ gitea.run_id }}"
|
||||||
path: artifacts/
|
path: artifacts/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
+2189
-414
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
|||||||
# CI quality-gate orchestration
|
# 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
|
adapter runs each gate as an independent matrix check with full fan-out and no
|
||||||
soft-fail wiring.
|
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를 한
|
`pnpm-lock.yaml`, build/module inventory와 local release/security evidence를 한
|
||||||
번 생성해 run ID/attempt로 구분된 archive로 게시한다. Raw lock bytes의 SHA-256은
|
번 생성해 run ID/attempt로 구분된 archive로 게시한다. Raw lock bytes의 SHA-256은
|
||||||
dependency inventory와 candidate manifest 양쪽의 `lockfileSha256`과 같아야 한다.
|
dependency inventory와 candidate manifest 양쪽의 `lockfileSha256`과 같아야 한다.
|
||||||
Vulnerability/provenance provider job은 같은 archive를 각각 내려받고
|
Producer는 게시 전에 manifest가 선언한 exact member set과 각 byte digest를
|
||||||
`CANDIDATE_LOCKFILE_PATH`, `CANDIDATE_DIST_SHA256`를 외부 command에 전달한다.
|
검증하고 archive SHA-256을 job output으로 고정한다. Vulnerability/provenance
|
||||||
Promotion은 같은 archive와 두 report를 별도 경로로 내려받은 뒤 local evidence를
|
provider job은 같은 archive를 각각 내려받아 output SHA-256과 exact member set을
|
||||||
read-only로 다시 계산하고 Ed25519 signature/digest를 확인한다. Promotion job에는
|
확인한 뒤 checkout과 분리된 root에만 추출하고, `CANDIDATE_LOCKFILE_PATH`와
|
||||||
build/rebuild command가 없으며 검증한 archive 자체를 그대로 승격한다.
|
`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 이상이다. 이
|
Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. 이
|
||||||
workflow를 required check로 전환하기 전에 staging instance에서
|
workflow의 provider job은 Linux runner에서 실행 권한이 있는
|
||||||
`permissions`, `gitea.run_attempt`, `actions/upload-artifact@v4`를 포함한 한
|
`/usr/bin/bwrap`를 필수로 요구하며, 사용할 Node 실행 파일도 sandbox 안의
|
||||||
번의 전체 provider smoke를 통과시켜야 한다. 모든 setup step은
|
`/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
|
`node-version-file: .nvmrc`를 사용하므로 CI Node.js 버전은 `.nvmrc`의 exact
|
||||||
pin과 같다. CI contract는 `.nvmrc`가 full semantic version인지, 모든 job이 이
|
pin과 같다. CI contract는 `.nvmrc`가 full semantic version인지, 모든 job이 이
|
||||||
파일을 사용하는지 함께 검사한다. `ubuntu-latest` runner label은
|
파일을 사용하는지 함께 검사한다. `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
|
promotion remains unavailable with `FAIL_UNVERIFIED`; there is no local
|
||||||
generator/restore fallback.
|
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
|
Branch protection must mark each `FE-GATE-* / <name>` check required for its
|
||||||
declared tier. This repository cannot configure server-side protection by
|
declared tier. This repository cannot configure server-side protection by
|
||||||
committing a file.
|
committing a 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,
|
`.vite` files), the build manifest, module inventory, release verification,
|
||||||
secret-scan result, and local supply-chain evidence once. The candidate
|
secret-scan result, and local supply-chain evidence once. The candidate
|
||||||
manifest hashes the raw lockfile bytes and requires that digest to equal the
|
manifest hashes the raw lockfile bytes and requires that digest to equal the
|
||||||
dependency inventory's `lockfileSha256`. The two provider jobs download this
|
dependency inventory's `lockfileSha256`. Before upload, the producer validates
|
||||||
same archive separately and receive `CANDIDATE_LOCKFILE_PATH` and
|
the manifest-bound exact archive member set and every member digest, then
|
||||||
`CANDIDATE_DIST_SHA256`; configured `VULNERABILITY_PROVIDER_COMMAND` and
|
publishes the archive SHA-256 as an immutable job output. The two provider jobs
|
||||||
`PROVENANCE_PROVIDER_COMMAND` must emit the signed reports.
|
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
|
If either provider input is absent, local verification remains meaningful but
|
||||||
`artifacts/security/supply-chain-verification.json` records
|
`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,
|
set and digests, then read-only revalidates the archived executable schemas,
|
||||||
raw lockfile, module inventory, build outputs, release coherence, SBOM,
|
raw lockfile, module inventory, build outputs, release coherence, SBOM,
|
||||||
provenance, security scan and supply-chain coherence. It never rebuilds or
|
provenance, security scan and supply-chain coherence. It never rebuilds or
|
||||||
rewrites candidate evidence. Scanner or signing outages are not converted to
|
rewrites candidate evidence. Promotion uploads the already verified archive
|
||||||
an empty PASS.
|
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,
|
Approved vulnerability exceptions require vulnerability/package identity,
|
||||||
owner, a different reviewer, reason and expiry. Expired or self-approved
|
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.
|
- [ ] 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"`.
|
- [ ] 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
|
### Task 4: One authoritative architecture graph
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|||||||
+3
-1
@@ -111,7 +111,9 @@
|
|||||||
"drill:runbook": "node scripts/drill-runbook.ts",
|
"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",
|
"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",
|
"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",
|
"verify:documentation": "node scripts/verify-documentation-readiness.ts",
|
||||||
"build:app-only": "vite build && node scripts/generate-build-manifest.ts",
|
"build:app-only": "vite build && node scripts/generate-build-manifest.ts",
|
||||||
"generate:contract-set": "node scripts/generate-contract-set.ts",
|
"generate:contract-set": "node scripts/generate-contract-set.ts",
|
||||||
|
|||||||
+81
-329
@@ -1,217 +1,42 @@
|
|||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
evaluatePromotionReadiness,
|
evaluatePromotionReadiness,
|
||||||
PROMOTION_FORMULA,
|
PROMOTION_FORMULA,
|
||||||
type GateResult,
|
type GateResult,
|
||||||
} from "../src/application/policies/promotion-readiness.ts";
|
} 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<{
|
const contract = await loadCiGateContract(process.cwd());
|
||||||
steps?: readonly unknown[];
|
const index = indexCiGateContract(contract);
|
||||||
evidence?: readonly string[];
|
const [packageDocument, nodeVersion] = await Promise.all([
|
||||||
retentionClass?: string;
|
readFile("package.json", "utf8").then((value) => JSON.parse(value) as { scripts?: Record<string, string> }),
|
||||||
}>;
|
readFile(".nvmrc", "utf8").then((value) => value.trim()),
|
||||||
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 packageScripts = packageDocument.scripts ?? {};
|
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[] = [];
|
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");
|
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 [
|
for (const script of [
|
||||||
"build:release-candidate",
|
"build:release-candidate",
|
||||||
"verify:local-evidence",
|
"verify:local-evidence",
|
||||||
"verify:provider-evidence",
|
"verify:provider-evidence",
|
||||||
"verify:promotion",
|
"verify:promotion",
|
||||||
|
"generate:ci-workflow",
|
||||||
|
"check:ci-workflow",
|
||||||
|
"check:ci",
|
||||||
]) {
|
]) {
|
||||||
if (!packageScripts[script]) failures.push(`package script missing ${script}`);
|
if (!packageScripts[script]) failures.push(`package script missing ${script}`);
|
||||||
}
|
}
|
||||||
@@ -220,160 +45,87 @@ for (const removedScript of [
|
|||||||
"verify:supply-chain",
|
"verify:supply-chain",
|
||||||
"verify:supply-chain:promotion",
|
"verify:supply-chain:promotion",
|
||||||
]) {
|
]) {
|
||||||
if (packageScripts[removedScript]) {
|
if (packageScripts[removedScript]) failures.push(`legacy package script remains ${removedScript}`);
|
||||||
failures.push(`legacy package script remains ${removedScript}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"] ?? "")) {
|
if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"] ?? "")) {
|
||||||
failures.push("verify:promotion must not build or rebuild candidate bytes");
|
failures.push("verify:promotion must not build or rebuild candidate bytes");
|
||||||
}
|
}
|
||||||
const promotionWorkflow = workflow.match(
|
failures.push(...validatePackageScriptGraph(packageScripts, "check:ci"));
|
||||||
/\n {2}promotion:\n(?<body>[\s\S]*?)\n {2}production_gate:/u,
|
|
||||||
)?.groups?.body;
|
const immutable = index.gates.get("FE-GATE-015");
|
||||||
if (!promotionWorkflow) {
|
const immutableCommands = immutable?.commandIds.map((id) => index.commands.get(id)?.script);
|
||||||
failures.push("workflow promotion job is missing or misplaced");
|
if (JSON.stringify(immutableCommands) !== JSON.stringify(["build:release-candidate", "verify:local-evidence"])) {
|
||||||
} else if (
|
failures.push("FE-GATE-015 must build candidate bytes once and verify local evidence only");
|
||||||
/\b(?:build|build:[\w-]+|rebuild)\b/u.test(
|
|
||||||
promotionWorkflow.replaceAll("immutable_build", ""),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
failures.push("workflow promotion job must not build or rebuild candidate bytes");
|
|
||||||
}
|
}
|
||||||
const immutableGateSteps = document.gates["FE-GATE-015"]?.steps;
|
const architecture = index.gates.get("FE-GATE-010");
|
||||||
if (
|
if (!architecture?.commandIds.some((id) => index.commands.get(id)?.script === "check:ci")) {
|
||||||
JSON.stringify(immutableGateSteps) !==
|
failures.push("a blocking gate must execute check:ci");
|
||||||
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 [
|
if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.path === "artifacts/quality/ci-contract.json")) {
|
||||||
"ciCheckoutIdentityFailures",
|
failures.push("FE-GATE-010 must publish the typed CI contract report");
|
||||||
"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 passingResults: Record<string, GateResult> = {};
|
const expectedGateIds = Array.from(
|
||||||
for (const gateId of expectedGateIds) passingResults[gateId] = "PASS";
|
{ 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 allPass = evaluatePromotionReadiness(passingResults);
|
||||||
const negativeFixtures: Array<{
|
const negativeFixtures: Array<{
|
||||||
readiness: keyof typeof PROMOTION_FORMULA;
|
readiness: keyof typeof PROMOTION_FORMULA;
|
||||||
failedGate: string;
|
failedGate: string;
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
for (const readiness of Object.keys(PROMOTION_FORMULA) as Array<
|
for (const readiness of Object.keys(PROMOTION_FORMULA) as Array<keyof typeof PROMOTION_FORMULA>) {
|
||||||
keyof typeof PROMOTION_FORMULA
|
const failedGate = PROMOTION_FORMULA[readiness][0];
|
||||||
>) {
|
if (!failedGate) throw new TypeError(`${readiness} has no configured gates`);
|
||||||
const gateIds = PROMOTION_FORMULA[readiness];
|
const evaluated = evaluatePromotionReadiness({ ...passingResults, [failedGate]: "FAIL" });
|
||||||
const failedGate = gateIds[0];
|
const passed = evaluated[readiness] === false;
|
||||||
if (!failedGate) throw new Error(`${readiness} has no configured gates`);
|
|
||||||
const result = evaluatePromotionReadiness({
|
|
||||||
...passingResults,
|
|
||||||
[failedGate]: "FAIL",
|
|
||||||
});
|
|
||||||
const passed = result[readiness] === false;
|
|
||||||
negativeFixtures.push({ readiness, failedGate, passed });
|
negativeFixtures.push({ readiness, failedGate, passed });
|
||||||
if (!passed) failures.push(`${readiness} did not fail closed`);
|
if (!passed) failures.push(`${readiness} did not fail closed`);
|
||||||
}
|
}
|
||||||
if (!Object.values(allPass).every(Boolean)) {
|
if (!Object.values(allPass).every(Boolean)) failures.push("all-PASS formula did not produce every readiness state");
|
||||||
failures.push("all-PASS formula did not produce every readiness state");
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = {
|
const renderedWorkflow = renderCiWorkflow(contract);
|
||||||
schemaVersion: 1,
|
const workflowCheck = await generateCiWorkflow({
|
||||||
generatedAt: new Date().toISOString(),
|
root: process.cwd(),
|
||||||
providerAdapter: document.providerAdapter,
|
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,
|
nodeVersion,
|
||||||
gateCount: configuredGateIds.length,
|
gateCount: contract.gates.length,
|
||||||
noDowngrade: failures.every(
|
commandDefinitionCount: contract.commands.length,
|
||||||
(failure) => !failure.includes("downgrade"),
|
commandReferenceCount: contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0),
|
||||||
),
|
artifactCount: contract.artifacts.length,
|
||||||
durationStatus: document.retention.durationStatus,
|
jobCount: contract.jobs.length,
|
||||||
|
workflowSha256: createHash("sha256").update(checkedWorkflowBytes).digest("hex"),
|
||||||
|
durationStatus: contract.retention.durationStatus,
|
||||||
negativeFixtures,
|
negativeFixtures,
|
||||||
failures,
|
failures,
|
||||||
passed: failures.length === 0,
|
passed: failures.length === 0,
|
||||||
};
|
});
|
||||||
await mkdir("artifacts/quality", { recursive: true });
|
await writeValidatedJsonArtifact({
|
||||||
await writeFile(
|
path: "artifacts/quality/ci-contract.json",
|
||||||
"artifacts/quality/ci-contract.json",
|
schema: ciContractReportSchema,
|
||||||
`${JSON.stringify(report, null, 2)}\n`,
|
value: report,
|
||||||
);
|
});
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
|
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n");
|
process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model 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));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ try {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const actualDefaultVerifier = await verifyPromotionInputs({
|
const actualDefaultVerifier = await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
environment: actualProviderEnvironment,
|
environment: actualProviderEnvironment,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,16 +84,19 @@ try {
|
|||||||
});
|
});
|
||||||
const fixtures = {
|
const fixtures = {
|
||||||
absent: await verifyPromotionInputs({
|
absent: await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: fixtureRoot,
|
repositoryRoot: fixtureRoot,
|
||||||
environment: {},
|
environment: {},
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
}),
|
}),
|
||||||
validImmutable: await verifyPromotionInputs({
|
validImmutable: await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: fixtureRoot,
|
repositoryRoot: fixtureRoot,
|
||||||
environment: validEnvironment,
|
environment: validEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
}),
|
}),
|
||||||
wrongDigest: await verifyPromotionInputs({
|
wrongDigest: await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: fixtureRoot,
|
repositoryRoot: fixtureRoot,
|
||||||
environment: wrongEnvironment,
|
environment: wrongEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
@@ -103,6 +107,7 @@ try {
|
|||||||
};
|
};
|
||||||
await writeFile(path.join(fixtureRoot, "dist/app.js"), "mutated\n");
|
await writeFile(path.join(fixtureRoot, "dist/app.js"), "mutated\n");
|
||||||
fixtures.postAttestationMutation = await verifyPromotionInputs({
|
fixtures.postAttestationMutation = await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: fixtureRoot,
|
repositoryRoot: fixtureRoot,
|
||||||
environment: validEnvironment,
|
environment: validEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
@@ -159,6 +164,7 @@ function absoluteProviderEnvironment(
|
|||||||
): NodeJS.ProcessEnv {
|
): NodeJS.ProcessEnv {
|
||||||
const absolute = { ...environment };
|
const absolute = { ...environment };
|
||||||
for (const key of [
|
for (const key of [
|
||||||
|
"CANDIDATE_ARCHIVE_PATH",
|
||||||
"VULNERABILITY_REPORT_PATH",
|
"VULNERABILITY_REPORT_PATH",
|
||||||
"PROVENANCE_ATTESTATION_PATH",
|
"PROVENANCE_ATTESTATION_PATH",
|
||||||
"VULNERABILITY_PUBLIC_KEY_PATH",
|
"VULNERABILITY_PUBLIC_KEY_PATH",
|
||||||
@@ -204,6 +210,10 @@ async function writeProviderEnvironment(
|
|||||||
provenanceKeys.privateKey,
|
provenanceKeys.privateKey,
|
||||||
);
|
);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(repositoryRoot, directory, "candidate.tar.gz"),
|
||||||
|
"fixture archive\n",
|
||||||
|
),
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(repositoryRoot, directory, "vulnerability.json"),
|
path.join(repositoryRoot, directory, "vulnerability.json"),
|
||||||
`${JSON.stringify(vulnerability)}\n`,
|
`${JSON.stringify(vulnerability)}\n`,
|
||||||
@@ -226,6 +236,10 @@ async function writeProviderEnvironment(
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
return {
|
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`,
|
VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`,
|
||||||
PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`,
|
PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`,
|
||||||
VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`,
|
VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type HttpScenarioReceipt,
|
type HttpScenarioReceipt,
|
||||||
} from "./lib/http-scenario-evidence.ts";
|
} from "./lib/http-scenario-evidence.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
|
import { testEvidenceReportSchema } from "./lib/test-evidence-artifact.ts";
|
||||||
|
|
||||||
const scenarioContributionSchema = z
|
const scenarioContributionSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -40,24 +41,6 @@ const policySchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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 {
|
function argumentValue(name: string, fallback: string): string {
|
||||||
const index = process.argv.indexOf(name);
|
const index = process.argv.indexOf(name);
|
||||||
return index >= 0 && process.argv[index + 1]
|
return index >= 0 && process.argv[index + 1]
|
||||||
@@ -455,7 +438,7 @@ const report = {
|
|||||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||||
await writeValidatedJsonArtifact({
|
await writeValidatedJsonArtifact({
|
||||||
path: artifactPath,
|
path: artifactPath,
|
||||||
schema: reportSchema,
|
schema: testEvidenceReportSchema,
|
||||||
value: report,
|
value: report,
|
||||||
});
|
});
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})`);
|
||||||
|
}
|
||||||
@@ -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")! } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createPublicKey } from "node:crypto";
|
import { createHash, createPublicKey } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
evaluatePromotionEvidence,
|
evaluatePromotionEvidence,
|
||||||
|
type ProviderVerificationArtifactType,
|
||||||
type ProviderTrust,
|
type ProviderTrust,
|
||||||
} from "./provider-evidence.ts";
|
} from "./provider-evidence.ts";
|
||||||
import {
|
import {
|
||||||
@@ -12,20 +12,58 @@ import {
|
|||||||
verifyReleaseCandidate,
|
verifyReleaseCandidate,
|
||||||
} from "./release-candidate.ts";
|
} from "./release-candidate.ts";
|
||||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||||
|
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||||
|
|
||||||
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
|
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
|
||||||
|
|
||||||
export type VerifyPromotionInputsOptions = Readonly<{
|
export type VerifyPromotionInputsOptions = Readonly<{
|
||||||
|
artifactType: ProviderVerificationArtifactType;
|
||||||
environment?: NodeJS.ProcessEnv;
|
environment?: NodeJS.ProcessEnv;
|
||||||
repositoryRoot?: string;
|
repositoryRoot?: string;
|
||||||
|
providerEvidenceRoot?: string;
|
||||||
|
trustRoot?: string;
|
||||||
verifyLocalEvidence?: LocalEvidenceVerifier;
|
verifyLocalEvidence?: LocalEvidenceVerifier;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export async function verifyPromotionInputs(
|
export async function verifyPromotionInputs(
|
||||||
options: VerifyPromotionInputsOptions = {},
|
options: VerifyPromotionInputsOptions,
|
||||||
) {
|
) {
|
||||||
const environment = options.environment ?? process.env;
|
const environment = options.environment ?? process.env;
|
||||||
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
|
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(
|
const manifestDocument = await requiredJson(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
@@ -38,38 +76,34 @@ export async function verifyPromotionInputs(
|
|||||||
const localEvidence = await (
|
const localEvidence = await (
|
||||||
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
|
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
|
||||||
)({ repositoryRoot, candidate: manifest });
|
)({ repositoryRoot, candidate: manifest });
|
||||||
const vulnerabilityReport = await optionalJson(
|
const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes);
|
||||||
repositoryRoot,
|
const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes);
|
||||||
environment.VULNERABILITY_REPORT_PATH,
|
|
||||||
);
|
|
||||||
const provenanceAttestation = await optionalJson(
|
|
||||||
repositoryRoot,
|
|
||||||
environment.PROVENANCE_ATTESTATION_PATH,
|
|
||||||
);
|
|
||||||
const result = evaluatePromotionEvidence({
|
const result = evaluatePromotionEvidence({
|
||||||
candidate: manifest,
|
candidate: manifest,
|
||||||
currentDistSha256: candidate.currentDistSha256 ?? "",
|
currentDistSha256: candidate.currentDistSha256 ?? "",
|
||||||
localStatus: localEvidence.status,
|
localStatus: localEvidence.status,
|
||||||
vulnerabilityReport,
|
vulnerabilityReport,
|
||||||
provenanceAttestation,
|
provenanceAttestation,
|
||||||
vulnerabilityTrust: await readTrust(
|
vulnerabilityTrust: await readProviderTrust(
|
||||||
repositoryRoot,
|
trustRoot,
|
||||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||||
environment.VULNERABILITY_KEY_ID,
|
environment.VULNERABILITY_KEY_ID,
|
||||||
),
|
),
|
||||||
provenanceTrust: await readTrust(
|
provenanceTrust: await readProviderTrust(
|
||||||
repositoryRoot,
|
trustRoot,
|
||||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||||
environment.PROVENANCE_KEY_ID,
|
environment.PROVENANCE_KEY_ID,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const failures = [
|
const failures = [
|
||||||
|
...inputFailures,
|
||||||
...candidate.failures,
|
...candidate.failures,
|
||||||
...localEvidence.failures,
|
...localEvidence.failures,
|
||||||
...result.failures,
|
...result.failures,
|
||||||
];
|
];
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
schemaVersion: 1 as const,
|
schemaVersion: 2 as const,
|
||||||
|
artifactType: options.artifactType,
|
||||||
status:
|
status:
|
||||||
failures.length === 0 && result.status === "PASS"
|
failures.length === 0 && result.status === "PASS"
|
||||||
? ("PASS" as const)
|
? ("PASS" as const)
|
||||||
@@ -78,11 +112,14 @@ export async function verifyPromotionInputs(
|
|||||||
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||||
lockfileSha256: manifest.lockfileSha256,
|
lockfileSha256: manifest.lockfileSha256,
|
||||||
distSha256: manifest.distSha256,
|
distSha256: manifest.distSha256,
|
||||||
|
candidateArchiveSha256: archive.sha256,
|
||||||
|
vulnerabilityReportSha256: vulnerabilityCapture.sha256,
|
||||||
|
provenanceAttestationSha256: provenanceCapture.sha256,
|
||||||
failures: Object.freeze(failures),
|
failures: Object.freeze(failures),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readTrust(
|
export async function readProviderTrust(
|
||||||
repositoryRoot: string,
|
repositoryRoot: string,
|
||||||
publicKeyPath: string | undefined,
|
publicKeyPath: string | undefined,
|
||||||
keyId: string | undefined,
|
keyId: string | undefined,
|
||||||
@@ -92,7 +129,9 @@ async function readTrust(
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
keyId,
|
keyId,
|
||||||
publicKey: createPublicKey(
|
publicKey: createPublicKey(
|
||||||
await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"),
|
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||||
|
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -100,15 +139,35 @@ async function readTrust(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function optionalJson(
|
async function captureOptionalInput(
|
||||||
repositoryRoot: string,
|
root: string,
|
||||||
file: string | undefined,
|
configuredPath: string | undefined,
|
||||||
): Promise<unknown> {
|
maxBytes: number,
|
||||||
if (!file) return null;
|
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 {
|
try {
|
||||||
return JSON.parse(
|
const bytes = await boundedConfiguredFile(root, configuredPath, maxBytes);
|
||||||
await readFile(path.resolve(repositoryRoot, file), "utf8"),
|
return Object.freeze({
|
||||||
) as unknown;
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -119,10 +178,28 @@ async function requiredJson(
|
|||||||
file: string,
|
file: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const value: unknown = JSON.parse(
|
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)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
throw new TypeError(`${file} must be a JSON object`);
|
throw new TypeError(`${file} must be a JSON object`);
|
||||||
}
|
}
|
||||||
return value as Record<string, unknown>;
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,15 +44,58 @@ export const provenanceProviderAttestationSchema = z
|
|||||||
|
|
||||||
export const providerVerificationArtifactSchema = z
|
export const providerVerificationArtifactSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(1),
|
schemaVersion: z.literal(2),
|
||||||
|
artifactType: z.enum(["provider-verification", "promotion-verification"]),
|
||||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||||
lockfileSha256: sha256,
|
lockfileSha256: sha256,
|
||||||
distSha256: sha256,
|
distSha256: sha256,
|
||||||
|
candidateArchiveSha256: sha256.nullable(),
|
||||||
|
vulnerabilityReportSha256: sha256.nullable(),
|
||||||
|
provenanceAttestationSha256: sha256.nullable(),
|
||||||
failures: z.array(z.string()),
|
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<{
|
export type ProviderTrust = Readonly<{
|
||||||
keyId: string;
|
keyId: string;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ export async function createReleaseCandidateManifest(
|
|||||||
sha256,
|
sha256,
|
||||||
})),
|
})),
|
||||||
...evidence,
|
...evidence,
|
||||||
].sort((left, right) => left.path.localeCompare(right.path));
|
].sort((left, right) => asciiCompare(left.path, right.path));
|
||||||
const dependencyInventory = JSON.parse(
|
const dependencyInventory = JSON.parse(
|
||||||
await readFile(
|
await readFile(
|
||||||
path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"),
|
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(
|
export async function verifyReleaseCandidate(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
repositoryRoot = process.cwd(),
|
repositoryRoot = process.cwd(),
|
||||||
@@ -202,7 +206,7 @@ async function regularFilesWithin(directory: string): Promise<string[]> {
|
|||||||
const entries = await readdir(directory, { withFileTypes: true });
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
for (const entry of entries.sort((left, right) =>
|
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);
|
const target = path.join(directory, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
@@ -1,6 +1,4 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ciCheckoutIdentityFailures,
|
ciCheckoutIdentityFailures,
|
||||||
@@ -9,48 +7,45 @@ import {
|
|||||||
isValidSourceDateEpoch,
|
isValidSourceDateEpoch,
|
||||||
} from "./lib/build-environment.ts";
|
} from "./lib/build-environment.ts";
|
||||||
import { classifyGateStepResult } from "./lib/ci-step-result.ts";
|
import { classifyGateStepResult } from "./lib/ci-step-result.ts";
|
||||||
|
import {
|
||||||
type GateStepBase = Readonly<{
|
indexCiGateContract,
|
||||||
script: string;
|
loadCiGateContract,
|
||||||
args?: readonly string[];
|
} from "./contracts/ci-gates.ts";
|
||||||
timeoutMs?: number;
|
import { validateCiArtifact } from "./lib/ci-artifact-validator.ts";
|
||||||
}>;
|
import { writeCiGateLogAtomic } from "./lib/ci-gate-log.ts";
|
||||||
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>>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const gateId = process.argv
|
const gateId = process.argv
|
||||||
.slice(2)
|
.slice(2)
|
||||||
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
|
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
|
||||||
const document = parseGateDocument(
|
const contract = await loadCiGateContract(process.cwd());
|
||||||
JSON.parse(await readFile("config/ci/gates.json", "utf8")),
|
const contractIndex = indexCiGateContract(contract);
|
||||||
);
|
const gate = gateId ? contractIndex.gates.get(gateId) : undefined;
|
||||||
const gate = gateId ? document.gates[gateId] : undefined;
|
|
||||||
if (!gateId || !gate) {
|
if (!gateId || !gate) {
|
||||||
process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n");
|
process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n");
|
||||||
process.exit(2);
|
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[] = [];
|
const output: string[] = [];
|
||||||
|
let outputBytes = 0;
|
||||||
let passed = true;
|
let passed = true;
|
||||||
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000;
|
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||||
const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024;
|
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 };
|
const gateEnvironment = { ...process.env };
|
||||||
if (gateEnvironment.CI === "true") {
|
if (gateEnvironment.CI === "true") {
|
||||||
@@ -68,17 +63,17 @@ if (gateEnvironment.CI === "true") {
|
|||||||
) {
|
) {
|
||||||
if (!gateEnvironment.SOURCE_DATE_EPOCH?.trim()) {
|
if (!gateEnvironment.SOURCE_DATE_EPOCH?.trim()) {
|
||||||
gateEnvironment.SOURCE_DATE_EPOCH = sourceDateEpoch;
|
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, {
|
for (const failure of ciCheckoutIdentityFailures(gateEnvironment, {
|
||||||
commitSha,
|
commitSha,
|
||||||
sourceDateEpoch,
|
sourceDateEpoch,
|
||||||
})) {
|
})) {
|
||||||
output.push(failure);
|
appendOutput(failure);
|
||||||
passed = false;
|
passed = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
output.push(
|
appendOutput(
|
||||||
"unable to resolve the checked-out commit identity and timestamp",
|
"unable to resolve the checked-out commit identity and timestamp",
|
||||||
commitMetadata.stderr,
|
commitMetadata.stderr,
|
||||||
);
|
);
|
||||||
@@ -87,19 +82,27 @@ if (gateEnvironment.CI === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const failure of ciBuildEnvironmentFailures(gateEnvironment)) {
|
for (const failure of ciBuildEnvironmentFailures(gateEnvironment)) {
|
||||||
output.push(failure);
|
appendOutput(failure);
|
||||||
passed = false;
|
passed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const variable of gate.requiresEnvironment ?? []) {
|
for (const variable of gate.requiresEnvironment ?? []) {
|
||||||
if (!gateEnvironment[variable]) {
|
if (!gateEnvironment[variable]) {
|
||||||
output.push(`missing required environment: ${variable}`);
|
appendOutput(`missing required environment: ${variable}`);
|
||||||
passed = false;
|
passed = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passed) {
|
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(
|
const result = spawnSync(
|
||||||
"corepack",
|
"corepack",
|
||||||
["pnpm", step.script, ...(step.args ?? [])],
|
["pnpm", step.script, ...(step.args ?? [])],
|
||||||
@@ -107,24 +110,30 @@ if (passed) {
|
|||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
env: gateEnvironment,
|
env: gateEnvironment,
|
||||||
timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS,
|
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 stdout = result.stdout ?? "";
|
||||||
const stderr = result.stderr ?? "";
|
const stderr = result.stderr ?? "";
|
||||||
output.push(
|
if (!appendOutput(stdout, stderr)) {
|
||||||
`$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(),
|
appendOutput("gate aggregate output exceeded the bounded log schema");
|
||||||
stdout,
|
passed = false;
|
||||||
stderr,
|
break;
|
||||||
);
|
}
|
||||||
const expectation =
|
const expectation = step.expect === "pass"
|
||||||
step.expect === "pass"
|
|
||||||
? ({ kind: "pass" } as const)
|
? ({ kind: "pass" } as const)
|
||||||
: ({
|
: step.expectedExitCode !== undefined && step.expectedDiagnosticId !== undefined
|
||||||
|
? ({
|
||||||
kind: "fail",
|
kind: "fail",
|
||||||
expectedExitCode: step.expectedExitCode,
|
expectedExitCode: step.expectedExitCode,
|
||||||
expectedDiagnosticId: step.expectedDiagnosticId,
|
expectedDiagnosticId: step.expectedDiagnosticId,
|
||||||
} as const);
|
} as const)
|
||||||
|
: (() => {
|
||||||
|
throw new TypeError(`negative command lost its validated identity: ${step.id}`);
|
||||||
|
})();
|
||||||
const classification = classifyGateStepResult(expectation, {
|
const classification = classifyGateStepResult(expectation, {
|
||||||
status: result.status,
|
status: result.status,
|
||||||
signal: result.signal,
|
signal: result.signal,
|
||||||
@@ -134,9 +143,9 @@ if (passed) {
|
|||||||
? { error: { code: (result.error as NodeJS.ErrnoException).code } }
|
? { error: { code: (result.error as NodeJS.ErrnoException).code } }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
output.push(`classification: ${classification.kind}`);
|
if (!appendOutput(`classification: ${classification.kind}`)) passed = false;
|
||||||
if (!classification.expectationMet) {
|
if (!classification.expectationMet) {
|
||||||
output.push(
|
appendOutput(
|
||||||
`expectation failed: expected ${step.expect}, exit=${result.status}, signal=${result.signal ?? "none"}`,
|
`expectation failed: expected ${step.expect}, exit=${result.status}, signal=${result.signal ?? "none"}`,
|
||||||
...(step.expect === "fail"
|
...(step.expect === "fail"
|
||||||
? [
|
? [
|
||||||
@@ -153,20 +162,37 @@ if (passed) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(path.dirname(gate.logPath), { recursive: true });
|
await writeCiGateLogAtomic({
|
||||||
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
|
root: process.cwd(),
|
||||||
|
relativePath: logArtifact.path,
|
||||||
|
content: `${output.filter(Boolean).join("\n")}\n`,
|
||||||
|
maxBytes: logSchema.maxBytes,
|
||||||
|
});
|
||||||
|
|
||||||
if (passed) {
|
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 {
|
try {
|
||||||
await access(evidencePath);
|
await validateCiArtifact({ root: process.cwd(), artifact, schema });
|
||||||
} catch {
|
if (!appendOutput(`validated evidence: ${artifact.path} (${schema.id})`)) passed = false;
|
||||||
output.push(`missing evidence: ${evidencePath}`);
|
} catch (error) {
|
||||||
|
appendOutput(
|
||||||
|
`invalid evidence: ${artifact.path}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
passed = false;
|
passed = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!passed) {
|
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.exit(1);
|
||||||
}
|
}
|
||||||
process.stdout.write(
|
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));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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";
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { parseCiGateContract } from "./contracts/ci-gates.ts";
|
||||||
|
import { generateCiWorkflow } from "./generate-ci-workflow.ts";
|
||||||
|
|
||||||
const fixtureRoot = path.resolve(
|
const fixtureRoot = path.resolve(
|
||||||
".tmp/browser-file-storage-runtime-removal",
|
".tmp/browser-file-storage-runtime-removal",
|
||||||
);
|
);
|
||||||
@@ -29,6 +32,16 @@ const runtimePaths = [
|
|||||||
const runtimeSourceRoots = runtimePaths.filter((entry) =>
|
const runtimeSourceRoots = runtimePaths.filter((entry) =>
|
||||||
entry.startsWith("src/"),
|
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 = [
|
const copyTargets = [
|
||||||
"src",
|
"src",
|
||||||
"tests",
|
"tests",
|
||||||
@@ -281,12 +294,7 @@ const packagePath = path.join(fixtureRoot, "package.json");
|
|||||||
const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as {
|
const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as {
|
||||||
scripts: Record<string, string>;
|
scripts: Record<string, string>;
|
||||||
};
|
};
|
||||||
for (const script of [
|
for (const script of removedScripts) {
|
||||||
"test:browser-capabilities",
|
|
||||||
"verify:browser-capability-evidence",
|
|
||||||
"check:browser-file-storage-boundaries",
|
|
||||||
"test:browser-file-storage-removal",
|
|
||||||
]) {
|
|
||||||
delete packageDocument.scripts[script];
|
delete packageDocument.scripts[script];
|
||||||
}
|
}
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -308,39 +316,80 @@ await rm(
|
|||||||
path.join(fixtureRoot, "scripts/test-browser-file-storage-runtime-removal.ts"),
|
path.join(fixtureRoot, "scripts/test-browser-file-storage-runtime-removal.ts"),
|
||||||
{ force: true },
|
{ 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 gatesPath = path.join(fixtureRoot, "config/ci/gates.json");
|
||||||
const gatesDocument = JSON.parse(
|
const gatesDocument = structuredClone(
|
||||||
await readFile(gatesPath, "utf8"),
|
parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))),
|
||||||
) as {
|
);
|
||||||
gates: Record<
|
const removedCommandIds = new Set(
|
||||||
string,
|
gatesDocument.commands
|
||||||
{
|
.filter(({ script }) => removedScripts.has(script))
|
||||||
steps: Array<{ script: string }>;
|
.map(({ id }) => id),
|
||||||
evidence: string[];
|
);
|
||||||
|
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(
|
||||||
for (const gate of Object.values(gatesDocument.gates)) {
|
({ id }) => !removedCommandIds.has(id),
|
||||||
gate.steps = gate.steps.filter(
|
);
|
||||||
({ script }) =>
|
gatesDocument.artifacts = gatesDocument.artifacts.filter(
|
||||||
![
|
({ id }) => !removedArtifactIds.has(id),
|
||||||
"test:browser-capabilities",
|
);
|
||||||
"verify:browser-capability-evidence",
|
for (const gate of gatesDocument.gates) {
|
||||||
"check:browser-file-storage-boundaries",
|
gate.commandIds = gate.commandIds.filter(
|
||||||
"test:browser-file-storage-removal",
|
(commandId) => !removedCommandIds.has(commandId),
|
||||||
].includes(script),
|
|
||||||
);
|
);
|
||||||
gate.evidence = gate.evidence.filter(
|
gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter(
|
||||||
(evidence) =>
|
(artifactId) => !removedArtifactIds.has(artifactId),
|
||||||
!evidence.includes("browser-capabilities") &&
|
|
||||||
!evidence.includes("browser-file-storage-runtime-removal"),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const referencedSchemaIds = new Set(
|
||||||
|
gatesDocument.artifacts.map(({ schemaId }) => schemaId),
|
||||||
|
);
|
||||||
|
gatesDocument.artifactSchemas = gatesDocument.artifactSchemas.filter(
|
||||||
|
({ id }) => referencedSchemaIds.has(id),
|
||||||
|
);
|
||||||
|
const validatedGates = parseCiGateContract(gatesDocument);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
gatesPath,
|
gatesPath,
|
||||||
`${JSON.stringify(gatesDocument, null, 2)}\n`,
|
`${JSON.stringify(validatedGates, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
|
await generateCiWorkflow({
|
||||||
|
root: fixtureRoot,
|
||||||
|
contract: validatedGates,
|
||||||
|
check: false,
|
||||||
|
});
|
||||||
await assertNoRuntimeImports(fixtureRoot);
|
await assertNoRuntimeImports(fixtureRoot);
|
||||||
|
|
||||||
const checks: Array<readonly [string, boolean]> = [
|
const checks: Array<readonly [string, boolean]> = [
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ const copyTargets = [
|
|||||||
"tests",
|
"tests",
|
||||||
"recipes",
|
"recipes",
|
||||||
"scripts",
|
"scripts",
|
||||||
|
"schemas",
|
||||||
"config",
|
"config",
|
||||||
"public",
|
"public",
|
||||||
|
".gitea",
|
||||||
".storybook",
|
".storybook",
|
||||||
"index.html",
|
"index.html",
|
||||||
"package.json",
|
"package.json",
|
||||||
@@ -39,6 +41,7 @@ const copyTargets = [
|
|||||||
"playwright.visual.config.ts",
|
"playwright.visual.config.ts",
|
||||||
"eslint.config.ts",
|
"eslint.config.ts",
|
||||||
".dependency-cruiser.json",
|
".dependency-cruiser.json",
|
||||||
|
".nvmrc",
|
||||||
];
|
];
|
||||||
|
|
||||||
function requireEnvironment(name: string): string {
|
function requireEnvironment(name: string): string {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 fixtureRoot = path.resolve(".tmp/realtime-runtime-removal");
|
||||||
const pnpmCli = requireEnvironment("npm_execpath");
|
const pnpmCli = requireEnvironment("npm_execpath");
|
||||||
const runtimePaths = [
|
const runtimePaths = [
|
||||||
@@ -31,6 +34,10 @@ const runtimeScripts = [
|
|||||||
"check:realtime-boundaries:fixture",
|
"check:realtime-boundaries:fixture",
|
||||||
"test:realtime-removal",
|
"test:realtime-removal",
|
||||||
] as const;
|
] as const;
|
||||||
|
const removedEvidencePathFragments = [
|
||||||
|
"realtime-boundaries",
|
||||||
|
"realtime-runtime-removal",
|
||||||
|
] as const;
|
||||||
const copyTargets = [
|
const copyTargets = [
|
||||||
"src",
|
"src",
|
||||||
"tests",
|
"tests",
|
||||||
@@ -292,32 +299,81 @@ for (const scriptPath of [
|
|||||||
]) {
|
]) {
|
||||||
await rm(path.join(fixtureRoot, scriptPath), { force: true });
|
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 gatesPath = path.join(fixtureRoot, "config/ci/gates.json");
|
||||||
const gatesDocument = JSON.parse(await readFile(gatesPath, "utf8")) as {
|
const gatesDocument = structuredClone(
|
||||||
gates: Record<
|
parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))),
|
||||||
string,
|
);
|
||||||
{
|
const removedScripts = new Set<string>(runtimeScripts);
|
||||||
steps: Array<{ script: string }>;
|
const removedCommandIds = new Set(
|
||||||
evidence: string[];
|
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(
|
||||||
for (const gate of Object.values(gatesDocument.gates)) {
|
({ id }) => !removedCommandIds.has(id),
|
||||||
gate.steps = gate.steps.filter(
|
);
|
||||||
({ script }) =>
|
gatesDocument.artifacts = gatesDocument.artifacts.filter(
|
||||||
!runtimeScripts.some((runtimeScript) => runtimeScript === script),
|
({ id }) => !removedArtifactIds.has(id),
|
||||||
|
);
|
||||||
|
for (const gate of gatesDocument.gates) {
|
||||||
|
gate.commandIds = gate.commandIds.filter(
|
||||||
|
(commandId) => !removedCommandIds.has(commandId),
|
||||||
);
|
);
|
||||||
gate.evidence = gate.evidence.filter(
|
gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter(
|
||||||
(evidence) =>
|
(artifactId) => !removedArtifactIds.has(artifactId),
|
||||||
!evidence.includes("realtime-boundaries") &&
|
|
||||||
!evidence.includes("realtime-runtime-removal"),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const referencedSchemaIds = new Set(
|
||||||
|
gatesDocument.artifacts.map(({ schemaId }) => schemaId),
|
||||||
|
);
|
||||||
|
gatesDocument.artifactSchemas = gatesDocument.artifactSchemas.filter(
|
||||||
|
({ id }) => referencedSchemaIds.has(id),
|
||||||
|
);
|
||||||
|
const validatedGates = parseCiGateContract(gatesDocument);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
gatesPath,
|
gatesPath,
|
||||||
`${JSON.stringify(gatesDocument, null, 2)}\n`,
|
`${JSON.stringify(validatedGates, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
|
await generateCiWorkflow({
|
||||||
|
root: fixtureRoot,
|
||||||
|
contract: validatedGates,
|
||||||
|
check: false,
|
||||||
|
});
|
||||||
await assertNoRuntimeImports(fixtureRoot);
|
await assertNoRuntimeImports(fixtureRoot);
|
||||||
|
|
||||||
const checks: Array<readonly [string, boolean]> = [
|
const checks: Array<readonly [string, boolean]> = [
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const copyTargets = [
|
|||||||
"schemas",
|
"schemas",
|
||||||
"config",
|
"config",
|
||||||
"public",
|
"public",
|
||||||
|
".gitea",
|
||||||
".storybook",
|
".storybook",
|
||||||
"index.html",
|
"index.html",
|
||||||
"package.json",
|
"package.json",
|
||||||
@@ -65,6 +66,7 @@ const copyTargets = [
|
|||||||
"playwright.visual.config.ts",
|
"playwright.visual.config.ts",
|
||||||
"eslint.config.ts",
|
"eslint.config.ts",
|
||||||
".dependency-cruiser.json",
|
".dependency-cruiser.json",
|
||||||
|
".nvmrc",
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
|
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { appendFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { verifyCiCandidateArchive } from "./lib/ci-candidate-archive.ts";
|
||||||
|
import {
|
||||||
|
CANDIDATE_ARCHIVE_USAGE,
|
||||||
|
parseCandidateArchiveArguments,
|
||||||
|
} from "./lib/ci-candidate-archive-cli.ts";
|
||||||
|
|
||||||
|
const parsedArguments = parseCandidateArchiveArguments(process.argv.slice(2));
|
||||||
|
if (!parsedArguments) {
|
||||||
|
process.stderr.write(CANDIDATE_ARCHIVE_USAGE);
|
||||||
|
process.exitCode = 2;
|
||||||
|
} else try {
|
||||||
|
const result = await verifyCiCandidateArchive({
|
||||||
|
archivePath: parsedArguments.archivePath,
|
||||||
|
repositoryRoot: process.cwd(),
|
||||||
|
...(process.env.CANDIDATE_ARCHIVE_SHA256
|
||||||
|
? { expectedSha256: process.env.CANDIDATE_ARCHIVE_SHA256 }
|
||||||
|
: {}),
|
||||||
|
...(parsedArguments.extractTo ? { extractTo: parsedArguments.extractTo } : {}),
|
||||||
|
});
|
||||||
|
if (parsedArguments.githubOutput) {
|
||||||
|
await appendFile(
|
||||||
|
parsedArguments.githubOutput,
|
||||||
|
`archive_sha256=${result.archiveSha256}\ndist_sha256=${result.manifest.distSha256}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
process.stdout.write(
|
||||||
|
`Candidate archive: ${result.memberCount} members, sha256=${result.archiveSha256} PASS\n`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Candidate archive failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
||||||
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.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 mkdir("artifacts/security", { recursive: true });
|
||||||
await writeValidatedJsonArtifact({
|
await writeValidatedJsonArtifact({
|
||||||
path: "artifacts/security/provider-verification.json",
|
path: "artifacts/security/provider-verification.json",
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
||||||
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.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 mkdir("artifacts/security", { recursive: true });
|
||||||
await writeValidatedJsonArtifact({
|
await writeValidatedJsonArtifact({
|
||||||
path: "artifacts/security/promotion-verification.json",
|
path: "artifacts/security/promotion-verification.json",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"providerAdapter": ".gitea/workflows/quality-gates.yml",
|
||||||
|
"commands": [],
|
||||||
|
"artifactSchemas": [{ "id": "text", "kind": "text", "maxBytes": 1024 }],
|
||||||
|
"artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "text" }],
|
||||||
|
"gates": [
|
||||||
|
{ "id": "FE-GATE-001", "name": "one", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" },
|
||||||
|
{ "id": "FE-GATE-001", "name": "duplicate", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" }
|
||||||
|
],
|
||||||
|
"stages": [],
|
||||||
|
"jobs": [],
|
||||||
|
"retention": { "durationStatus": "UNSUPPORTED", "classes": [{ "id": "merge", "policy": "one cycle" }] }
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"jobId": "merge_gate",
|
||||||
|
"needs": ["release_gate"]
|
||||||
|
}
|
||||||
@@ -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": [] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"jobId": "release_gate",
|
||||||
|
"addGateId": "FE-GATE-001"
|
||||||
|
}
|
||||||
+12
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check.
|
|||||||
import {
|
import {
|
||||||
evaluatePromotionEvidence,
|
evaluatePromotionEvidence,
|
||||||
providerEvidenceSignaturePayload,
|
providerEvidenceSignaturePayload,
|
||||||
|
providerVerificationArtifactSchema,
|
||||||
} from "../../scripts/lib/provider-evidence.ts";
|
} from "../../scripts/lib/provider-evidence.ts";
|
||||||
import {
|
import {
|
||||||
createReleaseCandidateManifest,
|
createReleaseCandidateManifest,
|
||||||
@@ -31,6 +32,10 @@ import {
|
|||||||
} from "../../scripts/lib/release-candidate.ts";
|
} from "../../scripts/lib/release-candidate.ts";
|
||||||
import { deterministicSupplyChainGeneratedAt } from "../../scripts/lib/supply-chain-time.ts";
|
import { deterministicSupplyChainGeneratedAt } from "../../scripts/lib/supply-chain-time.ts";
|
||||||
import { verifyPromotionInputs } from "../../scripts/lib/promotion-verifier.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 integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
|
||||||
const dependency = {
|
const dependency = {
|
||||||
@@ -125,6 +130,7 @@ async function writeProviderEnvironment(
|
|||||||
);
|
);
|
||||||
await mkdir(path.join(root, "provider"), { recursive: true });
|
await mkdir(path.join(root, "provider"), { recursive: true });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
writeFile(path.join(root, "provider/candidate.tar.gz"), "fixture archive\n"),
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(root, "provider/vulnerability.json"),
|
path.join(root, "provider/vulnerability.json"),
|
||||||
`${JSON.stringify(vulnerabilityReport)}\n`,
|
`${JSON.stringify(vulnerabilityReport)}\n`,
|
||||||
@@ -147,6 +153,10 @@ async function writeProviderEnvironment(
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
return {
|
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",
|
VULNERABILITY_REPORT_PATH: "provider/vulnerability.json",
|
||||||
PROVENANCE_ATTESTATION_PATH: "provider/provenance.json",
|
PROVENANCE_ATTESTATION_PATH: "provider/provenance.json",
|
||||||
VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem",
|
VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem",
|
||||||
@@ -157,6 +167,39 @@ async function writeProviderEnvironment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("supply-chain policy", () => {
|
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 () => {
|
it("wires candidate files, PEM trust, env report paths, and mutation checks", async () => {
|
||||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-"));
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-"));
|
||||||
try {
|
try {
|
||||||
@@ -171,11 +214,13 @@ describe("supply-chain policy", () => {
|
|||||||
failures: [] as const,
|
failures: [] as const,
|
||||||
});
|
});
|
||||||
const valid = await verifyPromotionInputs({
|
const valid = await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment: validEnvironment,
|
environment: validEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
});
|
});
|
||||||
const absent = await verifyPromotionInputs({
|
const absent = await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment: {},
|
environment: {},
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
@@ -186,12 +231,14 @@ describe("supply-chain policy", () => {
|
|||||||
manifest.lockfileSha256,
|
manifest.lockfileSha256,
|
||||||
);
|
);
|
||||||
const wrongDigest = await verifyPromotionInputs({
|
const wrongDigest = await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment: wrongEnvironment,
|
environment: wrongEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
});
|
});
|
||||||
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
|
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
|
||||||
const postAttestationMutation = await verifyPromotionInputs({
|
const postAttestationMutation = await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment: validEnvironment,
|
environment: validEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
@@ -228,6 +275,7 @@ describe("supply-chain policy", () => {
|
|||||||
);
|
);
|
||||||
const before = await readFile(localVerificationPath, "utf8");
|
const before = await readFile(localVerificationPath, "utf8");
|
||||||
const result = await verifyPromotionInputs({
|
const result = await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment,
|
environment,
|
||||||
});
|
});
|
||||||
@@ -656,20 +704,28 @@ describe("supply-chain policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("wires the exact security fixture checker as a passing CI gate", async () => {
|
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 {
|
const contract = await loadCiGateContract(process.cwd());
|
||||||
gates: Record<string, { steps: unknown[]; evidence: string[] }>;
|
const index = indexCiGateContract(contract);
|
||||||
};
|
const securityGate = index.gates.get("FE-GATE-013");
|
||||||
const securityGate = gates.gates["FE-GATE-013"]!;
|
expect(securityGate).toBeDefined();
|
||||||
expect(securityGate.steps).toContainEqual({
|
const commands = securityGate!.commandIds.map((commandId) =>
|
||||||
|
index.commands.get(commandId),
|
||||||
|
);
|
||||||
|
expect(commands).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
script: "check:security:fixtures",
|
script: "check:security:fixtures",
|
||||||
expect: "pass",
|
expect: "pass",
|
||||||
});
|
}),
|
||||||
expect(securityGate.steps).not.toEqual(
|
);
|
||||||
|
expect(commands).not.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ script: "scan:security:fixture" }),
|
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",
|
"artifacts/security/scan-fixture.sarif",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user