refactor: generate CI workflow from gate contracts
This commit is contained in:
@@ -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 {
|
||||
evaluatePromotionEvidence,
|
||||
providerEvidenceSignaturePayload,
|
||||
providerVerificationArtifactSchema,
|
||||
} from "../../scripts/lib/provider-evidence.ts";
|
||||
import {
|
||||
createReleaseCandidateManifest,
|
||||
@@ -31,6 +32,10 @@ import {
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
import { deterministicSupplyChainGeneratedAt } from "../../scripts/lib/supply-chain-time.ts";
|
||||
import { verifyPromotionInputs } from "../../scripts/lib/promotion-verifier.ts";
|
||||
import {
|
||||
indexCiGateContract,
|
||||
loadCiGateContract,
|
||||
} from "../../scripts/contracts/ci-gates.ts";
|
||||
|
||||
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
|
||||
const dependency = {
|
||||
@@ -125,6 +130,7 @@ async function writeProviderEnvironment(
|
||||
);
|
||||
await mkdir(path.join(root, "provider"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(path.join(root, "provider/candidate.tar.gz"), "fixture archive\n"),
|
||||
writeFile(
|
||||
path.join(root, "provider/vulnerability.json"),
|
||||
`${JSON.stringify(vulnerabilityReport)}\n`,
|
||||
@@ -147,6 +153,10 @@ async function writeProviderEnvironment(
|
||||
),
|
||||
]);
|
||||
return {
|
||||
CANDIDATE_ARCHIVE_PATH: "provider/candidate.tar.gz",
|
||||
CANDIDATE_ARCHIVE_SHA256: createHash("sha256")
|
||||
.update("fixture archive\n")
|
||||
.digest("hex"),
|
||||
VULNERABILITY_REPORT_PATH: "provider/vulnerability.json",
|
||||
PROVENANCE_ATTESTATION_PATH: "provider/provenance.json",
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem",
|
||||
@@ -157,6 +167,39 @@ async function writeProviderEnvironment(
|
||||
}
|
||||
|
||||
describe("supply-chain policy", () => {
|
||||
it("emits a strict role-bound v2 verification record from exact input bytes", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v2-"));
|
||||
try {
|
||||
const manifest = await createMinimalCandidateTree(root);
|
||||
const environment = await writeProviderEnvironment(
|
||||
root,
|
||||
manifest.distSha256,
|
||||
manifest.lockfileSha256,
|
||||
);
|
||||
const report = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: root,
|
||||
environment,
|
||||
verifyLocalEvidence: async () => ({ status: "PASS" as const, failures: [] }),
|
||||
} as Parameters<typeof verifyPromotionInputs>[0]);
|
||||
expect(providerVerificationArtifactSchema.parse(report)).toEqual(
|
||||
expect.objectContaining({
|
||||
schemaVersion: 2,
|
||||
artifactType: "provider-verification",
|
||||
candidateArchiveSha256: environment.CANDIDATE_ARCHIVE_SHA256,
|
||||
vulnerabilityReportSha256: createHash("sha256")
|
||||
.update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!)))
|
||||
.digest("hex"),
|
||||
provenanceAttestationSha256: createHash("sha256")
|
||||
.update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!)))
|
||||
.digest("hex"),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("wires candidate files, PEM trust, env report paths, and mutation checks", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-"));
|
||||
try {
|
||||
@@ -171,11 +214,13 @@ describe("supply-chain policy", () => {
|
||||
failures: [] as const,
|
||||
});
|
||||
const valid = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: root,
|
||||
environment: validEnvironment,
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
});
|
||||
const absent = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: root,
|
||||
environment: {},
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
@@ -186,12 +231,14 @@ describe("supply-chain policy", () => {
|
||||
manifest.lockfileSha256,
|
||||
);
|
||||
const wrongDigest = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: root,
|
||||
environment: wrongEnvironment,
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
});
|
||||
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
|
||||
const postAttestationMutation = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: root,
|
||||
environment: validEnvironment,
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
@@ -228,6 +275,7 @@ describe("supply-chain policy", () => {
|
||||
);
|
||||
const before = await readFile(localVerificationPath, "utf8");
|
||||
const result = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: root,
|
||||
environment,
|
||||
});
|
||||
@@ -656,20 +704,28 @@ describe("supply-chain policy", () => {
|
||||
});
|
||||
|
||||
it("wires the exact security fixture checker as a passing CI gate", async () => {
|
||||
const gates = JSON.parse(await readFile("config/ci/gates.json", "utf8")) as {
|
||||
gates: Record<string, { steps: unknown[]; evidence: string[] }>;
|
||||
};
|
||||
const securityGate = gates.gates["FE-GATE-013"]!;
|
||||
expect(securityGate.steps).toContainEqual({
|
||||
script: "check:security:fixtures",
|
||||
expect: "pass",
|
||||
});
|
||||
expect(securityGate.steps).not.toEqual(
|
||||
const contract = await loadCiGateContract(process.cwd());
|
||||
const index = indexCiGateContract(contract);
|
||||
const securityGate = index.gates.get("FE-GATE-013");
|
||||
expect(securityGate).toBeDefined();
|
||||
const commands = securityGate!.commandIds.map((commandId) =>
|
||||
index.commands.get(commandId),
|
||||
);
|
||||
expect(commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
script: "check:security:fixtures",
|
||||
expect: "pass",
|
||||
}),
|
||||
);
|
||||
expect(commands).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ script: "scan:security:fixture" }),
|
||||
]),
|
||||
);
|
||||
expect(securityGate.evidence).not.toContain(
|
||||
const evidence = securityGate!.evidenceArtifactIds.map(
|
||||
(artifactId) => index.artifacts.get(artifactId)?.path,
|
||||
);
|
||||
expect(evidence).not.toContain(
|
||||
"artifacts/security/scan-fixture.sarif",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user