From 7f0dbfdd70a7ec456348b4a3fa039aab6221513d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 1 Aug 2026 15:25:24 +0000 Subject: [PATCH] ci: simplify Sonar scanning --- .github/actions/protected-sonar/action.yml | 62 --- .../protected-sonar/verify-sonar-task.cjs | 70 ---- .github/workflows/quality.yml | 151 +------- .github/workflows/sonar-exact-revision.yml | 284 -------------- package.json | 1 - scripts/ci/check-event-diff.sh | 73 ---- scripts/ci/create-coverage-manifest.cjs | 178 --------- scripts/ci/fetch-verified-base.sh | 33 -- scripts/ci/guard-control-plane.sh | 43 --- scripts/ci/test-npm-ignore-scripts.sh | 25 -- scripts/ci/test_ci_helpers.py | 356 ------------------ scripts/ci/test_validate_coverage_artifact.py | 211 ----------- scripts/ci/test_validate_workflow_run.cjs | 221 ----------- scripts/ci/test_workflow_structure.py | 133 ------- scripts/ci/validate-candidate-tree.sh | 30 -- scripts/ci/validate-coverage-artifact.py | 289 -------------- scripts/ci/validate-workflow-run.cjs | 213 ----------- scripts/ci_monitor.cjs | 8 +- sonar-project.properties | 2 - 19 files changed, 21 insertions(+), 2362 deletions(-) delete mode 100644 .github/actions/protected-sonar/action.yml delete mode 100644 .github/actions/protected-sonar/verify-sonar-task.cjs delete mode 100644 .github/workflows/sonar-exact-revision.yml delete mode 100644 scripts/ci/check-event-diff.sh delete mode 100644 scripts/ci/create-coverage-manifest.cjs delete mode 100644 scripts/ci/fetch-verified-base.sh delete mode 100644 scripts/ci/guard-control-plane.sh delete mode 100644 scripts/ci/test-npm-ignore-scripts.sh delete mode 100644 scripts/ci/test_ci_helpers.py delete mode 100644 scripts/ci/test_validate_coverage_artifact.py delete mode 100644 scripts/ci/test_validate_workflow_run.cjs delete mode 100644 scripts/ci/test_workflow_structure.py delete mode 100644 scripts/ci/validate-candidate-tree.sh delete mode 100644 scripts/ci/validate-coverage-artifact.py delete mode 100644 scripts/ci/validate-workflow-run.cjs diff --git a/.github/actions/protected-sonar/action.yml b/.github/actions/protected-sonar/action.yml deleted file mode 100644 index fb8eb8e..0000000 --- a/.github/actions/protected-sonar/action.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Protected Sonar scan -description: Run and verify Sonar from the protected default-branch control plane. -inputs: - project-base-dir: - required: true - description: Absolute candidate checkout path. - organization: - required: true - description: SonarQube Cloud organization key. - project-key: - required: true - description: SonarQube Cloud project key. - candidate-sha: - required: true - description: Exact candidate commit SHA. - analysis-mode: - required: true - description: pull_request, main, or branch. - pull-request: - required: true - description: Pull request number, or zero outside pull requests. - candidate-ref: - required: true - description: Candidate branch name. - base-ref: - required: true - description: Pull request base or default branch. -runs: - using: composite - steps: - - name: Run SonarQube Cloud scanner - uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1 - with: - projectBaseDir: ${{ inputs.project-base-dir }} - args: >- - -Dsonar.organization=${{ inputs.organization }} - -Dsonar.projectKey=${{ inputs.project-key }} - -Dsonar.projectVersion=0.1.0-sonar.1 - -Dsonar.scm.revision=${{ inputs.candidate-sha }} - -Dsonar.sources=. - -Dsonar.tests=. - -Dsonar.exclusions=coverage/**,dist/**,node_modules/**,.omx/**,package-lock.json,tsconfig.tsbuildinfo - -Dsonar.test.inclusions=**/*_test.go,src/**/*.test.ts,src/**/*.test.tsx,src/test/**,scripts/**/*.test.sh,scripts/**/test-*.sh,scripts/**/test_*.py,scripts/**/test_*.cjs - -Dsonar.coverage.exclusions=.github/**,scripts/**,vite.config.ts - -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info - -Dsonar.go.coverage.reportPaths=coverage/go.out - -Dsonar.qualitygate.wait=true - -Dsonar.qualitygate.timeout=300 - ${{ inputs.analysis-mode == 'pull_request' && format('-Dsonar.pullrequest.key={0}', inputs.pull-request) || '' }} - ${{ inputs.analysis-mode == 'pull_request' && format('-Dsonar.pullrequest.branch={0}', inputs.candidate-ref) || '' }} - ${{ inputs.analysis-mode == 'pull_request' && format('-Dsonar.pullrequest.base={0}', inputs.base-ref) || '' }} - ${{ inputs.analysis-mode == 'branch' && format('-Dsonar.branch.name={0}', inputs.candidate-ref) || '' }} - - name: Verify Sonar compute-engine revision - shell: bash - env: - PROJECT_BASE_DIR: ${{ inputs.project-base-dir }} - SONAR_PROJECT_KEY: ${{ inputs.project-key }} - CANDIDATE_SHA: ${{ inputs.candidate-sha }} - ANALYSIS_MODE: ${{ inputs.analysis-mode }} - PULL_REQUEST: ${{ inputs.pull-request }} - CANDIDATE_REF: ${{ inputs.candidate-ref }} - run: node "$GITHUB_ACTION_PATH/verify-sonar-task.cjs" diff --git a/.github/actions/protected-sonar/verify-sonar-task.cjs b/.github/actions/protected-sonar/verify-sonar-task.cjs deleted file mode 100644 index b79b230..0000000 --- a/.github/actions/protected-sonar/verify-sonar-task.cjs +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env node - -const { readFileSync } = require('node:fs'); - -const SONAR_ORIGIN = 'https://sonarcloud.io'; - -function required(name) { - const value = process.env[name]; - if (!value) throw new Error(`missing required environment variable ${name}`); - return value; -} - -function properties(path) { - const result = new Map(); - for (const line of readFileSync(path, 'utf8').split('\n')) { - if (!line || line.startsWith('#')) continue; - const split = line.indexOf('='); - if (split < 1) throw new Error(`malformed report-task line: ${line}`); - result.set(line.slice(0, split), line.slice(split + 1)); - } - return result; -} - -async function sonar(url) { - const token = required('SONAR_TOKEN'); - const response = await fetch(url, { - headers: { Authorization: `Basic ${Buffer.from(`${token}:`).toString('base64')}` }, - }); - if (!response.ok) throw new Error(`Sonar API returned ${response.status} for ${new URL(url).pathname}`); - return response.json(); -} - -async function main() { - const candidateSha = required('CANDIDATE_SHA'); - if (!/^[0-9a-f]{40}$/.test(candidateSha)) throw new Error('candidate SHA is invalid'); - const report = properties(`${required('PROJECT_BASE_DIR')}/.scannerwork/report-task.txt`); - const serverUrl = new URL(report.get('serverUrl')); - if (serverUrl.origin !== SONAR_ORIGIN || serverUrl.username || serverUrl.password) { - throw new Error('unexpected Sonar server URL'); - } - const ceTaskId = report.get('ceTaskId'); - if (!/^[A-Za-z0-9_-]+$/.test(ceTaskId || '')) throw new Error('invalid compute-engine task id'); - const task = await sonar(new URL(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`, SONAR_ORIGIN)); - if (task.task?.status !== 'SUCCESS' || !task.task.analysisId) { - throw new Error(`compute-engine task is not successful: ${task.task?.status || 'missing'}`); - } - if (task.task.componentKey !== required('SONAR_PROJECT_KEY')) { - throw new Error(`compute-engine component ${task.task.componentKey} != ${required('SONAR_PROJECT_KEY')}`); - } - - const query = new URL('/api/project_analyses/search', SONAR_ORIGIN); - query.searchParams.set('project', required('SONAR_PROJECT_KEY')); - query.searchParams.set('pageSize', '100'); - if (required('ANALYSIS_MODE') === 'pull_request') query.searchParams.set('pullRequest', required('PULL_REQUEST')); - if (required('ANALYSIS_MODE') === 'branch') query.searchParams.set('branch', required('CANDIDATE_REF')); - const analyses = await sonar(query); - const analysis = analyses.analyses?.find((item) => item.key === task.task.analysisId); - if (!analysis) throw new Error(`analysis ${task.task.analysisId} not returned by project analysis API`); - if (analysis.revision !== candidateSha) throw new Error(`analysis revision ${analysis.revision} != ${candidateSha}`); - console.log(`verified Sonar task ${ceTaskId}, analysis ${analysis.key}, revision ${analysis.revision}`); -} - -if (require.main === module) { - main().catch((error) => { - console.error(`verify-sonar-task: ${error.message}`); - process.exitCode = 1; - }); -} - -module.exports = { main }; diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d5f2f61..e5625f3 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -1,10 +1,11 @@ -name: Regression and candidate coverage +name: Quality and Sonar on: push: + branches: [main] pull_request: + branches: [main] types: [opened, synchronize, reopened] - workflow_dispatch: permissions: contents: read @@ -14,47 +15,18 @@ concurrency: cancel-in-progress: true jobs: - regression: + quality: name: Regression (test-merge) runs-on: ubuntu-latest timeout-minutes: 30 - outputs: - diff_base: ${{ steps.diff.outputs.base }} - diff_head: ${{ steps.diff.outputs.head }} + steps: - - name: Check out GitHub test revision + - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Record tested identities - env: - EXPECTED_SHA: ${{ github.sha }} - CANDIDATE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - CHECK_RUN_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: | - set -eu - actual_sha=$(git rev-parse HEAD) - test "$actual_sha" = "$EXPECTED_SHA" - tree=$(git show -s --format=%T HEAD) - { - echo '### Regression identity' - echo - echo "- Event SHA/test revision: \`$EXPECTED_SHA\`" - echo "- Checked-out SHA: \`$actual_sha\`" - echo "- Candidate head SHA: \`$CANDIDATE_SHA\`" - echo "- Check-run head SHA: \`$CHECK_RUN_HEAD_SHA\`" - echo "- Tested tree: \`$tree\`" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Resolve and check event-aware diff - id: diff - env: - GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: sh scripts/ci/check-event-diff.sh - - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -67,7 +39,7 @@ jobs: go-version-file: go.mod cache-dependency-path: go.sum - - name: Install frontend dependencies without lifecycle scripts + - name: Install frontend dependencies run: npm ci --ignore-scripts - name: Run frontend coverage gate @@ -92,105 +64,16 @@ jobs: - name: Check Go formatting run: sh scripts/check-go-format.sh - - name: Install pinned Actionlint + - name: Run SonarQube Cloud scan + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1 env: - ACTIONLINT_VERSION: 1.7.12 - ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 - run: | - set -eu - archive="$RUNNER_TEMP/actionlint.tar.gz" - curl --fail --location --proto '=https' --tlsv1.2 \ - "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - --output "$archive" - printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum --check --strict - mkdir "$RUNNER_TEMP/actionlint" - tar -xzf "$archive" -C "$RUNNER_TEMP/actionlint" actionlint - "$RUNNER_TEMP/actionlint/actionlint" -version - - - name: Lint workflows and shell scripts - run: | - "$RUNNER_TEMP/actionlint/actionlint" .github/workflows/*.yml - shellcheck --version - shellcheck scripts/*.sh scripts/ci/*.sh - - - name: Verify immutable action pins - run: node scripts/ci_monitor.cjs check-actions - - - name: Run CI control-plane hostile tests - run: | - sh scripts/check-go-checkers.test.sh - sh scripts/ci/test-npm-ignore-scripts.sh - python3 scripts/ci/test_validate_coverage_artifact.py - python3 scripts/ci/test_ci_helpers.py - python3 scripts/ci/test_workflow_structure.py - node scripts/ci/test_validate_workflow_run.cjs - node scripts/ci/test_ci_monitor.cjs - - candidate_coverage: - name: Candidate head coverage - needs: regression - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - name: Check out exact candidate head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24.15.0 - cache: npm - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: - go-version-file: go.mod - cache-dependency-path: go.sum + args: >- + -Dsonar.organization=${{ vars.SONAR_ORGANIZATION }} + -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }} - - name: Install frontend dependencies without lifecycle scripts - run: npm ci --ignore-scripts - - - name: Generate frontend coverage - run: npm run test:coverage:report-only - - - name: Generate Go coverage - env: - GO_PACKAGE_COVERAGE_THRESHOLD: '95.0' - GO_TOTAL_COVERAGE_THRESHOLD: '96.4' - GO_COVERAGE_PROFILE: coverage/go.out - run: npm run coverage:go - - - name: Fetch verified pull request base - if: github.event_name == 'pull_request' - env: - BASE_REPOSITORY: ${{ github.repository }} - BASE_SHA: ${{ needs.regression.outputs.diff_base }} - run: sh scripts/ci/fetch-verified-base.sh "$BASE_REPOSITORY" "$BASE_SHA" - - - name: Create candidate-bound coverage manifest - env: - CANDIDATE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - CANDIDATE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - CANDIDATE_REF: ${{ github.event.pull_request.head.ref || github.ref_name }} - PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || 0 }} - BASE_SHA: ${{ needs.regression.outputs.diff_base }} - BASE_REF: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }} - run: node scripts/ci/create-coverage-manifest.cjs - - - name: Upload exact candidate coverage bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 - with: - name: candidate-head-coverage-${{ github.event.pull_request.head.sha || github.sha }} - path: | - coverage/lcov.info - coverage/go.out - coverage/manifest.json - if-no-files-found: error - compression-level: 9 - retention-days: 7 - include-hidden-files: false + - name: Explain skipped Sonar scan + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + run: echo "Sonar scan skipped because GitHub does not expose repository secrets to fork pull requests." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sonar-exact-revision.yml b/.github/workflows/sonar-exact-revision.yml deleted file mode 100644 index 64df3b8..0000000 --- a/.github/workflows/sonar-exact-revision.yml +++ /dev/null @@ -1,284 +0,0 @@ -name: Sonar exact-revision gate - -on: - workflow_run: - workflows: [Regression and candidate coverage] - types: [completed] - -permissions: - actions: read - contents: read - -concurrency: - group: sonar-exact-${{ github.event.workflow_run.id }} - cancel-in-progress: false - -jobs: - validate-candidate: - name: Validate candidate coverage and control plane - runs-on: ubuntu-latest - timeout-minutes: 10 - outputs: - artifact_id: ${{ steps.run.outputs.artifact_id }} - run_id: ${{ steps.run.outputs.run_id }} - run_attempt: ${{ steps.run.outputs.run_attempt }} - job_id: ${{ steps.run.outputs.job_id }} - event: ${{ steps.run.outputs.event }} - mode: ${{ steps.run.outputs.mode }} - candidate_repository: ${{ steps.run.outputs.candidate_repository }} - candidate_sha: ${{ steps.run.outputs.candidate_sha }} - candidate_tree: ${{ steps.run.outputs.candidate_tree }} - candidate_ref: ${{ steps.run.outputs.candidate_ref }} - workflow_ref: ${{ steps.run.outputs.workflow_ref }} - workflow_sha: ${{ steps.run.outputs.workflow_sha }} - test_revision_sha: ${{ steps.run.outputs.workflow_sha }} - control_plane_sha: ${{ steps.control-plane.outputs.sha }} - maintenance: ${{ steps.control-plane-change.outputs.maintenance }} - security_base_sha: ${{ steps.control-plane-change.outputs.security_base_sha }} - pull_request: ${{ steps.run.outputs.pull_request }} - base_sha: ${{ steps.run.outputs.base_sha }} - base_ref: ${{ steps.run.outputs.base_ref }} - steps: - - name: Check out protected default-branch control plane - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.workflow_sha }} - path: trusted-main - fetch-depth: 0 - persist-credentials: false - - - name: Pin protected control-plane identity - id: control-plane - env: - EXPECTED_CONTROL_PLANE_SHA: ${{ github.workflow_sha }} - run: | - set -eu - actual_sha=$(git -C trusted-main rev-parse HEAD) - test "$actual_sha" = "$EXPECTED_CONTROL_PLANE_SHA" - echo "sha=$actual_sha" >> "$GITHUB_OUTPUT" - echo "Protected control-plane SHA: \`$actual_sha\`" >> "$GITHUB_STEP_SUMMARY" - - - name: Validate triggering run, job, and artifact metadata - id: run - env: - GITHUB_TOKEN: ${{ github.token }} - TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} - TRIGGER_WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }} - TRIGGER_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} - TRIGGER_HEAD_REPOSITORY_ID: ${{ github.event.workflow_run.head_repository.id }} - TRIGGER_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: node trusted-main/scripts/ci/validate-workflow-run.cjs - - - name: Check out exact candidate without executing it - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ steps.run.outputs.candidate_repository }} - ref: ${{ steps.run.outputs.candidate_sha }} - path: candidate - fetch-depth: 0 - persist-credentials: false - - - name: Classify protected control-plane changes - id: control-plane-change - env: - BASE_SHA: ${{ steps.run.outputs.base_sha }} - BASE_REF: ${{ steps.run.outputs.base_ref }} - BASE_REPOSITORY: ${{ github.repository }} - CANDIDATE_SHA: ${{ steps.run.outputs.candidate_sha }} - ANALYSIS_MODE: ${{ steps.run.outputs.mode }} - working-directory: candidate - run: | - set -eu - if [ "$ANALYSIS_MODE" = pull_request ]; then - sh ../trusted-main/scripts/ci/fetch-verified-base.sh "$BASE_REPOSITORY" "$BASE_SHA" - guard_base=$(git merge-base "$CANDIDATE_SHA" "$BASE_SHA") - bash ../trusted-main/scripts/ci/guard-control-plane.sh "$guard_base" "$CANDIDATE_SHA" --classify >> "$GITHUB_OUTPUT" - echo "security_base_sha=$guard_base" >> "$GITHUB_OUTPUT" - elif [ "$ANALYSIS_MODE" = branch ]; then - git fetch --no-tags origin "$BASE_REF" - guard_base=$(git merge-base "$CANDIDATE_SHA" "origin/$BASE_REF") - bash ../trusted-main/scripts/ci/guard-control-plane.sh "$guard_base" "$CANDIDATE_SHA" --classify >> "$GITHUB_OUTPUT" - echo "security_base_sha=$guard_base" >> "$GITHUB_OUTPUT" - else - echo 'maintenance=false' >> "$GITHUB_OUTPUT" - echo "security_base_sha=$CANDIDATE_SHA" >> "$GITHUB_OUTPUT" - fi - - - name: Reject unsafe candidate tree before environment release - env: - CANDIDATE_SHA: ${{ steps.run.outputs.candidate_sha }} - CANDIDATE_TREE: ${{ steps.run.outputs.candidate_tree }} - run: bash trusted-main/scripts/ci/validate-candidate-tree.sh candidate "$CANDIDATE_SHA" "$CANDIDATE_TREE" - - - name: Download and validate hostile artifact - env: - GITHUB_TOKEN: ${{ github.token }} - ARTIFACT_ID: ${{ steps.run.outputs.artifact_id }} - RUN_ID: ${{ steps.run.outputs.run_id }} - RUN_ATTEMPT: ${{ steps.run.outputs.run_attempt }} - PRODUCER_EVENT: ${{ steps.run.outputs.event }} - CANDIDATE_REPOSITORY: ${{ steps.run.outputs.candidate_repository }} - CANDIDATE_SHA: ${{ steps.run.outputs.candidate_sha }} - CANDIDATE_TREE: ${{ steps.run.outputs.candidate_tree }} - CANDIDATE_REF: ${{ steps.run.outputs.candidate_ref }} - WORKFLOW_REF: ${{ steps.run.outputs.workflow_ref }} - WORKFLOW_SHA: ${{ steps.run.outputs.workflow_sha }} - TEST_REVISION_SHA: ${{ steps.run.outputs.workflow_sha }} - PULL_REQUEST: ${{ steps.run.outputs.pull_request }} - BASE_SHA: ${{ steps.run.outputs.base_sha }} - BASE_REF: ${{ steps.run.outputs.base_ref }} - MAINTENANCE: ${{ steps.control-plane-change.outputs.maintenance }} - SECURITY_BASE_SHA: ${{ steps.control-plane-change.outputs.security_base_sha }} - run: | - set -eu - archive="$RUNNER_TEMP/candidate-coverage.zip" - curl --fail --location --proto '=https' --tlsv1.2 \ - -H 'Accept: application/vnd.github+json' \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID/zip" \ - --output "$archive" - python3 trusted-main/scripts/ci/validate-coverage-artifact.py \ - --archive "$archive" --output "$RUNNER_TEMP/validated-coverage" \ - --repository "$GITHUB_REPOSITORY" --workflow 'Regression and candidate coverage' \ - --workflow-ref "$WORKFLOW_REF" --workflow-sha "$WORKFLOW_SHA" \ - --test-revision-sha "$TEST_REVISION_SHA" \ - --run-id "$RUN_ID" --run-attempt "$RUN_ATTEMPT" --event "$PRODUCER_EVENT" \ - --candidate-repository "$CANDIDATE_REPOSITORY" --candidate-sha "$CANDIDATE_SHA" \ - --candidate-tree "$CANDIDATE_TREE" --candidate-ref "$CANDIDATE_REF" \ - --pull-request "$PULL_REQUEST" --base-sha "$BASE_SHA" --base-ref "$BASE_REF" \ - --security-base-sha "$SECURITY_BASE_SHA" \ - --maintenance "$MAINTENANCE" \ - --repository-root "$GITHUB_WORKSPACE/candidate" - - scan: - name: Protected exact-revision scan - needs: validate-candidate - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: sonar-scan - steps: - - name: Check out exact clean candidate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ needs.validate-candidate.outputs.candidate_repository }} - ref: ${{ needs.validate-candidate.outputs.candidate_sha }} - path: candidate - fetch-depth: 0 - persist-credentials: false - - - name: Check out protected scanner control plane - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.validate-candidate.outputs.control_plane_sha }} - path: trusted-main - fetch-depth: 1 - persist-credentials: false - - - name: Reassert protected control-plane identity after approval - env: - EXPECTED_CONTROL_PLANE_SHA: ${{ needs.validate-candidate.outputs.control_plane_sha }} - run: | - set -eu - actual_sha=$(git -C trusted-main rev-parse HEAD) - test "$actual_sha" = "$EXPECTED_CONTROL_PLANE_SHA" - echo "Approved protected control-plane SHA: \`$actual_sha\`" >> "$GITHUB_STEP_SUMMARY" - - - name: Reject unsafe candidate tree before tokened scan - env: - CANDIDATE_SHA: ${{ needs.validate-candidate.outputs.candidate_sha }} - CANDIDATE_TREE: ${{ needs.validate-candidate.outputs.candidate_tree }} - run: bash trusted-main/scripts/ci/validate-candidate-tree.sh candidate "$CANDIDATE_SHA" "$CANDIDATE_TREE" - - - name: Revalidate artifact and prepare coverage-only delta - env: - GITHUB_TOKEN: ${{ github.token }} - ARTIFACT_ID: ${{ needs.validate-candidate.outputs.artifact_id }} - RUN_ID: ${{ needs.validate-candidate.outputs.run_id }} - RUN_ATTEMPT: ${{ needs.validate-candidate.outputs.run_attempt }} - PRODUCER_EVENT: ${{ needs.validate-candidate.outputs.event }} - CANDIDATE_REPOSITORY: ${{ needs.validate-candidate.outputs.candidate_repository }} - CANDIDATE_SHA: ${{ needs.validate-candidate.outputs.candidate_sha }} - CANDIDATE_TREE: ${{ needs.validate-candidate.outputs.candidate_tree }} - CANDIDATE_REF: ${{ needs.validate-candidate.outputs.candidate_ref }} - WORKFLOW_REF: ${{ needs.validate-candidate.outputs.workflow_ref }} - WORKFLOW_SHA: ${{ needs.validate-candidate.outputs.workflow_sha }} - TEST_REVISION_SHA: ${{ needs.validate-candidate.outputs.test_revision_sha }} - PULL_REQUEST: ${{ needs.validate-candidate.outputs.pull_request }} - BASE_SHA: ${{ needs.validate-candidate.outputs.base_sha }} - BASE_REF: ${{ needs.validate-candidate.outputs.base_ref }} - BASE_REPOSITORY: ${{ github.repository }} - MAINTENANCE: ${{ needs.validate-candidate.outputs.maintenance }} - SECURITY_BASE_SHA: ${{ needs.validate-candidate.outputs.security_base_sha }} - run: | - set -eu - test "$(git -C candidate rev-parse HEAD)" = "$CANDIDATE_SHA" - test "$(git -C candidate show -s --format=%T HEAD)" = "$CANDIDATE_TREE" - test -z "$(git -C candidate status --porcelain=v1 --untracked-files=all)" - if [ "$PRODUCER_EVENT" = pull_request ]; then - (cd candidate && sh ../trusted-main/scripts/ci/fetch-verified-base.sh "$BASE_REPOSITORY" "$BASE_SHA") - test "$(git -C candidate merge-base "$CANDIDATE_SHA" "$BASE_SHA")" = "$SECURITY_BASE_SHA" - fi - if [ "$MAINTENANCE" = true ]; then - cp --remove-destination trusted-main/sonar-project.properties candidate/sonar-project.properties - test "$(stat -c '%a' candidate/sonar-project.properties)" = "$(stat -c '%a' trusted-main/sonar-project.properties)" - else - cmp candidate/sonar-project.properties trusted-main/sonar-project.properties - fi - sha256sum trusted-main/sonar-project.properties - archive="$RUNNER_TEMP/candidate-coverage.zip" - curl --fail --location --proto '=https' --tlsv1.2 \ - -H 'Accept: application/vnd.github+json' \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID/zip" \ - --output "$archive" - python3 trusted-main/scripts/ci/validate-coverage-artifact.py \ - --archive "$archive" --output "$RUNNER_TEMP/validated-coverage" \ - --repository "$GITHUB_REPOSITORY" --workflow 'Regression and candidate coverage' \ - --workflow-ref "$WORKFLOW_REF" --workflow-sha "$WORKFLOW_SHA" \ - --test-revision-sha "$TEST_REVISION_SHA" \ - --run-id "$RUN_ID" --run-attempt "$RUN_ATTEMPT" --event "$PRODUCER_EVENT" \ - --candidate-repository "$CANDIDATE_REPOSITORY" --candidate-sha "$CANDIDATE_SHA" \ - --candidate-tree "$CANDIDATE_TREE" --candidate-ref "$CANDIDATE_REF" \ - --pull-request "$PULL_REQUEST" --base-sha "$BASE_SHA" --base-ref "$BASE_REF" \ - --security-base-sha "$SECURITY_BASE_SHA" \ - --maintenance "$MAINTENANCE" \ - --repository-root "$GITHUB_WORKSPACE/candidate" - mkdir candidate/coverage - cp "$RUNNER_TEMP/validated-coverage/lcov.info" candidate/coverage/lcov.info - cp "$RUNNER_TEMP/validated-coverage/go.out" candidate/coverage/go.out - tracked_delta=$(git -C candidate diff --name-only) - if [ "$MAINTENANCE" = true ]; then - test -z "$tracked_delta" || test "$tracked_delta" = sonar-project.properties - cmp candidate/sonar-project.properties trusted-main/sonar-project.properties - else - test -z "$tracked_delta" - fi - test -z "$(git -C candidate diff --cached --name-only)" - test -z "$(git -C candidate status --porcelain=v1 --untracked-files=all | grep -v '^ M sonar-project.properties$' || true)" - test "$(find candidate/coverage -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort)" = "$(printf 'go.out\nlcov.info')" - test "$(sha256sum candidate/coverage/lcov.info | cut -d ' ' -f 1)" = "$(sha256sum "$RUNNER_TEMP/validated-coverage/lcov.info" | cut -d ' ' -f 1)" - test "$(sha256sum candidate/coverage/go.out | cut -d ' ' -f 1)" = "$(sha256sum "$RUNNER_TEMP/validated-coverage/go.out" | cut -d ' ' -f 1)" - - - name: Validate protected Sonar variables - env: - SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION }} - SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }} - run: | - test -n "$SONAR_ORGANIZATION" - test -n "$SONAR_PROJECT_KEY" - - - name: Scan and verify exact Sonar revision - uses: ./trusted-main/.github/actions/protected-sonar - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - project-base-dir: ${{ github.workspace }}/candidate - organization: ${{ vars.SONAR_ORGANIZATION }} - project-key: ${{ vars.SONAR_PROJECT_KEY }} - candidate-sha: ${{ needs.validate-candidate.outputs.candidate_sha }} - analysis-mode: ${{ needs.validate-candidate.outputs.mode }} - pull-request: ${{ needs.validate-candidate.outputs.pull_request }} - candidate-ref: ${{ needs.validate-candidate.outputs.candidate_ref }} - base-ref: ${{ needs.validate-candidate.outputs.base_ref }} diff --git a/package.json b/package.json index 96017bc..a9178a0 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "test": "vitest run --coverage", "test:unit": "vitest run", "test:coverage": "vitest run --coverage", - "test:coverage:report-only": "vitest run --coverage --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.branches=0 --coverage.thresholds.statements=0", "coverage:go": "sh scripts/check-go-coverage.sh", "coverage": "npm test && npm run coverage:go" }, diff --git a/scripts/ci/check-event-diff.sh b/scripts/ci/check-event-diff.sh deleted file mode 100644 index 0926606..0000000 --- a/scripts/ci/check-event-diff.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/sh -set -eu - -event_name=${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required} -event_path=${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH is required} -head_sha=${GITHUB_SHA:?GITHUB_SHA is required} -default_branch=${GITHUB_DEFAULT_BRANCH:?GITHUB_DEFAULT_BRANCH is required} -empty_tree=4b825dc642cb6eb9a060e54bf8d69288fbee4904 - -validate_sha() { - case "$1" in - *[!0-9a-f]*|'') echo "invalid $2 SHA: $1" >&2; exit 1 ;; - esac - [ "${#1}" -eq 40 ] || { echo "invalid $2 SHA length" >&2; exit 1; } -} - -case "$event_name" in - pull_request) - base_sha=$(jq -er '.pull_request.base.sha' "$event_path") - head_sha=$(jq -er '.pull_request.head.sha' "$event_path") - ;; - push) - before=$(jq -er '.before' "$event_path") - validate_sha "$before" before - case "$before" in - 0000000000000000000000000000000000000000) - git fetch --no-tags origin "$default_branch" - base_sha=$(git merge-base "$head_sha" "origin/$default_branch" 2>/dev/null || true) - [ -n "$base_sha" ] || base_sha=$empty_tree - [ "$(git rev-list --parents -n 1 "$head_sha" | wc -w)" -ne 1 ] || base_sha=$empty_tree - ;; - *) - git fetch --no-tags origin "$before" - base_sha=$before - ;; - esac - ;; - workflow_dispatch) - git fetch --no-tags origin "$default_branch" - base_sha=$(git merge-base "$head_sha" "origin/$default_branch" 2>/dev/null || true) - [ -n "$base_sha" ] || base_sha=$empty_tree - ;; - *) - echo "unsupported event: $event_name" >&2 - exit 1 - ;; -esac - -validate_sha "$head_sha" head -validate_sha "$base_sha" base -[ "$base_sha" = "$empty_tree" ] || git cat-file -e "$base_sha^{commit}" -git cat-file -e "$head_sha^{commit}" - -status=0 -git diff --check "$base_sha" "$head_sha" || status=$? - -if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - { - echo '### Event-aware diff evidence' - echo - echo "- Event: \`$event_name\`" - echo "- Base: \`$base_sha\`" - echo "- Head: \`$head_sha\`" - echo "- Command: \`git diff --check $base_sha $head_sha\`" - echo "- Exit: \`$status\`" - } >> "$GITHUB_STEP_SUMMARY" -fi - -printf 'event=%s\nbase=%s\nhead=%s\nexit=%s\n' "$event_name" "$base_sha" "$head_sha" "$status" -[ -z "${GITHUB_OUTPUT:-}" ] || { - printf 'event=%s\nbase=%s\nhead=%s\n' "$event_name" "$base_sha" "$head_sha" >> "$GITHUB_OUTPUT" -} -exit "$status" diff --git a/scripts/ci/create-coverage-manifest.cjs b/scripts/ci/create-coverage-manifest.cjs deleted file mode 100644 index a64d443..0000000 --- a/scripts/ci/create-coverage-manifest.cjs +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env node - -const { createHash } = require('node:crypto'); -const { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, writeFileSync } = require('node:fs'); -const { execFileSync } = require('node:child_process'); -const { posix: pathPosix } = require('node:path'); - -const REPORTS = [ - ['frontend', 'coverage/lcov.info', 5 * 1024 * 1024], - ['go', 'coverage/go.out', 5 * 1024 * 1024], -]; - -function required(name) { - const value = process.env[name]; - if (!value) throw new Error(`missing required environment variable ${name}`); - return value; -} - -function validateRelativeSource(path, label) { - if (!path || path.includes('\\') || path.includes('\0') || path.includes('\r') || path.includes('\n') || pathPosix.isAbsolute(path)) { - throw new Error(`${label} has an unsafe source path`); - } - const normalized = pathPosix.normalize(path); - if (normalized !== path || normalized === '..' || normalized.startsWith('../')) { - throw new Error(`${label} source path is not normalized repository-relative path: ${path}`); - } - const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink()) { - throw new Error(`${label} source is not a regular non-symlink file: ${path}`); - } -} - -function validateReport(kind, path, limit) { - let descriptor; - try { - descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); - } catch (error) { - throw new Error(`${path} must be an openable regular, non-symlink file: ${error.message}`); - } - let stat; - let content; - try { - stat = fstatSync(descriptor); - if (!stat.isFile()) throw new Error(`${path} must be a regular, non-symlink file`); - if (stat.size === 0 || stat.size > limit) { - throw new Error(`${path} size ${stat.size} is outside 1..${limit} bytes`); - } - content = readFileSync(descriptor); - const finalStat = fstatSync(descriptor); - if (finalStat.size !== stat.size || content.length !== stat.size) { - throw new Error(`${path} changed size while being read`); - } - } finally { - closeSync(descriptor); - } - const sample = content.toString('utf8'); - if (sample.includes('\0') || sample.includes('\r')) throw new Error(`${path} contains forbidden control bytes`); - if (kind === 'frontend') { - const sources = sample.split('\n').filter((line) => line.startsWith('SF:')).map((line) => line.slice(3)); - if (!sources.length || !sample.includes('\nDA:')) throw new Error(`${path} is not an LCOV report`); - for (const source of sources) validateRelativeSource(source, path); - } - if (kind === 'go') { - if (!/^mode: (set|count|atomic)\n/.test(sample)) throw new Error(`${path} is not a Go coverage profile`); - const modulePath = execFileSync('go', ['list', '-m'], { encoding: 'utf8' }).trim(); - const lines = sample.trimEnd().split('\n').slice(1); - if (!lines.length) throw new Error(`${path} has no coverage entries`); - for (const line of lines) { - const match = line.match(/^(.+):[0-9]+\.[0-9]+,[0-9]+\.[0-9]+ [0-9]+ [0-9]+$/); - if (!match || !match[1].startsWith(`${modulePath}/`)) throw new Error(`${path} has a malformed coverage entry`); - validateRelativeSource(match[1].slice(modulePath.length + 1), path); - } - } - return { path, size: stat.size, sha256: createHash('sha256').update(content).digest('hex') }; -} - -function git(...args) { - return execFileSync('git', args, { encoding: 'utf8' }).trim(); -} - -function maintenanceState(baseSha, candidateSha) { - const output = execFileSync('bash', ['scripts/ci/guard-control-plane.sh', baseSha, candidateSha, '--classify'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); - if (output === 'maintenance=true') return true; - if (output === 'maintenance=false') return false; - throw new Error(`unexpected control-plane classification: ${output}`); -} - -function securityBase(candidateSha, baseRef, pullRequest, eventBaseSha) { - if (pullRequest > 0) return validateSha(git('merge-base', candidateSha, eventBaseSha), 'security base'); - try { - return validateSha(git('merge-base', candidateSha, `origin/${baseRef}`), 'security base'); - } catch { - return '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - } -} - -function integer(name, allowZero = false) { - const value = required(name); - if (!/^[0-9]+$/.test(value) || (!allowZero && value === '0')) { - throw new Error(`${name} must be ${allowZero ? 'a non-negative' : 'a positive'} integer`); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) throw new Error(`${name} must be a safe integer`); - return parsed; -} - -function validateSha(value, name) { - if (!/^[0-9a-f]{40}$/.test(value)) throw new Error(`${name} must be a lowercase 40-character Git SHA`); - return value; -} - -function validateText(value, name) { - if (!value || /[\r\n\0]/.test(value)) throw new Error(`${name} is invalid`); - return value; -} - -function main() { - const candidateSha = validateSha(required('CANDIDATE_SHA'), 'CANDIDATE_SHA'); - const actualSha = git('rev-parse', 'HEAD'); - if (actualSha !== candidateSha) throw new Error(`checked-out SHA ${actualSha} != ${candidateSha}`); - if (git('status', '--porcelain=v1', '--untracked-files=all')) { - throw new Error('coverage generation changed tracked or non-ignored candidate files'); - } - const pullRequest = integer('PULL_REQUEST_NUMBER', true); - const workflowSha = validateSha(required('GITHUB_WORKFLOW_SHA'), 'GITHUB_WORKFLOW_SHA'); - const baseSha = validateSha(required('BASE_SHA'), 'BASE_SHA'); - const baseRef = validateText(required('BASE_REF'), 'BASE_REF'); - const securityBaseSha = securityBase(candidateSha, baseRef, pullRequest, baseSha); - - const manifest = { - schema_version: 1, - repository: validateText(required('GITHUB_REPOSITORY'), 'GITHUB_REPOSITORY'), - producer: { - workflow: validateText(required('GITHUB_WORKFLOW'), 'GITHUB_WORKFLOW'), - workflow_ref: validateText(required('GITHUB_WORKFLOW_REF'), 'GITHUB_WORKFLOW_REF'), - workflow_sha: workflowSha, - test_revision_sha: workflowSha, - run_id: integer('GITHUB_RUN_ID'), - run_attempt: integer('GITHUB_RUN_ATTEMPT'), - job: validateText(required('GITHUB_JOB'), 'GITHUB_JOB'), - event: validateText(required('GITHUB_EVENT_NAME'), 'GITHUB_EVENT_NAME'), - }, - candidate: { - repository: validateText(required('CANDIDATE_REPOSITORY'), 'CANDIDATE_REPOSITORY'), - sha: candidateSha, - tree: validateSha(git('show', '-s', '--format=%T', 'HEAD'), 'candidate tree'), - ref: validateText(required('CANDIDATE_REF'), 'CANDIDATE_REF'), - pull_request: pullRequest, - base_sha: baseSha, - base_ref: baseRef, - security_base_sha: securityBaseSha, - maintenance: maintenanceState(securityBaseSha, candidateSha), - }, - reports: Object.fromEntries(REPORTS.map(([kind, path, limit]) => [kind, validateReport(kind, path, limit)])), - tools: { - node: process.version, - npm: execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(), - go: execFileSync('go', ['version'], { encoding: 'utf8' }).trim(), - go_module: execFileSync('go', ['list', '-m'], { encoding: 'utf8' }).trim(), - }, - }; - - writeFileSync('coverage/manifest.json', `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' }); -} - -if (require.main === module) { - try { - main(); - } catch (error) { - console.error(`create-coverage-manifest: ${error.message}`); - process.exitCode = 1; - } -} - -module.exports = { validateReport }; diff --git a/scripts/ci/fetch-verified-base.sh b/scripts/ci/fetch-verified-base.sh deleted file mode 100644 index bdeca7a..0000000 --- a/scripts/ci/fetch-verified-base.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env sh -set -eu - -repository=${1:?usage: fetch-verified-base.sh OWNER/REPOSITORY SHA} -sha=${2:?usage: fetch-verified-base.sh OWNER/REPOSITORY SHA} - -owner=${repository%%/*} -name=${repository#*/} -if [ "$owner" = "$repository" ] || [ -z "$owner" ] || [ -z "$name" ] || [ "$name" != "${name#*/}" ]; then - echo 'invalid base repository' >&2 - exit 2 -fi -case "$owner$name" in - *[!A-Za-z0-9_.-]*) - echo 'invalid base repository' >&2 - exit 2 - ;; -esac -[ "${#sha}" -eq 40 ] || { - echo 'invalid base SHA' >&2 - exit 2 -} -case "$sha" in - *[!0-9a-f]*) - echo 'invalid base SHA' >&2 - exit 2 - ;; -esac - -base_url=${FETCH_BASE_URL:-https://github.com/$repository.git} -git fetch --no-tags "$base_url" "$sha" -test "$(git rev-parse FETCH_HEAD)" = "$sha" -git cat-file -e "$sha^{commit}" diff --git a/scripts/ci/guard-control-plane.sh b/scripts/ci/guard-control-plane.sh deleted file mode 100644 index e2e7e37..0000000 --- a/scripts/ci/guard-control-plane.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -eu - -base_sha=${1:?usage: guard-control-plane.sh BASE_SHA HEAD_SHA} -head_sha=${2:?usage: guard-control-plane.sh BASE_SHA HEAD_SHA} -mode=${3:-reject} -[ "$mode" = reject ] || [ "$mode" = --classify ] || { - echo "invalid mode: $mode" >&2 - exit 2 -} -changed=$(mktemp) -trap 'rm -f "$changed"' EXIT HUP INT TERM - -git diff --no-renames --name-only -z "$base_sha" "$head_sha" > "$changed" - -blocked=0 -unprotected=0 -while IFS= read -r -d '' path; do - case "$path" in - .github/workflows/*|.github/actions/*|.github/sonar/*|.github/CODEOWNERS|.github/dependabot.yml|sonar-project.properties|scripts/check-*|scripts/ci/*|scripts/ci_monitor.cjs|package.json|package-lock.json|npm-shrinkwrap.json|yarn.lock|pnpm-lock.yaml|bun.lock|bun.lockb|.npmrc|vite.config.*|vitest.config.*|go.mod|go.sum) - printf 'protected control-plane path changed: %s\n' "$path" >&2 - blocked=1 - ;; - *) - unprotected=1 - ;; - esac -done < "$changed" - -if [ "$blocked" -ne 0 ]; then - if [ "$mode" = --classify ]; then - if [ "$unprotected" -ne 0 ]; then - echo 'candidate rejected: maintenance PR mixes protected control-plane and non-control-plane paths' >&2 - exit 1 - fi - echo 'maintenance=true' - exit 0 - fi - echo 'candidate rejected: protected control-plane changes require a separate maintenance PR' >&2 - exit 1 -fi - -[ "$mode" != --classify ] || echo 'maintenance=false' diff --git a/scripts/ci/test-npm-ignore-scripts.sh b/scripts/ci/test-npm-ignore-scripts.sh deleted file mode 100644 index 2922bf2..0000000 --- a/scripts/ci/test-npm-ignore-scripts.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -set -eu - -test_dir=$(mktemp -d) -trap 'rm -rf "$test_dir"' EXIT HUP INT TERM -git archive HEAD | tar -x -C "$test_dir" - -node - "$test_dir/package.json" <<'NODE' -const { readFileSync, writeFileSync } = require('node:fs'); -const path = process.argv[2]; -const value = JSON.parse(readFileSync(path, 'utf8')); -if (value.scripts?.['test:unit'] !== 'vitest run') { - throw new Error('test:unit must run the complete Vitest suite'); -} -value.scripts.postinstall = 'node -e "require(\'node:fs\').writeFileSync(\'postinstall-ran\', \'bad\')"'; -writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); -NODE - -( - cd "$test_dir" - npm ci --ignore-scripts - test ! -e postinstall-ran - npm run test:unit - npm run build -) diff --git a/scripts/ci/test_ci_helpers.py b/scripts/ci/test_ci_helpers.py deleted file mode 100644 index 543ed4e..0000000 --- a/scripts/ci/test_ci_helpers.py +++ /dev/null @@ -1,356 +0,0 @@ -#!/usr/bin/env python3 - -import json -import os -import subprocess -import tempfile -import unittest -import shutil -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -DIFF = ROOT / "scripts/ci/check-event-diff.sh" -GUARD = ROOT / "scripts/ci/guard-control-plane.sh" -MANIFEST = ROOT / "scripts/ci/create-coverage-manifest.cjs" -TREE_GUARD = ROOT / "scripts/ci/validate-candidate-tree.sh" -FETCH_BASE = ROOT / "scripts/ci/fetch-verified-base.sh" -EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" - - -class DiffAndGuardTest(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.dir = Path(self.temp.name) - self.bare = self.dir / "origin.git" - self.repo = self.dir / "repo" - subprocess.run(["git", "init", "-q", "--bare", self.bare], check=True) - subprocess.run(["git", "init", "-q", "-b", "main", self.repo], check=True) - subprocess.run(["git", "-C", self.repo, "config", "user.email", "ci@example.invalid"], check=True) - subprocess.run(["git", "-C", self.repo, "config", "user.name", "CI"], check=True) - subprocess.run(["git", "-C", self.repo, "remote", "add", "origin", self.bare], check=True) - - def tearDown(self): - self.temp.cleanup() - - def commit(self, name, text): - (self.repo / name).write_text(text) - subprocess.run(["git", "-C", self.repo, "add", name], check=True) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", name], check=True) - return subprocess.check_output(["git", "-C", self.repo, "rev-parse", "HEAD"], text=True).strip() - - def validate_tree(self): - sha = subprocess.check_output(["git", "-C", self.repo, "rev-parse", "HEAD"], text=True).strip() - tree = subprocess.check_output(["git", "-C", self.repo, "show", "-s", "--format=%T", "HEAD"], text=True).strip() - return subprocess.run( - ["bash", str(TREE_GUARD), str(self.repo), sha, tree], - text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - ) - - def check(self, before, head, repo=None): - repo = repo or self.repo - event = self.dir / "event.json" - event.write_text(json.dumps({"before": before})) - summary = self.dir / "summary" - output = self.dir / "output" - env = os.environ | { - "GITHUB_EVENT_NAME": "push", "GITHUB_EVENT_PATH": str(event), "GITHUB_SHA": head, - "GITHUB_DEFAULT_BRANCH": "main", "GITHUB_STEP_SUMMARY": str(summary), "GITHUB_OUTPUT": str(output), - } - result = subprocess.run(["sh", str(DIFF)], cwd=repo, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return result, output.read_text() if output.exists() else "" - - def test_root_push_uses_empty_tree(self): - head = self.commit("root.txt", "root\n") - subprocess.run(["git", "-C", self.repo, "push", "-q", "origin", "main"], check=True) - result, output = self.check("0" * 40, head) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn(f"base={EMPTY_TREE}\n", output) - - def test_multi_commit_push_uses_event_before(self): - before = self.commit("one.txt", "one\n") - self.commit("two.txt", "two\n") - head = self.commit("three.txt", "three\n") - result, output = self.check(before, head) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn(f"base={before}\n", output) - - def test_force_push_compares_disconnected_tips(self): - before = self.commit("old.txt", "old\n") - subprocess.run(["git", "-C", self.repo, "push", "-q", "origin", "main"], check=True) - subprocess.run(["git", "-C", self.repo, "checkout", "-q", "--orphan", "replacement"], check=True) - subprocess.run(["git", "-C", self.repo, "rm", "-q", "-r", "--cached", "."], check=True) - (self.repo / "old.txt").unlink() - head = self.commit("new.txt", "new\n") - subprocess.run(["git", "-C", self.repo, "push", "-q", "--force", "origin", "HEAD:main"], check=True) - fresh = self.dir / "fresh" - subprocess.run(["git", "clone", "-q", "--branch", "main", self.bare, fresh], check=True) - result, output = self.check(before, head, fresh) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn(f"base={before}\n", output) - - def test_guard_blocks_codeowners(self): - base = self.commit("safe.txt", "safe\n") - (self.repo / ".github").mkdir() - head = self.commit(".github/CODEOWNERS", "* @owner\n") - result = subprocess.run(["bash", str(GUARD), base, head], cwd=self.repo, text=True, stderr=subprocess.PIPE) - self.assertNotEqual(result.returncode, 0) - self.assertIn("protected control-plane path changed", result.stderr) - - def test_guard_catches_protected_change_before_innocuous_tip(self): - base = self.commit("base.txt", "base\n") - (self.repo / ".github").mkdir() - self.commit(".github/dependabot.yml", "version: 2\nupdates: []\n") - head = self.commit("innocuous.txt", "later\n") - result = subprocess.run(["bash", str(GUARD), base, head], cwd=self.repo, text=True, stderr=subprocess.PIPE) - self.assertNotEqual(result.returncode, 0) - self.assertIn(".github/dependabot.yml", result.stderr) - - def test_same_repo_pr_can_classify_maintenance_without_trusting_it(self): - base = self.commit("base.txt", "base\n") - (self.repo / ".github").mkdir() - head = self.commit(".github/CODEOWNERS", "* @owner\n") - result = subprocess.run( - ["bash", str(GUARD), base, head, "--classify"], cwd=self.repo, - text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "maintenance=true") - - def test_behind_base_fork_fetches_verified_base_and_uses_merge_base(self): - root = self.commit("root.txt", "root\n") - subprocess.run(["git", "-C", self.repo, "push", "-q", "origin", "main"], check=True) - - fork_bare = self.dir / "fork.git" - subprocess.run(["git", "init", "-q", "--bare", fork_bare], check=True) - subprocess.run( - ["git", "-C", self.repo, "push", "-q", str(fork_bare), f"{root}:refs/heads/main"], check=True, - ) - base = self.commit("base-only.txt", "base\n") - subprocess.run(["git", "-C", self.repo, "push", "-q", "origin", "main"], check=True) - - fork = self.dir / "fork" - subprocess.run(["git", "clone", "-q", "--branch", "main", fork_bare, fork], check=True) - subprocess.run(["git", "-C", fork, "config", "user.email", "ci@example.invalid"], check=True) - subprocess.run(["git", "-C", fork, "config", "user.name", "CI"], check=True) - (fork / "feature.txt").write_text("feature\n") - subprocess.run(["git", "-C", fork, "add", "feature.txt"], check=True) - subprocess.run(["git", "-C", fork, "commit", "-qm", "feature"], check=True) - candidate = subprocess.check_output(["git", "-C", fork, "rev-parse", "HEAD"], text=True).strip() - - missing = subprocess.run( - ["git", "-C", fork, "cat-file", "-e", f"{base}^{{commit}}"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - self.assertNotEqual(missing.returncode, 0) - env = os.environ | {"FETCH_BASE_URL": f"file://{self.bare}"} - fetched = subprocess.run( - ["sh", str(FETCH_BASE), "RandomCodeSpace/kb", base], cwd=fork, env=env, - text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - ) - self.assertEqual(fetched.returncode, 0, fetched.stderr) - merge_base = subprocess.check_output( - ["git", "-C", fork, "merge-base", candidate, base], text=True, - ).strip() - self.assertEqual(merge_base, root) - changed = subprocess.check_output( - ["git", "-C", fork, "diff", "--name-only", merge_base, candidate], text=True, - ).splitlines() - self.assertEqual(changed, ["feature.txt"]) - - def test_ordinary_source_pr_classifies_non_maintenance(self): - base = self.commit("base.txt", "base\n") - (self.repo / "src").mkdir() - head = self.commit("src/product.ts", "export const value = 1;\n") - result = subprocess.run( - ["bash", str(GUARD), base, head, "--classify"], cwd=self.repo, - text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "maintenance=false") - - def test_mixed_control_plane_and_source_pr_fails_closed(self): - base = self.commit("base.txt", "base\n") - (self.repo / ".github/workflows").mkdir(parents=True) - (self.repo / "src").mkdir() - (self.repo / ".github/workflows/change.yml").write_text("name: changed\n") - (self.repo / "src/product.ts").write_text("export const value = 1;\n") - subprocess.run( - ["git", "-C", self.repo, "add", ".github/workflows/change.yml", "src/product.ts"], check=True, - ) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", "mixed"], check=True) - head = subprocess.check_output(["git", "-C", self.repo, "rev-parse", "HEAD"], text=True).strip() - result = subprocess.run( - ["bash", str(GUARD), base, head, "--classify"], cwd=self.repo, - text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn("mixes protected control-plane and non-control-plane paths", result.stderr) - - def test_protected_to_unprotected_rename_cannot_hide_source_path(self): - (self.repo / ".github/workflows").mkdir(parents=True) - base = self.commit(".github/workflows/quality.yml", "name: protected\n") - (self.repo / "src").mkdir() - subprocess.run(["git", "-C", self.repo, "mv", ".github/workflows/quality.yml", "src/quality.yml"], check=True) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", "rename out"], check=True) - head = subprocess.check_output(["git", "-C", self.repo, "rev-parse", "HEAD"], text=True).strip() - for mode in ([], ["--classify"]): - result = subprocess.run(["bash", str(GUARD), base, head, *mode], cwd=self.repo, text=True, stderr=subprocess.PIPE) - self.assertNotEqual(result.returncode, 0) - - def test_unprotected_to_protected_rename_cannot_hide_destination(self): - (self.repo / "src").mkdir() - base = self.commit("src/quality.yml", "name: source\n") - (self.repo / ".github/workflows").mkdir(parents=True) - subprocess.run(["git", "-C", self.repo, "mv", "src/quality.yml", ".github/workflows/quality.yml"], check=True) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", "rename in"], check=True) - head = subprocess.check_output(["git", "-C", self.repo, "rev-parse", "HEAD"], text=True).strip() - for mode in ([], ["--classify"]): - result = subprocess.run(["bash", str(GUARD), base, head, *mode], cwd=self.repo, text=True, stderr=subprocess.PIPE) - self.assertNotEqual(result.returncode, 0) - - def test_relative_and_absolute_tracked_symlinks_are_rejected(self): - self.commit("target.txt", "target\n") - for link_target in ("target.txt", "/tmp/host-target"): - link = self.repo / "tracked-link" - if link.exists() or link.is_symlink(): - link.unlink() - os.symlink(link_target, link) - subprocess.run(["git", "-C", self.repo, "add", "tracked-link"], check=True) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", f"symlink {link_target}"], check=True) - result = self.validate_tree() - self.assertNotEqual(result.returncode, 0) - self.assertIn("tracked symlink rejected", result.stderr) - subprocess.run(["git", "-C", self.repo, "rm", "-q", "tracked-link"], check=True) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", "remove symlink"], check=True) - - def test_gitlink_is_rejected(self): - commit_sha = self.commit("base.txt", "base\n") - subprocess.run( - ["git", "-C", self.repo, "update-index", "--add", "--cacheinfo", f"160000,{commit_sha},vendor/module"], - check=True, - ) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", "gitlink"], check=True) - result = self.validate_tree() - self.assertNotEqual(result.returncode, 0) - self.assertIn("gitlink/submodule rejected", result.stderr) - - def test_manifest_generation_binds_real_git_identity(self): - (self.repo / "src").mkdir() - (self.repo / "internal").mkdir() - (self.repo / "coverage").mkdir() - (self.repo / "scripts/ci").mkdir(parents=True) - shutil.copy2(GUARD, self.repo / "scripts/ci/guard-control-plane.sh") - (self.repo / "go.mod").write_text("module github.com/RandomCodeSpace/kb\n\ngo 1.24\n") - (self.repo / ".gitignore").write_text("/coverage/\n") - (self.repo / "src/a.ts").write_text("export const a = 1;\n") - (self.repo / "internal/a.go").write_text("package internal\n") - (self.repo / "coverage/lcov.info").write_text("TN:\nSF:src/a.ts\nDA:1,1\nend_of_record\n") - (self.repo / "coverage/go.out").write_text("mode: atomic\ngithub.com/RandomCodeSpace/kb/internal/a.go:1.1,1.2 1 1\n") - subprocess.run(["git", "-C", self.repo, "add", ".gitignore", "go.mod", "src/a.ts", "internal/a.go", "scripts/ci/guard-control-plane.sh"], check=True) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", "fixture"], check=True) - sha = subprocess.check_output(["git", "-C", self.repo, "rev-parse", "HEAD"], text=True).strip() - env = os.environ | { - "CANDIDATE_SHA": sha, "CANDIDATE_REPOSITORY": "RandomCodeSpace/kb", "CANDIDATE_REF": "feature", - "PULL_REQUEST_NUMBER": "7", "BASE_SHA": sha, "BASE_REF": "main", - "GITHUB_REPOSITORY": "RandomCodeSpace/kb", "GITHUB_WORKFLOW": "Regression and candidate coverage", - "GITHUB_WORKFLOW_REF": "RandomCodeSpace/kb/.github/workflows/quality.yml@refs/pull/7/merge", - "GITHUB_WORKFLOW_SHA": sha, "GITHUB_RUN_ID": "10", "GITHUB_RUN_ATTEMPT": "1", - "GITHUB_JOB": "candidate_coverage", "GITHUB_EVENT_NAME": "pull_request", - } - result = subprocess.run(["node", str(MANIFEST)], cwd=self.repo, env=env, text=True, stderr=subprocess.PIPE) - self.assertEqual(result.returncode, 0, result.stderr) - manifest = json.loads((self.repo / "coverage/manifest.json").read_text()) - self.assertEqual(manifest["candidate"]["sha"], sha) - self.assertEqual(manifest["producer"]["workflow_sha"], sha) - self.assertFalse(manifest["candidate"]["maintenance"]) - subprocess.run( - ["git", "-C", self.repo, "push", "-q", "origin", f"{sha}:refs/heads/main"], check=True, - ) - - (self.repo / "coverage/manifest.json").unlink() - (self.repo / "src/a.ts").write_text("export const a = 2;\n") - dirty = subprocess.run(["node", str(MANIFEST)], cwd=self.repo, env=env, text=True, stderr=subprocess.PIPE) - self.assertNotEqual(dirty.returncode, 0) - self.assertIn("changed tracked or non-ignored", dirty.stderr) - subprocess.run(["git", "-C", self.repo, "checkout", "--", "src/a.ts"], check=True) - - (self.repo / ".github").mkdir() - (self.repo / ".github/CODEOWNERS").write_text("* @owner\n") - subprocess.run(["git", "-C", self.repo, "add", ".github/CODEOWNERS"], check=True) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", "maintenance"], check=True) - maintenance_sha = subprocess.check_output(["git", "-C", self.repo, "rev-parse", "HEAD"], text=True).strip() - maintenance_env = env | { - "CANDIDATE_SHA": maintenance_sha, - "BASE_SHA": sha, - "GITHUB_WORKFLOW_SHA": maintenance_sha, - } - result = subprocess.run(["node", str(MANIFEST)], cwd=self.repo, env=maintenance_env, text=True, stderr=subprocess.PIPE) - self.assertEqual(result.returncode, 0, result.stderr) - manifest = json.loads((self.repo / "coverage/manifest.json").read_text()) - self.assertTrue(manifest["candidate"]["maintenance"]) - - (self.repo / "coverage/manifest.json").unlink() - maintenance_push_env = maintenance_env | { - "PULL_REQUEST_NUMBER": "0", - "GITHUB_EVENT_NAME": "push", - "GITHUB_WORKFLOW_REF": "RandomCodeSpace/kb/.github/workflows/quality.yml@refs/heads/maintenance", - } - result = subprocess.run( - ["node", str(MANIFEST)], cwd=self.repo, env=maintenance_push_env, text=True, stderr=subprocess.PIPE, - ) - self.assertEqual(result.returncode, 0, result.stderr) - manifest = json.loads((self.repo / "coverage/manifest.json").read_text()) - self.assertEqual(manifest["candidate"]["security_base_sha"], sha) - self.assertTrue(manifest["candidate"]["maintenance"]) - - def test_push_and_dispatch_manifest_preserve_event_base(self): - (self.repo / "src").mkdir() - (self.repo / "internal").mkdir() - (self.repo / "coverage").mkdir() - (self.repo / "scripts/ci").mkdir(parents=True) - shutil.copy2(GUARD, self.repo / "scripts/ci/guard-control-plane.sh") - (self.repo / ".gitignore").write_text("/coverage/\n") - (self.repo / "go.mod").write_text("module github.com/RandomCodeSpace/kb\n\ngo 1.24\n") - (self.repo / "src/a.ts").write_text("export const a = 1;\n") - (self.repo / "internal/a.go").write_text("package internal\n") - subprocess.run( - [ - "git", "-C", self.repo, "add", ".gitignore", "go.mod", "src/a.ts", "internal/a.go", - "scripts/ci/guard-control-plane.sh", - ], - check=True, - ) - subprocess.run(["git", "-C", self.repo, "commit", "-qm", "base"], check=True) - event_base = subprocess.check_output(["git", "-C", self.repo, "rev-parse", "HEAD"], text=True).strip() - subprocess.run(["git", "-C", self.repo, "push", "-q", "origin", "HEAD:main"], check=True) - self.commit("second.txt", "second\n") - head = self.commit("third.txt", "third\n") - (self.repo / "coverage/lcov.info").write_text("TN:\nSF:src/a.ts\nDA:1,1\nend_of_record\n") - (self.repo / "coverage/go.out").write_text("mode: atomic\ngithub.com/RandomCodeSpace/kb/internal/a.go:1.1,1.2 1 1\n") - env = os.environ | { - "CANDIDATE_SHA": head, "CANDIDATE_REPOSITORY": "RandomCodeSpace/kb", "CANDIDATE_REF": "feature", - "PULL_REQUEST_NUMBER": "0", "BASE_SHA": event_base, "BASE_REF": "main", - "GITHUB_REPOSITORY": "RandomCodeSpace/kb", "GITHUB_WORKFLOW": "Regression and candidate coverage", - "GITHUB_WORKFLOW_REF": "RandomCodeSpace/kb/.github/workflows/quality.yml@refs/heads/feature", - "GITHUB_WORKFLOW_SHA": head, "GITHUB_RUN_ID": "10", "GITHUB_RUN_ATTEMPT": "1", - "GITHUB_JOB": "candidate_coverage", "GITHUB_EVENT_NAME": "push", - } - result = subprocess.run(["node", str(MANIFEST)], cwd=self.repo, env=env, text=True, stderr=subprocess.PIPE) - self.assertEqual(result.returncode, 0, result.stderr) - manifest = json.loads((self.repo / "coverage/manifest.json").read_text()) - self.assertEqual(manifest["candidate"]["base_sha"], event_base) - self.assertNotEqual(manifest["candidate"]["base_sha"], subprocess.check_output(["git", "-C", self.repo, "rev-parse", "HEAD^"], text=True).strip()) - self.assertEqual(manifest["candidate"]["security_base_sha"], event_base) - - for event_name, event_sha in (("push", EMPTY_TREE), ("workflow_dispatch", event_base)): - (self.repo / "coverage/manifest.json").unlink() - event_env = env | {"GITHUB_EVENT_NAME": event_name, "BASE_SHA": event_sha} - result = subprocess.run(["node", str(MANIFEST)], cwd=self.repo, env=event_env, text=True, stderr=subprocess.PIPE) - self.assertEqual(result.returncode, 0, result.stderr) - manifest = json.loads((self.repo / "coverage/manifest.json").read_text()) - self.assertEqual(manifest["candidate"]["base_sha"], event_sha) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_validate_coverage_artifact.py b/scripts/ci/test_validate_coverage_artifact.py deleted file mode 100644 index 4a460c1..0000000 --- a/scripts/ci/test_validate_coverage_artifact.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env python3 - -import hashlib -import json -import os -import stat -import subprocess -import tempfile -import unittest -import zipfile -from pathlib import Path - -SCRIPT = Path(__file__).with_name("validate-coverage-artifact.py") -SHA = "1" * 40 -TREE = "2" * 40 -BASE = "3" * 40 -WORKFLOW_SHA = "4" * 40 - - -class ArtifactValidationTest(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.root = Path(self.temp.name) - (self.root / "src").mkdir() - (self.root / "internal").mkdir() - (self.root / "src/a.ts").write_text("export const a = 1;\n") - (self.root / "-tracked.ts").write_text("export const tracked = true;\n") - (self.root / "internal/a.go").write_text("package internal\n") - (self.root / "go.mod").write_text("module github.com/RandomCodeSpace/kb\n\ngo 1.24\n") - subprocess.run(["git", "init", "-q", self.root], check=True) - subprocess.run(["git", "-C", self.root, "config", "user.email", "ci@example.invalid"], check=True) - subprocess.run(["git", "-C", self.root, "config", "user.name", "CI"], check=True) - subprocess.run(["git", "-C", self.root, "add", "--", "src/a.ts", "-tracked.ts", "internal/a.go", "go.mod"], check=True) - subprocess.run(["git", "-C", self.root, "commit", "-qm", "fixture"], check=True) - self.base = subprocess.check_output(["git", "-C", self.root, "rev-parse", "HEAD"], text=True).strip() - self.lcov = b"TN:\nSF:src/a.ts\nDA:1,1\nend_of_record\n" - self.go = b"mode: atomic\ngithub.com/RandomCodeSpace/kb/internal/a.go:1.1,1.2 1 1\n" - - def tearDown(self): - self.temp.cleanup() - - def manifest(self): - return { - "schema_version": 1, - "repository": "RandomCodeSpace/kb", - "producer": { - "workflow": "Regression and candidate coverage", - "workflow_ref": "RandomCodeSpace/kb/.github/workflows/quality.yml@refs/pull/7/merge", - "workflow_sha": WORKFLOW_SHA, - "test_revision_sha": WORKFLOW_SHA, - "run_id": 10, - "run_attempt": 1, - "job": "candidate_coverage", - "event": "pull_request", - }, - "candidate": { - "repository": "RandomCodeSpace/kb", "sha": SHA, "tree": TREE, - "ref": "feature", "pull_request": 7, "base_sha": self.base, "base_ref": "main", - "maintenance": False, - "security_base_sha": self.base, - }, - "reports": { - "frontend": {"path": "coverage/lcov.info", "size": len(self.lcov), "sha256": hashlib.sha256(self.lcov).hexdigest()}, - "go": {"path": "coverage/go.out", "size": len(self.go), "sha256": hashlib.sha256(self.go).hexdigest()}, - }, - "tools": {"node": "v24", "npm": "11", "go": "go1.26", "go_module": "github.com/RandomCodeSpace/kb"}, - } - - def archive(self, *, lcov=None, go=None, manifest_bytes=None, symlink=False): - path = self.root / f"artifact-{os.urandom(4).hex()}.zip" - manifest = self.manifest() - lcov = self.lcov if lcov is None else lcov - go = self.go if go is None else go - if lcov != self.lcov: - manifest["reports"]["frontend"] = {"path": "coverage/lcov.info", "size": len(lcov), "sha256": hashlib.sha256(lcov).hexdigest()} - if go != self.go: - manifest["reports"]["go"] = {"path": "coverage/go.out", "size": len(go), "sha256": hashlib.sha256(go).hexdigest()} - manifest_bytes = json.dumps(manifest).encode() if manifest_bytes is None else manifest_bytes - with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: - info = zipfile.ZipInfo("lcov.info") - info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = ((stat.S_IFLNK if symlink else stat.S_IFREG) | 0o600) << 16 - archive.writestr(info, lcov) - archive.writestr("go.out", go) - archive.writestr("manifest.json", manifest_bytes) - return path - - def run_validator(self, archive, output=None, *, candidate_sha=SHA, base_sha=None, event="pull_request", pull_request=7): - output = output or self.root / f"out-{os.urandom(4).hex()}" - base_sha = self.base if base_sha is None else base_sha - args = [ - "python3", str(SCRIPT), "--archive", str(archive), "--output", str(output), - "--repository", "RandomCodeSpace/kb", "--workflow", "Regression and candidate coverage", - "--workflow-ref", "RandomCodeSpace/kb/.github/workflows/quality.yml@refs/pull/7/merge", "--workflow-sha", WORKFLOW_SHA, - "--test-revision-sha", WORKFLOW_SHA, - "--run-id", "10", "--run-attempt", "1", "--event", event, - "--candidate-repository", "RandomCodeSpace/kb", f"--candidate-sha={candidate_sha}", - "--candidate-tree", TREE, "--candidate-ref", "feature", "--pull-request", str(pull_request), - f"--base-sha={base_sha}", "--security-base-sha", self.base, "--base-ref", "main", "--repository-root", str(self.root), - "--maintenance", "false", - ] - return subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - def test_accepts_exact_bundle(self): - self.assertEqual(self.run_validator(self.archive()).returncode, 0) - - def test_accepts_tracked_source_with_option_like_name(self): - lcov = b"TN:\nSF:-tracked.ts\nDA:1,1\nend_of_record\n" - self.assertEqual(self.run_validator(self.archive(lcov=lcov)).returncode, 0) - - def test_accepts_valid_push_revision_boundary(self): - manifest = self.manifest() - manifest["producer"]["event"] = "push" - manifest["candidate"]["sha"] = self.base - manifest["candidate"]["pull_request"] = 0 - result = self.run_validator( - self.archive(manifest_bytes=json.dumps(manifest).encode()), - candidate_sha=self.base, - event="push", - pull_request=0, - ) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_rejects_option_like_traversal_and_control_git_revisions(self): - for field, value in ( - ("sha", "--help"), - ("base_sha", "../HEAD"), - ("sha", "1" * 39 + "\n"), - ): - with self.subTest(field=field, value=value): - manifest = self.manifest() - manifest["candidate"][field] = value - result = self.run_validator( - self.archive(manifest_bytes=json.dumps(manifest).encode()), - candidate_sha=value if field == "sha" else SHA, - base_sha=value if field == "base_sha" else self.base, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn("must be a lowercase 40-character Git SHA", result.stderr) - - def test_rejects_traversal_and_url_sources(self): - for source in ("../src/a.ts", "/src/a.ts", "https://evil.invalid/a.ts", "src//a.ts"): - with self.subTest(source=source): - result = self.run_validator(self.archive(lcov=f"TN:\nSF:{source}\nDA:1,1\nend_of_record\n".encode())) - self.assertNotEqual(result.returncode, 0) - - def test_rejects_untracked_source(self): - (self.root / "src/untracked.ts").write_text("x") - result = self.run_validator(self.archive(lcov=b"TN:\nSF:src/untracked.ts\nDA:1,1\nend_of_record\n")) - self.assertNotEqual(result.returncode, 0) - - def test_rejects_duplicate_json_key(self): - raw = json.dumps(self.manifest()).encode().replace(b'"schema_version": 1', b'"schema_version": 1, "schema_version": 1', 1) - self.assertNotEqual(self.run_validator(self.archive(manifest_bytes=raw)).returncode, 0) - - def test_rejects_symlink_entry(self): - self.assertNotEqual(self.run_validator(self.archive(symlink=True)).returncode, 0) - - def test_rejects_go_traversal(self): - go = b"mode: atomic\ngithub.com/RandomCodeSpace/kb/../go.mod:1.1,1.2 1 1\n" - self.assertNotEqual(self.run_validator(self.archive(go=go)).returncode, 0) - - def test_rejects_missing_extra_and_duplicate_entries(self): - cases = [] - missing = self.root / "missing.zip" - with zipfile.ZipFile(missing, "w") as archive: - archive.writestr("lcov.info", self.lcov) - archive.writestr("go.out", self.go) - cases.append(missing) - extra = self.archive() - with zipfile.ZipFile(extra, "a") as archive: - archive.writestr("extra.txt", b"no") - cases.append(extra) - duplicate = self.archive() - with self.assertWarns(UserWarning): - with zipfile.ZipFile(duplicate, "a") as archive: - archive.writestr("go.out", self.go) - cases.append(duplicate) - for archive in cases: - with self.subTest(archive=archive.name): - self.assertNotEqual(self.run_validator(archive).returncode, 0) - - def test_rejects_empty_oversize_and_high_ratio_entries(self): - for lcov in (b"", b"x" * (5 * 1024 * 1024 + 1), b"TN:\nSF:src/a.ts\nDA:1,1\n" + b"A" * 1024 * 1024): - with self.subTest(size=len(lcov)): - self.assertNotEqual(self.run_validator(self.archive(lcov=lcov)).returncode, 0) - - def test_rejects_wrong_hash_size_and_metadata(self): - for mutation in ("hash", "size", "repository", "ref", "maintenance"): - manifest = self.manifest() - if mutation == "hash": manifest["reports"]["frontend"]["sha256"] = "0" * 64 - if mutation == "size": manifest["reports"]["frontend"]["size"] += 1 - if mutation == "repository": manifest["repository"] = "evil/repo" - if mutation == "ref": manifest["candidate"]["ref"] = "other" - if mutation == "maintenance": manifest["candidate"]["maintenance"] = True - with self.subTest(mutation=mutation): - self.assertNotEqual(self.run_validator(self.archive(manifest_bytes=json.dumps(manifest).encode())).returncode, 0) - - def test_rejects_control_bytes_and_invalid_utf8(self): - for lcov in (b"TN:\r\nSF:src/a.ts\r\nDA:1,1\r\n", b"TN:\nSF:src/a.ts\0\nDA:1,1\n", b"TN:\nSF:\xff\nDA:1,1\n"): - with self.subTest(lcov=lcov): - self.assertNotEqual(self.run_validator(self.archive(lcov=lcov)).returncode, 0) - - def test_rejects_existing_output_directory(self): - output = self.root / "already-exists" - output.mkdir() - self.assertNotEqual(self.run_validator(self.archive(), output).returncode, 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_validate_workflow_run.cjs b/scripts/ci/test_validate_workflow_run.cjs deleted file mode 100644 index affbf39..0000000 --- a/scripts/ci/test_validate_workflow_run.cjs +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env node - -const assert = require('node:assert/strict'); -const { createHash } = require('node:crypto'); -const { createServer } = require('node:http'); -const fs = require('node:fs'); -const { mkdirSync, mkdtempSync, readFileSync, writeFileSync } = fs; -const { tmpdir } = require('node:os'); -const { join } = require('node:path'); -const { spawn } = require('node:child_process'); - -const repository = 'RandomCodeSpace/kb'; -const candidateRepository = 'ExampleContributor/kb'; -const head = '1'.repeat(40); -const tree = '2'.repeat(40); -const base = '3'.repeat(40); -const merge = '4'.repeat(40); -const currentBase = '5'.repeat(40); -const runId = 10; -const jobId = 20; -const artifactId = 30; -const temp = mkdtempSync(join(tmpdir(), 'workflow-run-')); -const eventPath = join(temp, 'event.json'); -const outputPath = join(temp, 'output'); -const requests = []; - -const event = { - repository: { full_name: repository, default_branch: 'main' }, - workflow_run: { - id: runId, workflow_id: 5, run_attempt: 1, name: 'Regression and candidate coverage', - path: '.github/workflows/quality.yml', event: 'pull_request', status: 'completed', conclusion: 'success', - head_sha: head, head_branch: 'feature', repository: { full_name: repository }, - head_repository: { id: 99, full_name: candidateRepository }, - }, -}; -writeFileSync(eventPath, JSON.stringify(event)); - -const fixtures = new Map([ - ['/repos/RandomCodeSpace/kb/actions/workflows/quality.yml', { - id: 5, path: '.github/workflows/quality.yml', name: 'Regression and candidate coverage', state: 'active', - }], - [`/repos/RandomCodeSpace/kb/actions/runs/${runId}`, { - id: runId, workflow_id: 5, run_attempt: 1, name: event.workflow_run.name, path: event.workflow_run.path, - event: 'pull_request', status: 'completed', conclusion: 'success', head_sha: head, - head_repository: { full_name: candidateRepository }, - pull_requests: [{ number: 7, head: { sha: head, ref: 'feature', repo: { full_name: candidateRepository } }, base: { sha: base, ref: 'main' } }], - }], - [`/repos/RandomCodeSpace/kb/actions/runs/${runId}/jobs?filter=latest&per_page=100`, { - jobs: [{ id: jobId, run_id: runId, run_attempt: 1, name: 'Candidate head coverage', status: 'completed', conclusion: 'success', head_sha: head }], - }], - [`/repos/RandomCodeSpace/kb/actions/runs/${runId}/artifacts?per_page=100`, { - total_count: 1, - artifacts: [{ id: artifactId, name: `candidate-head-coverage-${head}`, expired: false, size_in_bytes: 1000, - workflow_run: { id: runId, head_sha: head, head_repository_id: 99 } }], - }], - [`/repos/ExampleContributor/kb/git/commits/${head}`, { sha: head, tree: { sha: tree }, parents: [{ sha: base }] }], - ['/repos/RandomCodeSpace/kb/pulls/7', { - head: { sha: head, ref: 'feature', repo: { full_name: candidateRepository } }, - base: { sha: currentBase, ref: 'main', repo: { full_name: repository } }, - merge_commit_sha: merge, - }], -]); - -const server = createServer((request, response) => { - requests.push(request.url); - const fixture = fixtures.get(request.url); - response.writeHead(fixture ? 200 : 404, { 'content-type': 'application/json' }); - response.end(JSON.stringify(fixture || { message: 'not found' })); -}); - -function runValidator(envOverrides = {}) { - const child = spawn(process.execPath, [join(__dirname, 'validate-workflow-run.cjs')], { - env: { - ...process.env, - GITHUB_API_URL: `http://127.0.0.1:${server.address().port}`, - GITHUB_EVENT_PATH: eventPath, - GITHUB_OUTPUT: outputPath, - GITHUB_REPOSITORY: repository, - GITHUB_TOKEN: 'test-token', - TRIGGER_RUN_ID: String(runId), - TRIGGER_WORKFLOW_ID: '5', - TRIGGER_RUN_ATTEMPT: '1', - TRIGGER_HEAD_REPOSITORY_ID: '99', - TRIGGER_HEAD_SHA: head, - ...envOverrides, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stderr = ''; - child.stderr.on('data', (chunk) => { stderr += chunk; }); - return new Promise((resolve) => child.on('close', (code) => resolve({ code, stderr }))); -} - -async function runWorkflowTests() { - const valid = await runValidator(); - assert.equal(valid.code, 0, valid.stderr); - const output = readFileSync(outputPath, 'utf8'); - assert.match(output, new RegExp(`candidate_sha=${head}`)); - assert.match(output, /workflow_ref=RandomCodeSpace\/kb\/\.github\/workflows\/quality\.yml@refs\/pull\/7\/merge/); - assert.match(output, new RegExp(`workflow_sha=${merge}`)); - assert.match(output, /pull_request=7/); - assert.match(output, new RegExp(`base_sha=${base}`)); - assert.match(output, /candidate_repository=ExampleContributor\/kb/); - - const requestCount = requests.length; - const hostileEnvironmentCases = [ - [{ TRIGGER_RUN_ID: `${runId}/../admin` }, /TRIGGER_RUN_ID must be a positive integer/], - [{ TRIGGER_RUN_ID: '9007199254740992' }, /TRIGGER_RUN_ID must be a positive safe integer/], - [{ GITHUB_REPOSITORY: 'RandomCodeSpace/kb/../admin' }, /GITHUB_REPOSITORY is invalid/], - [{ GITHUB_API_URL: `http://127.0.0.1:${server.address().port}//` }, /GITHUB_API_URL is invalid/], - ]; - for (const [envOverrides, expectedError] of hostileEnvironmentCases) { - const hostile = await runValidator(envOverrides); - assert.notEqual(hostile.code, 0); - assert.match(hostile.stderr, expectedError); - assert.equal(requests.length, requestCount, 'invalid API path identifier must not reach the network'); - } - - event.workflow_run.head_repository.full_name = 'ExampleContributor/kb/../../admin'; - writeFileSync(eventPath, JSON.stringify(event)); - const injectedRepository = await runValidator(); - assert.notEqual(injectedRepository.code, 0); - assert.match(injectedRepository.stderr, /candidate repository is invalid/); - assert.equal(requests.length, requestCount, 'injected repository path must not reach the network'); - - event.workflow_run.head_repository.full_name = candidateRepository; - event.workflow_run.id = runId + 1; - writeFileSync(eventPath, JSON.stringify(event)); - const mismatchedRun = await runValidator(); - assert.notEqual(mismatchedRun.code, 0); - assert.match(mismatchedRun.stderr, /trusted workflow run id mismatch/); - assert.equal(requests.length, requestCount, 'mismatched event id must not reach the network'); -} - -server.listen(0, '127.0.0.1', () => { - runWorkflowTests() - .then(testManifestDescriptorReads) - .then(testSonarOriginPinning) - .then(() => server.close(() => console.log('CI JavaScript hostile fixtures passed'))) - .catch((error) => server.close(() => { - console.error(error); - process.exitCode = 1; - })); -}); - -async function testManifestDescriptorReads() { - const fixture = join(temp, 'manifest-fixture'); - mkdirSync(join(fixture, 'coverage'), { recursive: true }); - mkdirSync(join(fixture, 'src'), { recursive: true }); - const report = 'TN:\nSF:src/a.ts\nDA:1,1\nend_of_record\n'; - writeFileSync(join(fixture, 'coverage/lcov.info'), report); - writeFileSync(join(fixture, 'src/a.ts'), 'export const a = 1;\n'); - const originalCwd = process.cwd(); - const originalReadFileSync = fs.readFileSync; - try { - fs.readFileSync = (target, ...args) => { - if (target === 'coverage/lcov.info') throw new Error('report path reopened after validation'); - return originalReadFileSync(target, ...args); - }; - delete require.cache[require.resolve('./create-coverage-manifest.cjs')]; - const { validateReport } = require('./create-coverage-manifest.cjs'); - process.chdir(fixture); - const result = validateReport('frontend', 'coverage/lcov.info', 1024); - assert.equal(result.size, Buffer.byteLength(report)); - assert.equal(result.sha256, createHash('sha256').update(report).digest('hex')); - } finally { - process.chdir(originalCwd); - fs.readFileSync = originalReadFileSync; - delete require.cache[require.resolve('./create-coverage-manifest.cjs')]; - } - - const originalFstatSync = fs.fstatSync; - let fstatCalls = 0; - try { - fs.fstatSync = (descriptor) => { - const stat = originalFstatSync(descriptor); - fstatCalls += 1; - if (fstatCalls === 2) return { ...stat, size: stat.size + 1, isFile: () => stat.isFile() }; - return stat; - }; - delete require.cache[require.resolve('./create-coverage-manifest.cjs')]; - const { validateReport } = require('./create-coverage-manifest.cjs'); - process.chdir(fixture); - assert.throws(() => validateReport('frontend', 'coverage/lcov.info', 1024), /changed size while being read/); - } finally { - process.chdir(originalCwd); - fs.fstatSync = originalFstatSync; - delete require.cache[require.resolve('./create-coverage-manifest.cjs')]; - } -} - -async function testSonarOriginPinning() { - const project = join(temp, 'sonar-project'); - mkdirSync(join(project, '.scannerwork'), { recursive: true }); - const reportPath = join(project, '.scannerwork/report-task.txt'); - process.env.PROJECT_BASE_DIR = project; - process.env.CANDIDATE_SHA = head; - process.env.SONAR_TOKEN = 'test-token'; - process.env.SONAR_PROJECT_KEY = 'RandomCodeSpace_kb'; - process.env.ANALYSIS_MODE = 'branch'; - process.env.CANDIDATE_REF = 'feature'; - const seen = []; - global.fetch = async (url) => { - const parsed = new URL(url); - seen.push(parsed.href); - const body = parsed.pathname === '/api/ce/task' - ? { task: { status: 'SUCCESS', analysisId: 'analysis-1', componentKey: process.env.SONAR_PROJECT_KEY } } - : { analyses: [{ key: 'analysis-1', revision: head }] }; - return { ok: true, json: async () => body }; - }; - const { main: verifySonarTask } = require('../../.github/actions/protected-sonar/verify-sonar-task.cjs'); - writeFileSync(reportPath, 'serverUrl=https://sonarcloud.io/untrusted/path\nceTaskId=task-1\n'); - await verifySonarTask(); - assert.equal(seen.length, 2); - assert.ok(seen.every((url) => new URL(url).origin === 'https://sonarcloud.io')); - - const requestCount = seen.length; - writeFileSync(reportPath, 'serverUrl=https://sonarcloud.io.example.invalid\nceTaskId=task-1\n'); - await assert.rejects(verifySonarTask(), /unexpected Sonar server URL/); - assert.equal(seen.length, requestCount, 'untrusted report origin must not reach the network'); -} diff --git a/scripts/ci/test_workflow_structure.py b/scripts/ci/test_workflow_structure.py deleted file mode 100644 index 189c646..0000000 --- a/scripts/ci/test_workflow_structure.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 - -import json -import re -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -QUALITY = (ROOT / ".github/workflows/quality.yml").read_text() -SONAR = (ROOT / ".github/workflows/sonar-exact-revision.yml").read_text() -PROTECTED_SONAR = (ROOT / ".github/actions/protected-sonar/action.yml").read_text() -SONAR_PROPERTIES = (ROOT / "sonar-project.properties").read_text() -PACKAGE = json.loads((ROOT / "package.json").read_text()) - - -class WorkflowStructureTest(unittest.TestCase): - def test_privileged_permissions_are_read_only(self): - permissions = re.search(r"^permissions:\n((?: .+\n)+)", SONAR, re.MULTILINE) - self.assertIsNotNone(permissions) - self.assertEqual(permissions.group(1), " actions: read\n contents: read\n") - self.assertNotIn("checks: write", SONAR) - self.assertNotIn("statuses: write", SONAR) - self.assertNotIn("pull_request_target", SONAR) - - def test_token_is_referenced_only_on_scan_step(self): - self.assertEqual(SONAR.count("SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}"), 1) - scan_step = SONAR.index("- name: Scan and verify exact Sonar revision") - self.assertGreater(SONAR.index("SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}"), scan_step) - - def test_privileged_workflow_executes_only_trusted_helpers(self): - self.assertNotRegex(SONAR, r"(?:sh|bash|node|python3) candidate/scripts/") - self.assertIn("node trusted-main/scripts/ci/validate-workflow-run.cjs", SONAR) - self.assertIn("python3 trusted-main/scripts/ci/validate-coverage-artifact.py", SONAR) - self.assertIn("uses: ./trusted-main/.github/actions/protected-sonar", SONAR) - - def test_trigger_network_identifiers_are_passed_as_trusted_scalars(self): - for name, expression in ( - ("TRIGGER_RUN_ID", "github.event.workflow_run.id"), - ("TRIGGER_WORKFLOW_ID", "github.event.workflow_run.workflow_id"), - ("TRIGGER_RUN_ATTEMPT", "github.event.workflow_run.run_attempt"), - ("TRIGGER_HEAD_REPOSITORY_ID", "github.event.workflow_run.head_repository.id"), - ("TRIGGER_HEAD_SHA", "github.event.workflow_run.head_sha"), - ): - self.assertIn(f"{name}: ${{{{ {expression} }}}}", SONAR) - - def test_control_plane_is_pinned_across_environment_approval(self): - self.assertIn("ref: ${{ github.workflow_sha }}", SONAR) - self.assertIn("control_plane_sha: ${{ steps.control-plane.outputs.sha }}", SONAR) - self.assertIn("test_revision_sha: ${{ steps.run.outputs.workflow_sha }}", SONAR) - self.assertIn("ref: ${{ needs.validate-candidate.outputs.control_plane_sha }}", SONAR) - self.assertIn('test "$actual_sha" = "$EXPECTED_CONTROL_PLANE_SHA"', SONAR) - self.assertNotIn("ref: ${{ github.event.repository.default_branch }}", SONAR) - - def test_maintenance_uses_only_pinned_sonar_configuration(self): - self.assertIn("maintenance: ${{ steps.control-plane-change.outputs.maintenance }}", SONAR) - self.assertIn('cp --remove-destination trusted-main/sonar-project.properties candidate/sonar-project.properties', SONAR) - self.assertIn('test "$tracked_delta" = sonar-project.properties', SONAR) - self.assertIn('--maintenance "$MAINTENANCE"', SONAR) - self.assertNotIn("checks: write", SONAR) - self.assertNotIn("statuses: write", SONAR) - - def test_candidate_coverage_has_no_secret_context(self): - job = QUALITY.split(" candidate_coverage:\n", 1)[1] - self.assertNotIn("secrets.", job) - self.assertNotIn("github.token", job) - self.assertNotIn("GITHUB_TOKEN", job) - self.assertIn("repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}", job) - self.assertIn("ref: ${{ github.event.pull_request.head.sha || github.sha }}", job) - - def test_branch_and_pr_control_plane_classification_match(self): - branch = SONAR.split('elif [ "$ANALYSIS_MODE" = branch ]; then', 1)[1].split("else", 1)[0] - self.assertIn('guard-control-plane.sh "$guard_base" "$CANDIDATE_SHA" --classify', branch) - self.assertNotIn("echo 'maintenance=false'", branch) - - def test_fork_base_is_fetched_and_security_diff_uses_merge_base(self): - self.assertIn('sh scripts/ci/fetch-verified-base.sh "$BASE_REPOSITORY" "$BASE_SHA"', QUALITY) - self.assertEqual(SONAR.count('trusted-main/scripts/ci/fetch-verified-base.sh "$BASE_REPOSITORY" "$BASE_SHA"'), 2) - self.assertIn('guard_base=$(git merge-base "$CANDIDATE_SHA" "$BASE_SHA")', SONAR) - self.assertIn('merge-base "$CANDIDATE_SHA" "$BASE_SHA")" = "$SECURITY_BASE_SHA"', SONAR) - - def test_frontend_coverage_gate_and_candidate_report_are_separate(self): - regression, candidate = QUALITY.split(" candidate_coverage:\n", 1) - self.assertEqual(len(re.findall(r"^ run: npm test$", regression, re.MULTILINE)), 1) - self.assertEqual( - len( - re.findall( - r"^ run: npm run test:coverage:report-only$", - candidate, - re.MULTILINE, - ) - ), - 1, - ) - self.assertEqual(len(re.findall(r"^ run: npm test$", candidate, re.MULTILINE)), 0) - - scripts = PACKAGE["scripts"] - self.assertEqual(scripts["test"], "vitest run --coverage") - self.assertEqual( - scripts["test:coverage:report-only"], - "vitest run --coverage --coverage.thresholds.lines=0 " - "--coverage.thresholds.functions=0 --coverage.thresholds.branches=0 " - "--coverage.thresholds.statements=0", - ) - - def test_sonar_scope_matches_enforced_application_coverage(self): - values = { - "sonar.projectVersion": "0.1.0-sonar.1", - "sonar.exclusions": ( - "coverage/**,dist/**,node_modules/**,.omx/**,package-lock.json," - "tsconfig.tsbuildinfo" - ), - "sonar.test.inclusions": ( - "**/*_test.go,src/**/*.test.ts,src/**/*.test.tsx,src/test/**," - "scripts/**/*.test.sh,scripts/**/test-*.sh,scripts/**/test_*.py," - "scripts/**/test_*.cjs" - ), - "sonar.coverage.exclusions": ".github/**,scripts/**,vite.config.ts", - } - - for key, value in values.items(): - self.assertIn(f"{key}={value}\n", SONAR_PROPERTIES) - self.assertIn(f"-D{key}={value}\n", PROTECTED_SONAR) - - self.assertNotIn("sonar.exclusions=.github/**", SONAR_PROPERTIES) - self.assertNotIn("-Dsonar.exclusions=.github/**", PROTECTED_SONAR) - - def test_candidate_tree_is_rejected_on_both_sides_of_approval(self): - self.assertEqual(SONAR.count("trusted-main/scripts/ci/validate-candidate-tree.sh"), 2) - self.assertIn("security_base_sha: ${{ steps.control-plane-change.outputs.security_base_sha }}", SONAR) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/validate-candidate-tree.sh b/scripts/ci/validate-candidate-tree.sh deleted file mode 100644 index a61728b..0000000 --- a/scripts/ci/validate-candidate-tree.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repository=${1:?usage: validate-candidate-tree.sh REPOSITORY EXPECTED_SHA EXPECTED_TREE} -expected_sha=${2:?usage: validate-candidate-tree.sh REPOSITORY EXPECTED_SHA EXPECTED_TREE} -expected_tree=${3:?usage: validate-candidate-tree.sh REPOSITORY EXPECTED_SHA EXPECTED_TREE} - -actual_sha=$(git -C "$repository" rev-parse HEAD) -actual_tree=$(git -C "$repository" show -s --format=%T HEAD) -[ "$actual_sha" = "$expected_sha" ] || { echo "candidate SHA mismatch: $actual_sha != $expected_sha" >&2; exit 1; } -[ "$actual_tree" = "$expected_tree" ] || { echo "candidate tree mismatch: $actual_tree != $expected_tree" >&2; exit 1; } - -while IFS= read -r -d '' entry; do - metadata=${entry%% *} - path=${entry#* } - mode=${metadata%% *} - case "$mode" in - 120000) echo "candidate tracked symlink rejected: $path" >&2; exit 1 ;; - 160000) echo "candidate gitlink/submodule rejected: $path" >&2; exit 1 ;; - 100644|100755) ;; - *) echo "candidate unexpected Git index mode $mode: $path" >&2; exit 1 ;; - esac -done < <(git -C "$repository" ls-files -s -z) - -while IFS= read -r -d '' path; do - [ ! -L "$repository/$path" ] || { echo "candidate filesystem symlink rejected: $path" >&2; exit 1; } -done < <(git -C "$repository" ls-files -z) - -status=$(git -C "$repository" status --porcelain=v1 --untracked-files=all) -[ -z "$status" ] || { printf 'candidate checkout is not clean:\n%s\n' "$status" >&2; exit 1; } diff --git a/scripts/ci/validate-coverage-artifact.py b/scripts/ci/validate-coverage-artifact.py deleted file mode 100644 index f4af115..0000000 --- a/scripts/ci/validate-coverage-artifact.py +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env python3 -"""Validate and safely extract the only artifact allowed across the secret boundary.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import stat -import sys -import zipfile -import subprocess -from pathlib import Path, PurePosixPath - -ARCHIVE_LIMIT = 10 * 1024 * 1024 -ENTRY_LIMITS = { - "lcov.info": 5 * 1024 * 1024, - "go.out": 5 * 1024 * 1024, - "manifest.json": 64 * 1024, -} -EXPECTED_ENTRIES = frozenset(ENTRY_LIMITS) -HEX_SHA = frozenset("0123456789abcdef") - - -def fail(message: str) -> None: - raise ValueError(message) - - -def sha256(data: bytes) -> str: - return hashlib.sha256(data).hexdigest() - - -def exact_keys(value: dict, keys: set[str], label: str) -> None: - if set(value) != keys: - fail(f"{label} keys differ: expected {sorted(keys)}, got {sorted(value)}") - - -def positive_int(value: object, label: str, allow_zero: bool = False) -> int: - if not isinstance(value, int) or isinstance(value, bool) or value < (0 if allow_zero else 1) or value > 9_007_199_254_740_991: - fail(f"{label} must be {'non-negative' if allow_zero else 'positive'} integer") - return value - - -def validate_sha(value: object, label: str) -> str: - if not isinstance(value, str) or len(value) != 40 or any(ch not in HEX_SHA for ch in value): - fail(f"{label} must be a lowercase 40-character Git SHA") - return value - - -def validate_manifest(manifest: dict, args: argparse.Namespace, reports: dict[str, bytes]) -> None: - exact_keys(manifest, {"schema_version", "repository", "producer", "candidate", "reports", "tools"}, "manifest") - if manifest["schema_version"] != 1: - fail("unsupported manifest schema") - if manifest["repository"] != args.repository: - fail("manifest repository mismatch") - - producer = manifest["producer"] - exact_keys(producer, {"workflow", "workflow_ref", "workflow_sha", "test_revision_sha", "run_id", "run_attempt", "job", "event"}, "producer") - expected_producer = { - "workflow": args.workflow, - "run_id": args.run_id, - "run_attempt": args.run_attempt, - "job": "candidate_coverage", - "event": args.event, - } - for key, expected in expected_producer.items(): - if producer[key] != expected: - fail(f"producer {key} mismatch") - if producer["workflow_ref"] != args.workflow_ref: - fail("producer workflow_ref mismatch") - if producer["workflow_sha"] != args.workflow_sha: - fail("producer workflow_sha mismatch") - if producer["test_revision_sha"] != args.test_revision_sha: - fail("producer test_revision_sha mismatch") - validate_sha(producer["workflow_sha"], "producer.workflow_sha") - validate_sha(producer["test_revision_sha"], "producer.test_revision_sha") - positive_int(producer["run_id"], "producer.run_id") - positive_int(producer["run_attempt"], "producer.run_attempt") - - candidate = manifest["candidate"] - exact_keys(candidate, {"repository", "sha", "tree", "ref", "pull_request", "base_sha", "base_ref", "security_base_sha", "maintenance"}, "candidate") - expected_candidate = { - "repository": args.candidate_repository, - "sha": args.candidate_sha, - "tree": args.candidate_tree, - "ref": args.candidate_ref, - "pull_request": args.pull_request, - "base_ref": args.base_ref, - "security_base_sha": args.security_base_sha, - "maintenance": args.maintenance == "true", - } - for key, expected in expected_candidate.items(): - if candidate[key] != expected: - fail(f"candidate {key} mismatch") - validate_sha(candidate["sha"], "candidate.sha") - validate_sha(candidate["tree"], "candidate.tree") - validate_sha(candidate["base_sha"], "candidate.base_sha") - validate_sha(candidate["security_base_sha"], "candidate.security_base_sha") - if args.event == "pull_request" and candidate["base_sha"] != args.base_sha: - fail("candidate base_sha mismatch") - positive_int(candidate["pull_request"], "candidate.pull_request", allow_zero=True) - if not isinstance(candidate["maintenance"], bool): - fail("candidate.maintenance must be a boolean") - if not isinstance(candidate["ref"], str) or not candidate["ref"] or any(ch in candidate["ref"] for ch in "\r\n\0"): - fail("candidate ref is invalid") - - entries = manifest["reports"] - exact_keys(entries, {"frontend", "go"}, "reports") - for kind, archive_name, report_path in ( - ("frontend", "lcov.info", "coverage/lcov.info"), - ("go", "go.out", "coverage/go.out"), - ): - report = entries[kind] - exact_keys(report, {"path", "size", "sha256"}, f"reports.{kind}") - if report["path"] != report_path: - fail(f"reports.{kind}.path mismatch") - if report["size"] != len(reports[archive_name]): - fail(f"reports.{kind}.size mismatch") - digest = sha256(reports[archive_name]) - if report["sha256"] != digest: - fail(f"reports.{kind}.sha256 mismatch") - - tools = manifest["tools"] - exact_keys(tools, {"node", "npm", "go", "go_module"}, "tools") - if not all(isinstance(value, str) and value for value in tools.values()): - fail("tool versions must be non-empty strings") - - -def safe_relative_source(value: str, label: str, repository_root: Path) -> None: - path = PurePosixPath(value) - if not value or path.is_absolute() or ".." in path.parts or "\\" in value or "://" in value or ":" in value or any(char in value for char in "\r\n\0"): - fail(f"{label} contains an unsafe source path") - if str(path) != value or len(path.parts) < 1: - fail(f"{label} source path is not normalized") - source = repository_root / value - source_stat = source.lstat() - if not stat.S_ISREG(source_stat.st_mode) or stat.S_ISLNK(source_stat.st_mode): - fail(f"{label} source is not a regular non-symlink file: {value}") - tracked = subprocess.run( - ["git", "-C", str(repository_root), "ls-files", "--error-unmatch", "--", value], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if tracked.returncode != 0: - fail(f"{label} source is not tracked: {value}") - - -def validate_report_sources(manifest: dict, reports: dict[str, bytes], repository_root: Path) -> None: - try: - lcov = reports["lcov.info"].decode("utf-8") - go_report = reports["go.out"].decode("utf-8") - except UnicodeDecodeError as error: - fail(f"coverage report is not UTF-8: {error}") - if "\r" in lcov or "\0" in lcov or "\r" in go_report or "\0" in go_report: - fail("coverage report contains forbidden control bytes") - lcov_sources = [line[3:] for line in lcov.splitlines() if line.startswith("SF:")] - if not lcov_sources or not any(line.startswith("DA:") for line in lcov.splitlines()): - fail("lcov.info has an invalid format") - for source in lcov_sources: - safe_relative_source(source, "lcov.info", repository_root) - module = manifest["tools"]["go_module"] - if not isinstance(module, str) or not module or any(char in module for char in "\r\n\0"): - fail("tools.go_module is invalid") - go_mod = (repository_root / "go.mod").read_text(encoding="utf-8") - module_lines = [line.split(None, 1)[1] for line in go_mod.splitlines() if line.startswith("module ")] - if module_lines != [module]: - fail("tools.go_module does not match the candidate go.mod module") - go_lines = go_report.splitlines() - if not go_lines or go_lines[0] not in {"mode: set", "mode: count", "mode: atomic"} or len(go_lines) < 2: - fail("go.out has an invalid format") - import re - pattern = re.compile(r"^(.+):[0-9]+\.[0-9]+,[0-9]+\.[0-9]+ [0-9]+ [0-9]+$") - for line in go_lines[1:]: - match = pattern.fullmatch(line) - if not match or not match.group(1).startswith(f"{module}/"): - fail("go.out has a malformed coverage entry") - safe_relative_source(match.group(1)[len(module) + 1 :], "go.out", repository_root) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--archive", required=True, type=Path) - parser.add_argument("--output", required=True, type=Path) - parser.add_argument("--repository", required=True) - parser.add_argument("--workflow", required=True) - parser.add_argument("--workflow-ref", required=True) - parser.add_argument("--workflow-sha", required=True) - parser.add_argument("--test-revision-sha", required=True) - parser.add_argument("--run-id", required=True, type=int) - parser.add_argument("--run-attempt", required=True, type=int) - parser.add_argument("--event", required=True, choices=("pull_request", "push", "workflow_dispatch")) - parser.add_argument("--candidate-repository", required=True) - parser.add_argument("--candidate-sha", required=True) - parser.add_argument("--candidate-tree", required=True) - parser.add_argument("--candidate-ref", required=True) - parser.add_argument("--pull-request", required=True, type=int) - parser.add_argument("--base-sha", required=True) - parser.add_argument("--security-base-sha", required=True) - parser.add_argument("--base-ref", required=True) - parser.add_argument("--maintenance", required=True, choices=("true", "false")) - parser.add_argument("--repository-root", required=True, type=Path) - parser.add_argument("--github-output", type=Path) - return parser.parse_args() - - -def main() -> None: - args = parse_args() - archive_stat = args.archive.lstat() - if not archive_stat.st_size or archive_stat.st_size > ARCHIVE_LIMIT or not stat.S_ISREG(archive_stat.st_mode): - fail(f"artifact archive must be a regular file within 1..{ARCHIVE_LIMIT} bytes") - - with zipfile.ZipFile(args.archive) as archive: - infos = archive.infolist() - names = [info.filename for info in infos] - if len(names) != len(set(names)): - fail("artifact contains duplicate paths") - if set(names) != EXPECTED_ENTRIES: - fail(f"artifact entries differ: expected {sorted(EXPECTED_ENTRIES)}, got {sorted(names)}") - reports: dict[str, bytes] = {} - for info in infos: - path = PurePosixPath(info.filename) - unix_mode = info.external_attr >> 16 - if path.is_absolute() or ".." in path.parts or len(path.parts) != 1: - fail(f"unsafe artifact path: {info.filename}") - file_type = stat.S_IFMT(unix_mode) - if info.is_dir() or stat.S_ISLNK(unix_mode) or (file_type and file_type != stat.S_IFREG): - fail(f"artifact entry is not a regular non-symlink file: {info.filename}") - if info.file_size < 1 or info.file_size > ENTRY_LIMITS[info.filename]: - fail(f"artifact entry size out of bounds: {info.filename}") - if info.compress_size and info.file_size / info.compress_size > 100: - fail(f"artifact entry compression ratio too high: {info.filename}") - reports[info.filename] = archive.read(info) - - if b"SF:" not in reports["lcov.info"] or b"DA:" not in reports["lcov.info"]: - fail("lcov.info has an invalid format") - if not reports["go.out"].startswith((b"mode: set\n", b"mode: count\n", b"mode: atomic\n")): - fail("go.out has an invalid format") - try: - def unique_object(pairs): - result = {} - for key, value in pairs: - if key in result: - fail(f"manifest.json contains duplicate key: {key}") - result[key] = value - return result - manifest = json.loads(reports["manifest.json"], object_pairs_hook=unique_object) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - fail(f"manifest.json is invalid JSON: {error}") - if not isinstance(manifest, dict): - fail("manifest root must be an object") - validate_manifest(manifest, args, reports) - empty_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" - manifest_base = manifest["candidate"]["base_sha"] - if manifest_base != empty_tree: - base_exists = subprocess.run( - ["git", "-C", str(args.repository_root), "cat-file", "-e", "--end-of-options", f"{manifest_base}^{{commit}}"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - if base_exists.returncode != 0: - fail("candidate event base is not an available commit") - if args.event != "pull_request": - ancestor = subprocess.run( - ["git", "-C", str(args.repository_root), "merge-base", "--is-ancestor", "--end-of-options", manifest_base, args.candidate_sha], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - if ancestor.returncode != 0: - fail("candidate event base is not an ancestor of candidate SHA") - validate_report_sources(manifest, reports, args.repository_root.resolve(strict=True)) - - args.output.mkdir(mode=0o700, parents=True, exist_ok=False) - for name in ("lcov.info", "go.out", "manifest.json"): - target = args.output / name - target.write_bytes(reports[name]) - target.chmod(0o600) - - if args.github_output: - with args.github_output.open("a", encoding="utf-8") as output: - output.write(f"lcov_sha256={sha256(reports['lcov.info'])}\n") - output.write(f"go_sha256={sha256(reports['go.out'])}\n") - - -if __name__ == "__main__": - try: - main() - except (OSError, ValueError, zipfile.BadZipFile) as error: - print(f"validate-coverage-artifact: {error}", file=sys.stderr) - sys.exit(1) diff --git a/scripts/ci/validate-workflow-run.cjs b/scripts/ci/validate-workflow-run.cjs deleted file mode 100644 index 55252b4..0000000 --- a/scripts/ci/validate-workflow-run.cjs +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env node - -const { appendFileSync, readFileSync } = require('node:fs'); - -const WORKFLOW_NAME = 'Regression and candidate coverage'; -const WORKFLOW_PATH = '.github/workflows/quality.yml'; -const JOB_NAME = 'Candidate head coverage'; -const ARTIFACT_LIMIT = 10 * 1024 * 1024; - -function required(name) { - const value = process.env[name]; - if (!value) throw new Error(`missing required environment variable ${name}`); - return value; -} - -function equal(actual, expected, label) { - if (actual !== expected) throw new Error(`${label} mismatch: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`); -} - -function safeInteger(value, label) { - if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be a positive safe integer`); - return value; -} - -function environmentInteger(name) { - const value = required(name); - if (!/^[1-9][0-9]*$/.test(value)) throw new Error(`${name} must be a positive integer`); - return safeInteger(Number(value), name); -} - -function repositoryName(value, label) { - if (typeof value !== 'string') throw new Error(`${label} is invalid`); - const segments = value.split('/'); - if (segments.length !== 2 || segments.some((segment) => !/^[A-Za-z0-9_.-]+$/.test(segment))) { - throw new Error(`${label} is invalid`); - } - return value; -} - -function githubPathIdentifier(value, label) { - if (typeof value === 'number') return String(safeInteger(value, label)); - if (typeof value !== 'string' || !value || value.length > 100 || !/^[A-Za-z0-9_.-]+$/.test(value)) { - throw new Error(`${label} is invalid`); - } - return encodeURIComponent(value); -} - -async function github(segments, query = {}) { - if (!Array.isArray(segments) || !segments.length) throw new Error('GitHub API path is invalid'); - const apiUrl = new URL(process.env.GITHUB_API_URL || 'https://api.github.com'); - if (!['http:', 'https:'].includes(apiUrl.protocol) || apiUrl.username || apiUrl.password || apiUrl.search || apiUrl.hash || apiUrl.pathname.includes('//')) { - throw new Error('GITHUB_API_URL is invalid'); - } - const path = segments.map((segment, index) => githubPathIdentifier(segment, `GitHub API path segment ${index + 1}`)).join('/'); - const basePath = apiUrl.pathname === '/' ? '' : apiUrl.pathname.endsWith('/') ? apiUrl.pathname.slice(0, -1) : apiUrl.pathname; - apiUrl.pathname = `${basePath}/${path}`; - for (const [key, value] of Object.entries(query)) apiUrl.searchParams.set(key, String(value)); - const response = await fetch(apiUrl, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${required('GITHUB_TOKEN')}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - }); - if (!response.ok) throw new Error(`GitHub API ${path} returned ${response.status}`); - return response.json(); -} - -function output(values) { - const path = required('GITHUB_OUTPUT'); - for (const [key, value] of Object.entries(values)) { - if (String(value).includes('\n')) throw new Error(`unsafe newline in output ${key}`); - appendFileSync(path, `${key}=${value}\n`); - } -} - -async function main() { - const event = JSON.parse(readFileSync(required('GITHUB_EVENT_PATH'), 'utf8')); - const repository = repositoryName(required('GITHUB_REPOSITORY'), 'GITHUB_REPOSITORY'); - const triggerRunId = environmentInteger('TRIGGER_RUN_ID'); - const triggerWorkflowId = environmentInteger('TRIGGER_WORKFLOW_ID'); - const triggerRunAttempt = environmentInteger('TRIGGER_RUN_ATTEMPT'); - const triggerHeadRepositoryId = environmentInteger('TRIGGER_HEAD_REPOSITORY_ID'); - const triggerHeadSha = required('TRIGGER_HEAD_SHA'); - if (!/^[0-9a-f]{40}$/.test(triggerHeadSha)) throw new Error('TRIGGER_HEAD_SHA must be a lowercase 40-character Git SHA'); - const trigger = event.workflow_run; - if (!trigger || typeof trigger !== 'object') throw new Error('missing workflow_run payload'); - equal(event.repository?.full_name, repository, 'trigger repository'); - equal(trigger.repository?.full_name, repository, 'workflow repository'); - const candidateRepository = repositoryName(trigger.head_repository?.full_name, 'candidate repository'); - equal(trigger.name, WORKFLOW_NAME, 'producer workflow name'); - equal(trigger.path, WORKFLOW_PATH, 'producer workflow path'); - equal(trigger.event === 'pull_request' || trigger.event === 'push' || trigger.event === 'workflow_dispatch', true, 'producer event allowlist'); - equal(trigger.status, 'completed', 'producer status'); - equal(trigger.conclusion, 'success', 'producer conclusion'); - equal(safeInteger(trigger.id, 'workflow run id'), triggerRunId, 'trusted workflow run id'); - equal(safeInteger(trigger.workflow_id, 'workflow id'), triggerWorkflowId, 'trusted workflow id'); - equal(safeInteger(trigger.run_attempt, 'workflow run attempt'), triggerRunAttempt, 'trusted workflow run attempt'); - equal(safeInteger(trigger.head_repository.id, 'head repository id'), triggerHeadRepositoryId, 'trusted head repository id'); - equal(trigger.head_sha, triggerHeadSha, 'trusted head SHA'); - - const repositorySegments = repository.split('/'); - const run = await github(['repos', ...repositorySegments, 'actions', 'runs', triggerRunId]); - equal(run.id, triggerRunId, 'API run id'); - equal(run.workflow_id, triggerWorkflowId, 'API workflow id'); - equal(run.name, WORKFLOW_NAME, 'API workflow name'); - equal(run.path, WORKFLOW_PATH, 'API workflow path'); - equal(run.event, trigger.event, 'API event'); - equal(run.status, 'completed', 'API status'); - equal(run.conclusion, 'success', 'API conclusion'); - equal(run.head_repository?.full_name, candidateRepository, 'API head repository'); - equal(run.head_sha, triggerHeadSha, 'API head SHA'); - equal(run.run_attempt, triggerRunAttempt, 'API run attempt'); - const workflowFile = WORKFLOW_PATH.split('/').at(-1); - const workflow = await github(['repos', ...repositorySegments, 'actions', 'workflows', workflowFile]); - equal(workflow.id, triggerWorkflowId, 'workflow-by-path id'); - equal(workflow.path, WORKFLOW_PATH, 'workflow-by-path path'); - equal(workflow.name, WORKFLOW_NAME, 'workflow-by-path name'); - - const jobs = await github(['repos', ...repositorySegments, 'actions', 'runs', triggerRunId, 'jobs'], { filter: 'latest', per_page: 100 }); - const jobMatches = jobs.jobs.filter((job) => job.name === JOB_NAME && job.run_attempt === triggerRunAttempt); - if (jobMatches.length !== 1) throw new Error(`expected exactly one ${JOB_NAME} job, found ${jobMatches.length}`); - const job = jobMatches[0]; - safeInteger(job.id, 'producer job id'); - equal(job.run_id, triggerRunId, 'producer job run id'); - equal(job.status, 'completed', 'producer job status'); - equal(job.conclusion, 'success', 'producer job conclusion'); - equal(job.head_sha, triggerHeadSha, 'producer job head SHA'); - - const artifacts = await github(['repos', ...repositorySegments, 'actions', 'runs', triggerRunId, 'artifacts'], { per_page: 100 }); - equal(artifacts.total_count, 1, 'artifact count'); - equal(artifacts.artifacts.length, 1, 'returned artifact count'); - const artifact = artifacts.artifacts[0]; - safeInteger(artifact.id, 'artifact id'); - equal(artifact.name, `candidate-head-coverage-${triggerHeadSha}`, 'artifact name'); - equal(artifact.expired, false, 'artifact expiration'); - if (!Number.isSafeInteger(artifact.size_in_bytes) || artifact.size_in_bytes < 1 || artifact.size_in_bytes > ARTIFACT_LIMIT) { - throw new Error(`artifact size ${artifact.size_in_bytes} is outside 1..${ARTIFACT_LIMIT} bytes`); - } - equal(artifact.workflow_run?.id, triggerRunId, 'artifact workflow run id'); - equal(artifact.workflow_run?.head_sha, triggerHeadSha, 'artifact head SHA'); - equal(artifact.workflow_run?.head_repository_id, triggerHeadRepositoryId, 'artifact head repository id'); - - const commit = await github(['repos', ...candidateRepository.split('/'), 'git', 'commits', triggerHeadSha]); - equal(commit.sha, triggerHeadSha, 'candidate commit SHA'); - const candidateTree = commit.tree?.sha; - if (!/^[0-9a-f]{40}$/.test(candidateTree || '')) throw new Error('candidate tree is invalid'); - - let mode; - let pullRequest = 0; - let baseSha; - let baseRef; - let candidateRef = trigger.head_branch; - let workflowRef; - let workflowSha; - if (trigger.event === 'pull_request') { - const pullRequests = run.pull_requests || []; - if (pullRequests.length !== 1) throw new Error(`expected exactly one triggering pull request, found ${pullRequests.length}`); - const pull = pullRequests[0]; - safeInteger(pull.number, 'pull request number'); - equal(pull.head?.sha, triggerHeadSha, 'pull request head SHA'); - pullRequest = pull.number; - mode = 'pull_request'; - baseSha = pull.base?.sha; - baseRef = pull.base?.ref; - candidateRef = pull.head?.ref; - const pullDetails = await github(['repos', ...repositorySegments, 'pulls', pull.number]); - equal(pullDetails.head?.sha, triggerHeadSha, 'pull request API head SHA'); - equal(pullDetails.head?.repo?.full_name, candidateRepository, 'pull request API head repository'); - equal(pullDetails.base?.repo?.full_name, repository, 'pull request API base repository'); - equal(pullDetails.head?.ref, candidateRef, 'pull request API head ref'); - equal(pullDetails.base?.ref, baseRef, 'pull request API base ref'); - workflowRef = `${repository}/${WORKFLOW_PATH}@refs/pull/${pull.number}/merge`; - workflowSha = pullDetails.merge_commit_sha; - } else { - equal(candidateRepository, repository, 'non-pull-request candidate repository'); - const parents = commit.parents || []; - baseSha = parents.length ? parents[0].sha : triggerHeadSha; - baseRef = event.repository?.default_branch; - mode = trigger.head_branch === event.repository?.default_branch ? 'main' : 'branch'; - workflowRef = `${repository}/${WORKFLOW_PATH}@refs/heads/${trigger.head_branch}`; - workflowSha = triggerHeadSha; - } - if (!/^[0-9a-f]{40}$/.test(baseSha || '')) throw new Error('base SHA is invalid'); - for (const [label, value] of [['base ref', baseRef], ['candidate ref', candidateRef]]) { - if (typeof value !== 'string' || !value || /[\r\n\0]/.test(value)) throw new Error(`${label} is invalid`); - } - if (!/^[0-9a-f]{40}$/.test(workflowSha || '')) throw new Error('workflow SHA is invalid'); - - output({ - artifact_id: artifact.id, - artifact_name: artifact.name, - run_id: triggerRunId, - run_attempt: triggerRunAttempt, - job_id: job.id, - event: trigger.event, - mode, - candidate_repository: candidateRepository, - candidate_sha: triggerHeadSha, - candidate_tree: candidateTree, - candidate_ref: candidateRef, - workflow_ref: workflowRef, - workflow_sha: workflowSha, - pull_request: pullRequest, - base_sha: baseSha, - base_ref: baseRef, - }); -} - -main().catch((error) => { - console.error(`validate-workflow-run: ${error.message}`); - process.exitCode = 1; -}); diff --git a/scripts/ci_monitor.cjs b/scripts/ci_monitor.cjs index 8a056ef..5f7a787 100644 --- a/scripts/ci_monitor.cjs +++ b/scripts/ci_monitor.cjs @@ -4,7 +4,7 @@ // It deliberately wraps every GitHub CLI observation with an explicit repository. const { execFileSync, spawnSync } = require('node:child_process'); -const { readFileSync, readdirSync } = require('node:fs'); +const { existsSync, readFileSync, readdirSync } = require('node:fs'); const { join } = require('node:path'); const HELP = `usage: node scripts/ci_monitor.cjs [arguments] @@ -73,15 +73,15 @@ function positiveInteger(value, label) { function checkActions(files) { function actionFiles(directory) { + if (!existsSync(directory)) return []; return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); if (entry.isDirectory()) return actionFiles(path); - return /(?:action\.ya?ml)$/.test(entry.name) ? [path] : []; + return /\.ya?ml$/.test(entry.name) ? [path] : []; }); } const selected = files.length ? files : [ - '.github/workflows/quality.yml', - '.github/workflows/sonar-exact-revision.yml', + ...actionFiles('.github/workflows'), ...actionFiles('.github/actions'), ]; let failures = 0; diff --git a/sonar-project.properties b/sonar-project.properties index 28c397d..7af6f9b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,5 @@ sonar.sources=. sonar.tests=. -sonar.projectVersion=0.1.0-sonar.1 - sonar.exclusions=coverage/**,dist/**,node_modules/**,.omx/**,package-lock.json,tsconfig.tsbuildinfo sonar.test.inclusions=**/*_test.go,src/**/*.test.ts,src/**/*.test.tsx,src/test/**,scripts/**/*.test.sh,scripts/**/test-*.sh,scripts/**/test_*.py,scripts/**/test_*.cjs sonar.coverage.exclusions=.github/**,scripts/**,vite.config.ts