From 0de5855363f58c1c20884e029129961a9b299711 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 1 Aug 2026 09:50:40 +0000 Subject: [PATCH 1/5] ci: prepare protected sonar bootstrap --- .github/CODEOWNERS | 18 ++ .github/actions/protected-sonar/action.yml | 60 ++++ .../protected-sonar/verify-sonar-task.cjs | 64 ++++ .github/workflows/quality.yml | 177 ++++++++--- .github/workflows/sonar-exact-revision.yml | 272 +++++++++++++++++ scripts/check-go-checkers.test.sh | 257 ++++++++++++++++ scripts/check-go-coverage.sh | 115 +++++-- scripts/check-go-format.sh | 62 ++++ scripts/ci/check-event-diff.sh | 73 +++++ scripts/ci/create-coverage-manifest.cjs | 162 ++++++++++ scripts/ci/guard-control-plane.sh | 43 +++ scripts/ci/test-npm-ignore-scripts.sh | 22 ++ scripts/ci/test_ci_helpers.py | 288 +++++++++++++++++ scripts/ci/test_ci_monitor.cjs | 40 +++ scripts/ci/test_validate_coverage_artifact.py | 175 +++++++++++ scripts/ci/test_validate_workflow_run.cjs | 88 ++++++ scripts/ci/test_workflow_structure.py | 61 ++++ scripts/ci/validate-candidate-tree.sh | 30 ++ scripts/ci/validate-coverage-artifact.py | 289 ++++++++++++++++++ scripts/ci/validate-workflow-run.cjs | 172 +++++++++++ scripts/ci_monitor.cjs | 172 +++++++++++ vite.config.ts | 8 +- 22 files changed, 2580 insertions(+), 68 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/actions/protected-sonar/action.yml create mode 100644 .github/actions/protected-sonar/verify-sonar-task.cjs create mode 100644 .github/workflows/sonar-exact-revision.yml create mode 100644 scripts/check-go-checkers.test.sh create mode 100644 scripts/check-go-format.sh create mode 100644 scripts/ci/check-event-diff.sh create mode 100644 scripts/ci/create-coverage-manifest.cjs create mode 100644 scripts/ci/guard-control-plane.sh create mode 100644 scripts/ci/test-npm-ignore-scripts.sh create mode 100644 scripts/ci/test_ci_helpers.py create mode 100644 scripts/ci/test_ci_monitor.cjs create mode 100644 scripts/ci/test_validate_coverage_artifact.py create mode 100644 scripts/ci/test_validate_workflow_run.cjs create mode 100644 scripts/ci/test_workflow_structure.py create mode 100644 scripts/ci/validate-candidate-tree.sh create mode 100644 scripts/ci/validate-coverage-artifact.py create mode 100644 scripts/ci/validate-workflow-run.cjs create mode 100644 scripts/ci_monitor.cjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a1e5c1e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,18 @@ +# CI control plane. Reviews are evaluated from the protected base branch. +/.github/ @aksOps +/scripts/check-* @aksOps +/scripts/ci/ @aksOps +/scripts/ci_monitor.cjs @aksOps +/sonar-project.properties @aksOps +/package.json @aksOps +/package-lock.json @aksOps +/npm-shrinkwrap.json @aksOps +/yarn.lock @aksOps +/pnpm-lock.yaml @aksOps +/bun.lock @aksOps +/bun.lockb @aksOps +/.npmrc @aksOps +/vite.config.* @aksOps +/vitest.config.* @aksOps +/go.mod @aksOps +/go.sum @aksOps diff --git a/.github/actions/protected-sonar/action.yml b/.github/actions/protected-sonar/action.yml new file mode 100644 index 0000000..c8611da --- /dev/null +++ b/.github/actions/protected-sonar/action.yml @@ -0,0 +1,60 @@ +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.scm.revision=${{ inputs.candidate-sha }} + -Dsonar.sources=. + -Dsonar.tests=. + -Dsonar.exclusions=coverage/**,dist/**,node_modules/**,.omx/**,package-lock.json,tsconfig.tsbuildinfo,**/*_test.go,src/**/*.test.ts,src/**/*.test.tsx,src/test/** + -Dsonar.test.inclusions=**/*_test.go,src/**/*.test.ts,src/**/*.test.tsx + -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 new file mode 100644 index 0000000..8927c2d --- /dev/null +++ b/.github/actions/protected-sonar/verify-sonar-task.cjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +const { readFileSync } = require('node:fs'); + +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.protocol !== 'https:' || serverUrl.hostname !== 'sonarcloud.io' || 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)}`, serverUrl)); + 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', serverUrl); + 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}`); +} + +main().catch((error) => { + console.error(`verify-sonar-task: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b0d5d34..6828831 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -1,12 +1,9 @@ -name: Coverage and Sonar +name: Regression and candidate coverage on: push: pull_request: - types: - - opened - - synchronize - - reopened + types: [opened, synchronize, reopened] workflow_dispatch: permissions: @@ -17,18 +14,47 @@ concurrency: cancel-in-progress: true jobs: - quality: - name: Coverage and quality gate + regression: + name: Regression (test-merge) runs-on: ubuntu-latest - timeout-minutes: 25 - + timeout-minutes: 30 + outputs: + diff_base: ${{ steps.diff.outputs.base }} + diff_head: ${{ steps.diff.outputs.head }} steps: - - name: Check out repository + - name: Check out GitHub test revision 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: @@ -41,14 +67,16 @@ jobs: go-version-file: go.mod cache-dependency-path: go.sum - - name: Install frontend dependencies - run: npm ci + - name: Install frontend dependencies without lifecycle scripts + run: npm ci --ignore-scripts - name: Run frontend coverage gate run: npm test - name: Run Go coverage gate env: + GO_PACKAGE_COVERAGE_THRESHOLD: '95.0' + GO_TOTAL_COVERAGE_THRESHOLD: '96.4' GO_COVERAGE_PROFILE: coverage/go.out run: npm run coverage:go @@ -58,36 +86,103 @@ jobs: - name: Vet Go packages run: go vet ./... - - name: Validate Sonar configuration - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + - name: Run Go race tests + run: go test -race ./... -count=1 + + - name: Check Go formatting + run: sh scripts/check-go-format.sh + + - name: Install pinned Actionlint env: - SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION }} - SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 run: | - test -n "$SONAR_ORGANIZATION" || { - echo "::error title=Missing Sonar organization::Set the SONAR_ORGANIZATION repository variable." - exit 1 - } - test -n "$SONAR_PROJECT_KEY" || { - echo "::error title=Missing Sonar project key::Set the SONAR_PROJECT_KEY repository variable." - exit 1 - } - test -n "$SONAR_TOKEN" || { - echo "::error title=Missing Sonar token::Add SONAR_TOKEN as a repository Actions secret." - exit 1 - } - - - name: Run SonarQube Cloud scan and quality gate - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + 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: + 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: - args: > - -Dsonar.organization=${{ vars.SONAR_ORGANIZATION }} - -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }} + node-version: 24.15.0 + cache: npm + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Install frontend dependencies without lifecycle scripts + run: npm ci --ignore-scripts + + - name: Generate frontend coverage + run: npm test - - 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 repository secrets are unavailable to fork pull requests." >> "$GITHUB_STEP_SUMMARY" + - 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: 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 diff --git a/.github/workflows/sonar-exact-revision.yml b/.github/workflows/sonar-exact-revision.yml new file mode 100644 index 0000000..56c3a8e --- /dev/null +++ b/.github/workflows/sonar-exact-revision.yml @@ -0,0 +1,272 @@ +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 }} + 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 }} + 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 + bash ../trusted-main/scripts/ci/guard-control-plane.sh "$BASE_SHA" "$CANDIDATE_SHA" --classify >> "$GITHUB_OUTPUT" + echo "security_base_sha=$BASE_SHA" >> "$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" + echo 'maintenance=false' >> "$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 }} + 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 [ "$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/scripts/check-go-checkers.test.sh b/scripts/check-go-checkers.test.sh new file mode 100644 index 0000000..74c874f --- /dev/null +++ b/scripts/check-go-checkers.test.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" +test_dir="$(mktemp -d "${TMPDIR:-/tmp}/kb-go-checkers-test.XXXXXX")" + +cleanup() { + rm -rf -- "$test_dir" +} +trap cleanup EXIT HUP INT TERM + +fail() { + echo "check-go-checkers.test: $*" >&2 + exit 1 +} + +run_capture() { + output_file="$1" + shift + if "$@" >"$output_file" 2>&1; then + return 0 + else + return $? + fi +} + +assert_status() { + expected="$1" + actual="$2" + label="$3" + [ "$actual" -eq "$expected" ] || fail "$label: expected status $expected, got $actual" +} + +assert_contains() { + pattern="$1" + file="$2" + label="$3" + grep -F -- "$pattern" "$file" >/dev/null || fail "$label: missing output: $pattern" +} + +fake_go_dir="$test_dir/fake-go" +mkdir -p -- "$fake_go_dir" +cat >"$fake_go_dir/go" <<'EOF' +#!/usr/bin/env sh +set -eu +case "$1" in + test) + for argument do + case "$argument" in + -coverprofile=*) : >"${argument#-coverprofile=}" ;; + esac + done + if [ -n "${FAKE_TEST_OUTPUT_FILE:-}" ]; then + cat "$FAKE_TEST_OUTPUT_FILE" + else + printf 'ok example.test/one 0.001s coverage: %s%% of statements\n' "${FAKE_PACKAGE_ONE:-95.0}" + printf 'ok example.test/two 0.001s coverage: %s%% of statements\n' "${FAKE_PACKAGE_TWO:-100.0}" + fi + exit "${FAKE_TEST_STATUS:-0}" + ;; + list) + if [ -n "${FAKE_LIST_OUTPUT_FILE:-}" ]; then + cat "$FAKE_LIST_OUTPUT_FILE" + else + printf 'example.test/one\nexample.test/two\n' + fi + exit "${FAKE_LIST_STATUS:-0}" + ;; + tool) + if [ -n "${FAKE_COVER_OUTPUT_FILE:-}" ]; then + cat "$FAKE_COVER_OUTPUT_FILE" + else + printf 'total: (statements) %s%%\n' "${FAKE_TOTAL:-96.4}" + fi + exit "${FAKE_COVER_STATUS:-0}" + ;; + *) + exit 64 + ;; +esac +EOF +chmod +x "$fake_go_dir/go" + +coverage_output="$test_dir/coverage-output" +coverage_profile="$test_dir/coverage.out" +if ! PATH="$fake_go_dir:$PATH" GO_COVERAGE_PROFILE="$coverage_profile" \ + sh "$repo_root/scripts/check-go-coverage.sh" >"$coverage_output" 2>&1; then + fail "coverage success case failed" +fi +[ -f "$coverage_profile" ] || fail "GO_COVERAGE_PROFILE was not retained" +assert_contains 'Go statement coverage: 96.4% (required: 96.4%)' "$coverage_output" "coverage success" + +status=0 +PATH="$fake_go_dir:$PATH" GO_PACKAGE_COVERAGE_THRESHOLD=94.9 \ + run_capture "$coverage_output" sh "$repo_root/scripts/check-go-coverage.sh" || status=$? +assert_status 2 "$status" "invalid package threshold" +assert_contains 'GO_PACKAGE_COVERAGE_THRESHOLD must be a number from 95 through 100' "$coverage_output" "invalid package threshold" + +status=0 +PATH="$fake_go_dir:$PATH" GO_TOTAL_COVERAGE_THRESHOLD=100.1 \ + run_capture "$coverage_output" sh "$repo_root/scripts/check-go-coverage.sh" || status=$? +assert_status 2 "$status" "invalid total threshold" +assert_contains 'GO_TOTAL_COVERAGE_THRESHOLD must be a number from 95 through 100' "$coverage_output" "invalid total threshold" + +status=0 +PATH="$fake_go_dir:$PATH" FAKE_PACKAGE_ONE=94.9 FAKE_TOTAL=99.0 \ + run_capture "$coverage_output" sh "$repo_root/scripts/check-go-coverage.sh" || status=$? +assert_status 1 "$status" "package floor" +assert_contains 'package example.test/one is 94.9%, below 95.0%' "$coverage_output" "package floor" + +status=0 +PATH="$fake_go_dir:$PATH" FAKE_PACKAGE_ONE=95.0 FAKE_TOTAL=96.3 \ + run_capture "$coverage_output" sh "$repo_root/scripts/check-go-coverage.sh" || status=$? +assert_status 1 "$status" "aggregate floor" +assert_contains 'Go statement coverage: 96.3% (required: 96.4%)' "$coverage_output" "aggregate floor" + +malformed_package_output="$test_dir/malformed-package-output" +printf 'ok example.test/one 0.001s coverage: 95.0junk%% of statements\nok example.test/two 0.001s coverage: 100.0%% of statements\n' \ + >"$malformed_package_output" +status=0 +PATH="$fake_go_dir:$PATH" FAKE_TEST_OUTPUT_FILE="$malformed_package_output" \ + run_capture "$coverage_output" sh "$repo_root/scripts/check-go-coverage.sh" || status=$? +assert_status 1 "$status" "malformed package percentage" +assert_contains 'invalid coverage percentage 95.0junk%' "$coverage_output" "malformed package percentage" + +duplicate_package_output="$test_dir/duplicate-package-output" +printf 'ok example.test/one 0.001s coverage: 95.0%% of statements\nok example.test/one 0.001s coverage: 100.0%% of statements\n' \ + >"$duplicate_package_output" +status=0 +PATH="$fake_go_dir:$PATH" FAKE_TEST_OUTPUT_FILE="$duplicate_package_output" \ + run_capture "$coverage_output" sh "$repo_root/scripts/check-go-coverage.sh" || status=$? +assert_status 1 "$status" "duplicate and missing package identities" +assert_contains 'duplicate package result example.test/one' "$coverage_output" "duplicate package identity" +assert_contains 'missing package result example.test/two' "$coverage_output" "missing package identity" + +malformed_total_output="$test_dir/malformed-total-output" +printf 'total: (statements) 96.4junk%%\n' >"$malformed_total_output" +status=0 +PATH="$fake_go_dir:$PATH" FAKE_COVER_OUTPUT_FILE="$malformed_total_output" \ + run_capture "$coverage_output" sh "$repo_root/scripts/check-go-coverage.sh" || status=$? +assert_status 1 "$status" "malformed aggregate" +assert_contains 'expected exactly one numeric total Go statement coverage result' "$coverage_output" "malformed aggregate" + +duplicate_total_output="$test_dir/duplicate-total-output" +printf 'total: (statements) 96.4%%\ntotal: (statements) 99.0%%\n' >"$duplicate_total_output" +status=0 +PATH="$fake_go_dir:$PATH" FAKE_COVER_OUTPUT_FILE="$duplicate_total_output" \ + run_capture "$coverage_output" sh "$repo_root/scripts/check-go-coverage.sh" || status=$? +assert_status 1 "$status" "duplicate aggregate" +assert_contains 'expected exactly one numeric total Go statement coverage result' "$coverage_output" "duplicate aggregate" + +format_repo="$test_dir/format-repo" +format_tmp="$test_dir/format-tmp" +mkdir -p -- "$format_repo" "$format_tmp" +( + cd "$format_repo" + git init -q + printf 'package main\n\nfunc main() {}\n' >'clean.go' + printf 'package main\n' >'-leading.go' + printf 'package main\n' >'space name.go' + tab_name="$(printf 'tab\tname.go')" + printf 'package main\n' >"$tab_name" + printf 'package main\n' >'odd +name.go' + mkdir -p nested + printf 'package nested\n' >'nested/clean.go' + git add -- '*.go' + git add -- 'nested/clean.go' + printf 'this is deliberately not Go\n' >'untracked-ignored.go' + TMPDIR="$format_tmp" sh "$repo_root/scripts/check-go-format.sh" +) +if find "$format_tmp" -type f -print | grep . >/dev/null; then + fail "format success left temporary files behind" +fi + +format_output="$test_dir/format-output" +printf 'package main\n\nfunc broken(\n' >"$format_repo/syntax-error.go" +( + cd "$format_repo" + git add -- 'syntax-error.go' +) +status=0 +( + cd "$format_repo" + TMPDIR="$format_tmp" run_capture "$format_output" sh "$repo_root/scripts/check-go-format.sh" +) || status=$? +[ "$status" -ne 0 ] || fail "syntactically malformed tracked Go file unexpectedly passed" +assert_contains 'syntax-error.go' "$format_output" "syntactically malformed tracked Go file" +if find "$format_tmp" -type f -print | grep . >/dev/null; then + fail "syntax failure left temporary files behind" +fi +( + cd "$format_repo" + git rm -q -f -- 'syntax-error.go' +) + +printf 'package main\nfunc badlyFormatted( ){ }\n' >"$format_repo/bad.go" +( + cd "$format_repo" + git add -- 'bad.go' +) +status=0 +( + cd "$format_repo" + TMPDIR="$format_tmp" run_capture "$format_output" sh "$repo_root/scripts/check-go-format.sh" +) || status=$? +assert_status 1 "$status" "unformatted file" +assert_contains 'bad.go' "$format_output" "unformatted file" + +fake_git_dir="$test_dir/fake-git" +mkdir -p -- "$fake_git_dir" +cat >"$fake_git_dir/git" <<'EOF' +#!/usr/bin/env sh +exit 23 +EOF +chmod +x "$fake_git_dir/git" +status=0 +PATH="$fake_git_dir:$PATH" TMPDIR="$format_tmp" \ + run_capture "$format_output" sh "$repo_root/scripts/check-go-format.sh" || status=$? +assert_status 23 "$status" "git enumeration failure" + +fake_gofmt_dir="$test_dir/fake-gofmt" +mkdir -p -- "$fake_gofmt_dir" +cat >"$fake_gofmt_dir/gofmt" <<'EOF' +#!/usr/bin/env sh +exit 17 +EOF +chmod +x "$fake_gofmt_dir/gofmt" +status=0 +( + cd "$format_repo" + PATH="$fake_gofmt_dir:$PATH" TMPDIR="$format_tmp" \ + run_capture "$format_output" sh "$repo_root/scripts/check-go-format.sh" +) || status=$? +assert_status 17 "$status" "exact gofmt failure through xargs" + +fake_xargs_dir="$test_dir/fake-xargs" +mkdir -p -- "$fake_xargs_dir" +cat >"$fake_xargs_dir/xargs" <<'EOF' +#!/usr/bin/env sh +exit 42 +EOF +chmod +x "$fake_xargs_dir/xargs" +status=0 +( + cd "$format_repo" + PATH="$fake_xargs_dir:$PATH" TMPDIR="$format_tmp" \ + run_capture "$format_output" sh "$repo_root/scripts/check-go-format.sh" +) || status=$? +assert_status 42 "$status" "xargs infrastructure failure" +assert_contains 'format: xargs failed' "$format_output" "xargs infrastructure failure" + +if find "$format_tmp" -type f -print | grep . >/dev/null; then + fail "format failure left temporary files behind" +fi + +echo "check-go-checkers.test: pass" diff --git a/scripts/check-go-coverage.sh b/scripts/check-go-coverage.sh index 1b406d1..c1ada7c 100644 --- a/scripts/check-go-coverage.sh +++ b/scripts/check-go-coverage.sh @@ -1,7 +1,8 @@ #!/usr/bin/env sh set -eu -threshold="${GO_COVERAGE_THRESHOLD:-95.0}" +package_threshold="${GO_PACKAGE_COVERAGE_THRESHOLD:-95.0}" +total_threshold="${GO_TOTAL_COVERAGE_THRESHOLD:-96.4}" cleanup_profile=0 if [ -n "${GO_COVERAGE_PROFILE:-}" ]; then @@ -19,31 +20,72 @@ cleanup() { } trap cleanup EXIT HUP INT TERM -if ! awk -v required="$threshold" 'BEGIN { - exit !(required ~ /^[0-9]+([.][0-9]+)?$/ && required + 0 >= 95 && required + 0 <= 100) -}'; then - echo "coverage: GO_COVERAGE_THRESHOLD must be a number from 95 through 100" >&2 - exit 2 -fi +validate_threshold() { + name="$1" + value="$2" + if ! awk -v required="$value" 'BEGIN { + exit !(required ~ /^[0-9]+([.][0-9]+)?$/ && required + 0 >= 95 && required + 0 <= 100) + }'; then + printf 'coverage: %s must be a number from 95 through 100\n' "$name" >&2 + exit 2 + fi +} + +validate_threshold GO_PACKAGE_COVERAGE_THRESHOLD "$package_threshold" +validate_threshold GO_TOTAL_COVERAGE_THRESHOLD "$total_threshold" : "${CGO_ENABLED:=0}" export CGO_ENABLED -if ! test_output="$(go test ./... -count=1 -covermode=atomic -coverprofile="$profile")"; then +if test_output="$(go test ./... -count=1 -covermode=atomic -coverprofile="$profile")"; then + : +else + status=$? printf '%s\n' "$test_output" - exit 1 + exit "$status" fi printf '%s\n' "$test_output" -package_count="$(go list ./... | awk 'END { print NR }')" -if ! printf '%s\n' "$test_output" | awk -v required="$threshold" -v expected="$package_count" ' - /coverage: [0-9.]+% of statements/ { - seen++ +if package_list="$(go list ./...)"; then + : +else + exit $? +fi +if ! printf '%s\n' "$test_output" | awk -v required="$package_threshold" -v expected_packages="$package_list" ' + BEGIN { + expected_count = split(expected_packages, expected, "\n") + for (i = 1; i <= expected_count; i++) { + if (expected[i] != "") { + expected_set[expected[i]] = 1 + } + } + } + /coverage:/ { package_name = $2 for (i = 1; i <= NF; i++) { if ($i == "coverage:") { - value = $(i + 1) + raw_value = $(i + 1) + if (raw_value !~ /^[0-9]+([.][0-9]+)?%$/) { + printf "coverage: package %s has invalid coverage percentage %s\n", package_name, raw_value > "/dev/stderr" + failed = 1 + next + } + value = raw_value sub(/%$/, "", value) + if (value + 0 < 0 || value + 0 > 100) { + printf "coverage: package %s has invalid coverage percentage %s\n", package_name, raw_value > "/dev/stderr" + failed = 1 + next + } + if (!(package_name in expected_set)) { + printf "coverage: unexpected package result %s\n", package_name > "/dev/stderr" + failed = 1 + } + seen[package_name]++ + if (seen[package_name] > 1) { + printf "coverage: duplicate package result %s\n", package_name > "/dev/stderr" + failed = 1 + } if (value + 0 < required + 0) { printf "coverage: package %s is %.1f%%, below %.1f%%\n", package_name, value, required > "/dev/stderr" failed = 1 @@ -52,9 +94,11 @@ if ! printf '%s\n' "$test_output" | awk -v required="$threshold" -v expected="$p } } END { - if (seen != expected) { - printf "coverage: read %d package results, expected %d\n", seen, expected > "/dev/stderr" - failed = 1 + for (package_name in expected_set) { + if (!(package_name in seen)) { + printf "coverage: missing package result %s\n", package_name > "/dev/stderr" + failed = 1 + } } exit failed } @@ -62,12 +106,37 @@ if ! printf '%s\n' "$test_output" | awk -v required="$threshold" -v expected="$p exit 1 fi -total="$(go tool cover -func="$profile" | awk '/^total:/ { value=$NF; sub(/%$/, "", value); print value }')" - -if [ -z "$total" ]; then - echo "coverage: could not read the total Go statement coverage" >&2 +if cover_output="$(go tool cover -func="$profile")"; then + : +else + exit $? +fi +if total="$(printf '%s\n' "$cover_output" | awk ' + /^total:/ { + count++ + raw_value = $NF + if (raw_value !~ /^[0-9]+([.][0-9]+)?%$/) { + invalid = 1 + } else { + value = raw_value + sub(/%$/, "", value) + if (value + 0 < 0 || value + 0 > 100) { + invalid = 1 + } + } + } + END { + if (count != 1 || invalid) { + exit 1 + } + print value + } +')"; then + : +else + echo "coverage: expected exactly one numeric total Go statement coverage result" >&2 exit 1 fi -printf 'Go statement coverage: %s%% (required: %s%%)\n' "$total" "$threshold" -awk -v actual="$total" -v required="$threshold" 'BEGIN { exit !(actual + 0 >= required + 0) }' +printf 'Go statement coverage: %s%% (required: %s%%)\n' "$total" "$total_threshold" +awk -v actual="$total" -v required="$total_threshold" 'BEGIN { exit !(actual + 0 >= required + 0) }' diff --git a/scripts/check-go-format.sh b/scripts/check-go-format.sh new file mode 100644 index 0000000..16507a8 --- /dev/null +++ b/scripts/check-go-format.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env sh +set -eu + +list_file='' +output_file='' +status_file='' + +cleanup() { + [ -z "$list_file" ] || rm -f -- "$list_file" + [ -z "$output_file" ] || rm -f -- "$output_file" + [ -z "$status_file" ] || rm -f -- "$status_file" +} +trap cleanup EXIT HUP INT TERM + +list_file="$(mktemp "${TMPDIR:-/tmp}/kb-go-format-list.XXXXXX")" +output_file="$(mktemp "${TMPDIR:-/tmp}/kb-go-format-output.XXXXXX")" +status_file="$(mktemp "${TMPDIR:-/tmp}/kb-go-format-status.XXXXXX")" + +if git ls-files -z -- '*.go' >"$list_file"; then + : +else + status=$? + echo "format: could not enumerate tracked Go files" >&2 + exit "$status" +fi + +# The quoted body is intentionally evaluated by the child shell, not this one. +# shellcheck disable=SC2016 +if xargs -0 -r sh -c ' + status_file=$1 + shift + if gofmt -l -- "$@"; then + exit 0 + else + status=$? + fi + printf "%s\n" "$status" >"$status_file" + exit 255 +' sh "$status_file" <"$list_file" >"$output_file"; then + : +else + xargs_status=$? + if [ -s "$status_file" ]; then + status="$(cat "$status_file")" + case "$status" in + *[!0-9]*|'') + echo "format: invalid recorded gofmt status" >&2 + exit "$xargs_status" + ;; + esac + echo "format: gofmt failed" >&2 + exit "$status" + fi + echo "format: xargs failed" >&2 + exit "$xargs_status" +fi + +if [ -s "$output_file" ]; then + echo "format: these tracked Go files are not gofmt-clean:" >&2 + cat "$output_file" >&2 + exit 1 +fi diff --git a/scripts/ci/check-event-diff.sh b/scripts/ci/check-event-diff.sh new file mode 100644 index 0000000..0926606 --- /dev/null +++ b/scripts/ci/check-event-diff.sh @@ -0,0 +1,73 @@ +#!/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 new file mode 100644 index 0000000..0390da0 --- /dev/null +++ b/scripts/ci/create-coverage-manifest.cjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +const { createHash } = require('node:crypto'); +const { lstatSync, 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 sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +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) { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) { + 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`); + } + const sample = readFileSync(path, '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: sha256(path) }; +} + +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 eventBaseSha; + 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 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: securityBase(candidateSha, baseRef, pullRequest, baseSha), + maintenance: pullRequest > 0 ? maintenanceState(baseSha, candidateSha) : false, + }, + 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' }); +} + +try { + main(); +} catch (error) { + console.error(`create-coverage-manifest: ${error.message}`); + process.exitCode = 1; +} diff --git a/scripts/ci/guard-control-plane.sh b/scripts/ci/guard-control-plane.sh new file mode 100644 index 0000000..e2e7e37 --- /dev/null +++ b/scripts/ci/guard-control-plane.sh @@ -0,0 +1,43 @@ +#!/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 new file mode 100644 index 0000000..5316467 --- /dev/null +++ b/scripts/ci/test-npm-ignore-scripts.sh @@ -0,0 +1,22 @@ +#!/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')); +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 test + npm run build +) diff --git a/scripts/ci/test_ci_helpers.py b/scripts/ci/test_ci_helpers.py new file mode 100644 index 0000000..4b1b934 --- /dev/null +++ b/scripts/ci/test_ci_helpers.py @@ -0,0 +1,288 @@ +#!/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" +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_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"]) + + (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"]) + + def test_push_and_dispatch_manifest_preserve_event_base(self): + (self.repo / "src").mkdir() + (self.repo / "internal").mkdir() + (self.repo / "coverage").mkdir() + (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"], 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_ci_monitor.cjs b/scripts/ci/test_ci_monitor.cjs new file mode 100644 index 0000000..7755553 --- /dev/null +++ b/scripts/ci/test_ci_monitor.cjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +const assert = require('node:assert/strict'); +const { chmodSync, mkdtempSync, readFileSync, writeFileSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join, resolve } = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = resolve(__dirname, '../..'); +const monitor = join(root, 'scripts/ci_monitor.cjs'); +const temp = mkdtempSync(join(tmpdir(), 'ci-monitor-')); +const log = join(temp, 'args'); +const fake = join(temp, 'gh'); +writeFileSync(fake, '#!/bin/sh\nprintf "%s\\n" "$@" > "$CI_MONITOR_LOG"\n'); +chmodSync(fake, 0o700); + +function run(args, extraEnv = {}) { + return spawnSync(process.execPath, [monitor, ...args], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, CI_MONITOR_GH: fake, CI_MONITOR_LOG: log, ...extraEnv }, + }); +} + +let result = run(['--help']); +assert.equal(result.status, 0); +assert.match(result.stdout, /runs \[--branch NAME\]/); + +result = run(['runs', '--repo', 'RandomCodeSpace/kb', '--branch', 'main', '--limit', '5']); +assert.equal(result.status, 0, result.stderr); +assert.deepEqual(readFileSync(log, 'utf8').trim().split('\n'), ['run', 'list', '--limit', '5', '--branch', 'main', '-R', 'RandomCodeSpace/kb']); + +result = run(['check-actions']); +assert.equal(result.status, 0, result.stderr); +assert.match(result.stdout, /immutable SHAs/); + +result = run(['watch', 'not-a-run', '--repo', 'RandomCodeSpace/kb']); +assert.equal(result.status, 2); + +console.log('ci_monitor tests passed'); diff --git a/scripts/ci/test_validate_coverage_artifact.py b/scripts/ci/test_validate_coverage_artifact.py new file mode 100644 index 0000000..e162cd6 --- /dev/null +++ b/scripts/ci/test_validate_coverage_artifact.py @@ -0,0 +1,175 @@ +#!/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 / "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", "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): + output = output or self.root / f"out-{os.urandom(4).hex()}" + 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", "pull_request", + "--candidate-repository", "RandomCodeSpace/kb", "--candidate-sha", SHA, + "--candidate-tree", TREE, "--candidate-ref", "feature", "--pull-request", "7", + "--base-sha", self.base, "--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_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 new file mode 100644 index 0000000..3f812b5 --- /dev/null +++ b/scripts/ci/test_validate_workflow_run.cjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +const assert = require('node:assert/strict'); +const { createServer } = require('node:http'); +const { mkdtempSync, readFileSync, writeFileSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { spawn } = require('node:child_process'); + +const repository = 'RandomCodeSpace/kb'; +const head = '1'.repeat(40); +const tree = '2'.repeat(40); +const base = '3'.repeat(40); +const merge = '4'.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 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: repository }, + }, +}; +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: repository }, + pull_requests: [{ number: 7, head: { sha: head, ref: 'feature', repo: { full_name: repository } }, 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/RandomCodeSpace/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: repository } }, + base: { sha: base, ref: 'main', repo: { full_name: repository } }, + merge_commit_sha: merge, + }], +]); + +const server = createServer((request, response) => { + const fixture = fixtures.get(request.url); + response.writeHead(fixture ? 200 : 404, { 'content-type': 'application/json' }); + response.end(JSON.stringify(fixture || { message: 'not found' })); +}); + +server.listen(0, '127.0.0.1', () => { + 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', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('close', (code) => { + assert.equal(code, 0, 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/); + server.close(() => console.log('workflow-run API fixture passed')); + }); +}); diff --git a/scripts/ci/test_workflow_structure.py b/scripts/ci/test_workflow_structure.py new file mode 100644 index 0000000..9f27796 --- /dev/null +++ b/scripts/ci/test_workflow_structure.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +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() + + +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_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("ref: ${{ github.event.pull_request.head.sha || github.sha }}", job) + + 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 new file mode 100644 index 0000000..a61728b --- /dev/null +++ b/scripts/ci/validate-candidate-tree.sh @@ -0,0 +1,30 @@ +#!/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 new file mode 100644 index 0000000..f0b080f --- /dev/null +++ b/scripts/ci/validate-coverage-artifact.py @@ -0,0 +1,289 @@ +#!/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", 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", 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 new file mode 100644 index 0000000..121f07c --- /dev/null +++ b/scripts/ci/validate-workflow-run.cjs @@ -0,0 +1,172 @@ +#!/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; +} + +async function github(path) { + const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com'; + const response = await fetch(`${apiUrl}${path}`, { + 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 = required('GITHUB_REPOSITORY'); + 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'); + equal(trigger.head_repository?.full_name, repository, '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'); + safeInteger(trigger.id, 'workflow run id'); + safeInteger(trigger.workflow_id, 'workflow id'); + safeInteger(trigger.run_attempt, 'workflow run attempt'); + safeInteger(trigger.head_repository.id, 'head repository id'); + + const encodedRepo = repository.split('/').map(encodeURIComponent).join('/'); + const run = await github(`/repos/${encodedRepo}/actions/runs/${trigger.id}`); + equal(run.id, trigger.id, 'API run id'); + equal(run.workflow_id, trigger.workflow_id, '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, repository, 'API head repository'); + equal(run.head_sha, trigger.head_sha, 'API head SHA'); + equal(run.run_attempt, trigger.run_attempt, 'API run attempt'); + const workflowFile = WORKFLOW_PATH.split('/').at(-1); + const workflow = await github(`/repos/${encodedRepo}/actions/workflows/${encodeURIComponent(workflowFile)}`); + equal(workflow.id, trigger.workflow_id, '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/${encodedRepo}/actions/runs/${trigger.id}/jobs?filter=latest&per_page=100`); + const jobMatches = jobs.jobs.filter((job) => job.name === JOB_NAME && job.run_attempt === trigger.run_attempt); + 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, trigger.id, 'producer job run id'); + equal(job.status, 'completed', 'producer job status'); + equal(job.conclusion, 'success', 'producer job conclusion'); + equal(job.head_sha, trigger.head_sha, 'producer job head SHA'); + + const artifacts = await github(`/repos/${encodedRepo}/actions/runs/${trigger.id}/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-${trigger.head_sha}`, '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, trigger.id, 'artifact workflow run id'); + equal(artifact.workflow_run?.head_sha, trigger.head_sha, 'artifact head SHA'); + equal(artifact.workflow_run?.head_repository_id, trigger.head_repository.id, 'artifact head repository id'); + + const commit = await github(`/repos/${encodedRepo}/git/commits/${trigger.head_sha}`); + equal(commit.sha, trigger.head_sha, '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, trigger.head_sha, 'pull request head SHA'); + pullRequest = pull.number; + mode = 'pull_request'; + const pullDetails = await github(`/repos/${encodedRepo}/pulls/${pull.number}`); + equal(pullDetails.head?.sha, trigger.head_sha, 'pull request API head SHA'); + equal(pullDetails.head?.repo?.full_name, repository, 'pull request API head repository'); + equal(pullDetails.base?.repo?.full_name, repository, 'pull request API base repository'); + baseSha = pullDetails.base?.sha; + baseRef = pullDetails.base?.ref; + candidateRef = pullDetails.head?.ref; + workflowRef = `${repository}/${WORKFLOW_PATH}@refs/pull/${pull.number}/merge`; + workflowSha = pullDetails.merge_commit_sha; + } else { + const parents = commit.parents || []; + baseSha = parents.length ? parents[0].sha : trigger.head_sha; + 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 = trigger.head_sha; + } + 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: trigger.id, + run_attempt: trigger.run_attempt, + job_id: job.id, + event: trigger.event, + mode, + candidate_repository: repository, + candidate_sha: trigger.head_sha, + 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 new file mode 100644 index 0000000..8a056ef --- /dev/null +++ b/scripts/ci_monitor.cjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node + +// Repository-local replacement for the github-workflows skill's absent monitor. +// 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 { join } = require('node:path'); + +const HELP = `usage: node scripts/ci_monitor.cjs [arguments] + +commands: + runs [--branch NAME] [--limit N] list recent workflow runs + watch RUN_ID watch a run and return its conclusion + fail-fast RUN_ID watch a run with failure exit status + log-failed RUN_ID print failed job logs + test-summary RUN_ID print job names and conclusions + check-actions [FILE] reject non-SHA action references + grep RUN_ID --pattern REGEX search complete run logs + wait-for RUN_ID JOB --keyword TEXT wait until a job log contains text + +global: + --repo OWNER/REPO override repository detection +`; + +function die(message) { + console.error(`ci_monitor: ${message}`); + process.exit(2); +} + +function extractOption(args, name, fallback) { + const index = args.indexOf(name); + if (index < 0) return fallback; + if (!args[index + 1] || args[index + 1].startsWith('--')) die(`${name} requires a value`); + const value = args[index + 1]; + args.splice(index, 2); + return value; +} + +function repository(args) { + const explicit = extractOption(args, '--repo', process.env.GITHUB_REPOSITORY); + if (explicit) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(explicit)) die('invalid --repo value'); + return explicit; + } + let remote; + try { + remote = execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' }).trim(); + } catch { + die('cannot detect repository; pass --repo OWNER/REPO'); + } + const match = remote.match(/(?:github\.com[:/])([^/]+)\/([^/]+?)(?:\.git)?$/); + if (!match) die('origin is not a GitHub remote; pass --repo OWNER/REPO'); + return `${match[1]}/${match[2]}`; +} + +function gh(repo, args, capture = false) { + const executable = process.env.CI_MONITOR_GH || 'gh'; + const result = spawnSync(executable, [...args, '-R', repo], { + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + if (result.error) die(result.error.message); + if (capture && result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) process.exit(result.status ?? 1); + return result.stdout || ''; +} + +function positiveInteger(value, label) { + if (!/^[1-9][0-9]*$/.test(value || '') || !Number.isSafeInteger(Number(value))) die(`${label} must be a positive safe integer`); + return value; +} + +function checkActions(files) { + function actionFiles(directory) { + 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] : []; + }); + } + const selected = files.length ? files : [ + '.github/workflows/quality.yml', + '.github/workflows/sonar-exact-revision.yml', + ...actionFiles('.github/actions'), + ]; + let failures = 0; + for (const file of selected) { + const text = readFileSync(file, 'utf8'); + for (const [lineIndex, line] of text.split('\n').entries()) { + const match = line.match(/^\s*uses:\s*([^\s#]+)\s*/); + if (!match || match[1].startsWith('./')) continue; + const at = match[1].lastIndexOf('@'); + const ref = at >= 0 ? match[1].slice(at + 1) : ''; + if (!/^[0-9a-f]{40}$/.test(ref)) { + console.error(`${file}:${lineIndex + 1}: action is not pinned to a full commit SHA: ${match[1]}`); + failures += 1; + } + } + } + if (failures) process.exit(1); + console.log(`checked ${selected.length} workflow file(s): all external actions use immutable SHAs`); +} + +function main() { + const args = process.argv.slice(2); + if (!args.length || args.includes('--help') || args[0] === 'help') { + process.stdout.write(HELP); + return; + } + const repo = repository(args); + const command = args.shift(); + switch (command) { + case 'runs': { + const branch = extractOption(args, '--branch'); + const limit = positiveInteger(extractOption(args, '--limit', '20'), '--limit'); + if (args.length) die(`unexpected arguments: ${args.join(' ')}`); + gh(repo, ['run', 'list', '--limit', limit, ...(branch ? ['--branch', branch] : [])]); + break; + } + case 'watch': + case 'fail-fast': { + const runId = positiveInteger(args.shift(), 'run id'); + if (args.length) die(`unexpected arguments: ${args.join(' ')}`); + gh(repo, ['run', 'watch', runId, '--exit-status']); + break; + } + case 'log-failed': { + const runId = positiveInteger(args.shift(), 'run id'); + if (args.length) die(`unexpected arguments: ${args.join(' ')}`); + gh(repo, ['run', 'view', runId, '--log-failed']); + break; + } + case 'test-summary': { + const runId = positiveInteger(args.shift(), 'run id'); + if (args.length) die(`unexpected arguments: ${args.join(' ')}`); + gh(repo, ['run', 'view', runId, '--json', 'jobs', '--jq', '.jobs[] | [.name, .conclusion] | @tsv']); + break; + } + case 'check-actions': + checkActions(args); + break; + case 'grep': { + const runId = positiveInteger(args.shift(), 'run id'); + const pattern = extractOption(args, '--pattern'); + if (!pattern || args.length) die('grep requires RUN_ID --pattern REGEX'); + let regex; + try { regex = new RegExp(pattern); } catch (error) { die(`invalid regex: ${error.message}`); } + const lines = gh(repo, ['run', 'view', runId, '--log'], true).split('\n').filter((line) => regex.test(line)); + process.stdout.write(lines.length ? `${lines.join('\n')}\n` : ''); + process.exitCode = lines.length ? 0 : 1; + break; + } + case 'wait-for': { + const runId = positiveInteger(args.shift(), 'run id'); + const job = args.shift(); + const keyword = extractOption(args, '--keyword'); + if (!job || !keyword || args.length) die('wait-for requires RUN_ID JOB --keyword TEXT'); + const jobs = JSON.parse(gh(repo, ['run', 'view', runId, '--json', 'jobs'], true)).jobs; + const matches = jobs.filter((item) => item.name === job); + if (matches.length !== 1) die(`expected one job named ${job}, found ${matches.length}`); + const log = gh(repo, ['run', 'view', runId, '--job', String(matches[0].databaseId), '--log'], true); + if (!log.includes(keyword)) process.exit(1); + console.log(`found ${JSON.stringify(keyword)} in ${job}`); + break; + } + default: + die(`unknown command ${command}`); + } +} + +main(); diff --git a/vite.config.ts b/vite.config.ts index 337a4be..3d17dd0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,10 +34,10 @@ export default defineConfig({ 'src/test/**', ], thresholds: { - branches: 95, - functions: 95, - lines: 95, - statements: 95, + branches: 95.07, + functions: 98.71, + lines: 99.42, + statements: 98.20, }, }, // Vitest replaces CSS modules with an empty string by default, which also From a670ca1cfc8b1bff484e2e97004fb7cbecbfee8d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 1 Aug 2026 10:41:49 +0000 Subject: [PATCH 2/5] fix(ci): harden sonar bootstrap checks --- .../protected-sonar/verify-sonar-task.cjs | 20 ++- .github/workflows/sonar-exact-revision.yml | 5 + scripts/ci/create-coverage-manifest.cjs | 49 ++++-- scripts/ci/test_validate_workflow_run.cjs | 118 +++++++++++++- scripts/ci/test_workflow_structure.py | 10 ++ scripts/ci/validate-workflow-run.cjs | 69 ++++---- src/App.dom.test.tsx | 147 +++++++++++++++++- 7 files changed, 360 insertions(+), 58 deletions(-) diff --git a/.github/actions/protected-sonar/verify-sonar-task.cjs b/.github/actions/protected-sonar/verify-sonar-task.cjs index 8927c2d..b79b230 100644 --- a/.github/actions/protected-sonar/verify-sonar-task.cjs +++ b/.github/actions/protected-sonar/verify-sonar-task.cjs @@ -2,6 +2,8 @@ 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}`); @@ -33,12 +35,12 @@ async function main() { 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.protocol !== 'https:' || serverUrl.hostname !== 'sonarcloud.io' || serverUrl.username || serverUrl.password) { + 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)}`, serverUrl)); + 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'}`); } @@ -46,7 +48,7 @@ async function main() { throw new Error(`compute-engine component ${task.task.componentKey} != ${required('SONAR_PROJECT_KEY')}`); } - const query = new URL('/api/project_analyses/search', serverUrl); + 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')); @@ -58,7 +60,11 @@ async function main() { console.log(`verified Sonar task ${ceTaskId}, analysis ${analysis.key}, revision ${analysis.revision}`); } -main().catch((error) => { - console.error(`verify-sonar-task: ${error.message}`); - process.exitCode = 1; -}); +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/sonar-exact-revision.yml b/.github/workflows/sonar-exact-revision.yml index 56c3a8e..41628e7 100644 --- a/.github/workflows/sonar-exact-revision.yml +++ b/.github/workflows/sonar-exact-revision.yml @@ -62,6 +62,11 @@ jobs: 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 diff --git a/scripts/ci/create-coverage-manifest.cjs b/scripts/ci/create-coverage-manifest.cjs index 0390da0..745eea5 100644 --- a/scripts/ci/create-coverage-manifest.cjs +++ b/scripts/ci/create-coverage-manifest.cjs @@ -1,7 +1,7 @@ #!/usr/bin/env node const { createHash } = require('node:crypto'); -const { lstatSync, readFileSync, writeFileSync } = require('node:fs'); +const { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, writeFileSync } = require('node:fs'); const { execFileSync } = require('node:child_process'); const { posix: pathPosix } = require('node:path'); @@ -16,10 +16,6 @@ function required(name) { return value; } -function sha256(path) { - return createHash('sha256').update(readFileSync(path)).digest('hex'); -} - 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`); @@ -35,14 +31,29 @@ function validateRelativeSource(path, label) { } function validateReport(kind, path, limit) { - const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink()) { - throw new Error(`${path} must be a regular, non-symlink file`); + 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}`); } - if (stat.size === 0 || stat.size > limit) { - throw new Error(`${path} size ${stat.size} is outside 1..${limit} bytes`); + 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 = readFileSync(path, 'utf8'); + 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)); @@ -60,7 +71,7 @@ function validateReport(kind, path, limit) { validateRelativeSource(match[1].slice(modulePath.length + 1), path); } } - return { path, size: stat.size, sha256: sha256(path) }; + return { path, size: stat.size, sha256: createHash('sha256').update(content).digest('hex') }; } function git(...args) { @@ -154,9 +165,13 @@ function main() { writeFileSync('coverage/manifest.json', `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' }); } -try { - main(); -} catch (error) { - console.error(`create-coverage-manifest: ${error.message}`); - process.exitCode = 1; +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/test_validate_workflow_run.cjs b/scripts/ci/test_validate_workflow_run.cjs index 3f812b5..25120d1 100644 --- a/scripts/ci/test_validate_workflow_run.cjs +++ b/scripts/ci/test_validate_workflow_run.cjs @@ -1,8 +1,10 @@ #!/usr/bin/env node const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); const { createServer } = require('node:http'); -const { mkdtempSync, readFileSync, writeFileSync } = require('node:fs'); +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'); @@ -18,6 +20,7 @@ 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' }, @@ -57,6 +60,7 @@ const fixtures = new Map([ ]); 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' })); @@ -71,6 +75,11 @@ server.listen(0, '127.0.0.1', () => { 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, }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -83,6 +92,111 @@ server.listen(0, '127.0.0.1', () => { 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/); - server.close(() => console.log('workflow-run API fixture passed')); + const requestCount = requests.length; + event.workflow_run.id = runId + 1; + writeFileSync(eventPath, JSON.stringify(event)); + const hostile = 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, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let hostileStderr = ''; + hostile.stderr.on('data', (chunk) => { hostileStderr += chunk; }); + hostile.on('close', async (hostileCode) => { + assert.notEqual(hostileCode, 0); + assert.match(hostileStderr, /trusted workflow run id mismatch/); + assert.equal(requests.length, requestCount, 'mismatched event id must not reach the network'); + await testManifestDescriptorReads(); + await testSonarOriginPinning(); + server.close(() => console.log('CI JavaScript hostile fixtures passed')); + }); }); }); + +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 index 9f27796..199b7be 100644 --- a/scripts/ci/test_workflow_structure.py +++ b/scripts/ci/test_workflow_structure.py @@ -29,6 +29,16 @@ def test_privileged_workflow_executes_only_trusted_helpers(self): 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) diff --git a/scripts/ci/validate-workflow-run.cjs b/scripts/ci/validate-workflow-run.cjs index 121f07c..f999112 100644 --- a/scripts/ci/validate-workflow-run.cjs +++ b/scripts/ci/validate-workflow-run.cjs @@ -22,6 +22,12 @@ function safeInteger(value, label) { 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); +} + async function github(path) { const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com'; const response = await fetch(`${apiUrl}${path}`, { @@ -46,6 +52,12 @@ function output(values) { async function main() { const event = JSON.parse(readFileSync(required('GITHUB_EVENT_PATH'), 'utf8')); const repository = required('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'); @@ -56,55 +68,56 @@ async function main() { 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'); - safeInteger(trigger.id, 'workflow run id'); - safeInteger(trigger.workflow_id, 'workflow id'); - safeInteger(trigger.run_attempt, 'workflow run attempt'); - safeInteger(trigger.head_repository.id, 'head repository id'); + 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 encodedRepo = repository.split('/').map(encodeURIComponent).join('/'); - const run = await github(`/repos/${encodedRepo}/actions/runs/${trigger.id}`); - equal(run.id, trigger.id, 'API run id'); - equal(run.workflow_id, trigger.workflow_id, 'API workflow id'); + const run = await github(`/repos/${encodedRepo}/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, repository, 'API head repository'); - equal(run.head_sha, trigger.head_sha, 'API head SHA'); - equal(run.run_attempt, trigger.run_attempt, 'API run attempt'); + 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/${encodedRepo}/actions/workflows/${encodeURIComponent(workflowFile)}`); - equal(workflow.id, trigger.workflow_id, 'workflow-by-path id'); + 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/${encodedRepo}/actions/runs/${trigger.id}/jobs?filter=latest&per_page=100`); - const jobMatches = jobs.jobs.filter((job) => job.name === JOB_NAME && job.run_attempt === trigger.run_attempt); + const jobs = await github(`/repos/${encodedRepo}/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, trigger.id, 'producer job run 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, trigger.head_sha, 'producer job head SHA'); + equal(job.head_sha, triggerHeadSha, 'producer job head SHA'); - const artifacts = await github(`/repos/${encodedRepo}/actions/runs/${trigger.id}/artifacts?per_page=100`); + const artifacts = await github(`/repos/${encodedRepo}/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-${trigger.head_sha}`, 'artifact name'); + 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, trigger.id, 'artifact workflow run id'); - equal(artifact.workflow_run?.head_sha, trigger.head_sha, 'artifact head SHA'); - equal(artifact.workflow_run?.head_repository_id, trigger.head_repository.id, 'artifact head repository id'); + 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/${encodedRepo}/git/commits/${trigger.head_sha}`); - equal(commit.sha, trigger.head_sha, 'candidate commit SHA'); + const commit = await github(`/repos/${encodedRepo}/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'); @@ -120,11 +133,11 @@ async function main() { 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, trigger.head_sha, 'pull request head SHA'); + equal(pull.head?.sha, triggerHeadSha, 'pull request head SHA'); pullRequest = pull.number; mode = 'pull_request'; const pullDetails = await github(`/repos/${encodedRepo}/pulls/${pull.number}`); - equal(pullDetails.head?.sha, trigger.head_sha, 'pull request API head SHA'); + equal(pullDetails.head?.sha, triggerHeadSha, 'pull request API head SHA'); equal(pullDetails.head?.repo?.full_name, repository, 'pull request API head repository'); equal(pullDetails.base?.repo?.full_name, repository, 'pull request API base repository'); baseSha = pullDetails.base?.sha; @@ -134,11 +147,11 @@ async function main() { workflowSha = pullDetails.merge_commit_sha; } else { const parents = commit.parents || []; - baseSha = parents.length ? parents[0].sha : trigger.head_sha; + 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 = trigger.head_sha; + 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]]) { @@ -149,13 +162,13 @@ async function main() { output({ artifact_id: artifact.id, artifact_name: artifact.name, - run_id: trigger.id, - run_attempt: trigger.run_attempt, + run_id: triggerRunId, + run_attempt: triggerRunAttempt, job_id: job.id, event: trigger.event, mode, candidate_repository: repository, - candidate_sha: trigger.head_sha, + candidate_sha: triggerHeadSha, candidate_tree: candidateTree, candidate_ref: candidateRef, workflow_ref: workflowRef, diff --git a/src/App.dom.test.tsx b/src/App.dom.test.tsx index 2bac27a..d4a00ae 100644 --- a/src/App.dom.test.tsx +++ b/src/App.dom.test.tsx @@ -10,6 +10,10 @@ const state = vi.hoisted(() => ({ identity: null as Identity | null, resolveIdentity: null as Identity | null, resolveError: null as unknown, + resolveDeferred: false, + resolveSettler: null as null | { + resolve: (identity: Identity) => void; + }, board: null as Board | null, importBoard: null as Board | null, importError: false, @@ -17,6 +21,9 @@ const state = vi.hoisted(() => ({ detect: false, detectDeferred: false, detectResolve: null as null | ((value: boolean) => void), + startupDeferred: false, + startupResolve: null as null | (() => void), + startupCalls: 0, remoteBoard: null as Board | null, migratedRaw: false, pendingWrite: null as null | Record, @@ -31,7 +38,14 @@ const state = vi.hoisted(() => ({ persistenceFails: false, persistenceConflicts: [] as string[], remoteSaveError: null as Error | null, + remoteSaveCalls: 0, prepareError: null as Error | null, + prepareDeferred: false, + prepareResolve: null as null | (() => void), + commitDeferred: false, + commitDeferredPersisted: true, + commitResolve: null as null | (() => void), + commitCalls: 0, remoteSaveConflicts: [] as string[], labels: [] as string[], settings: { has_key: false, ai_base_url: '' }, @@ -98,10 +112,15 @@ vi.mock('./lib/auth', () => { state.clearedIdentity += 1; state.identity = null; }, - resolveAzureIdentity: async () => { - if (state.resolveError) throw state.resolveError; - if (!state.resolveIdentity) throw new Error('no restored identity configured'); - return state.resolveIdentity; + resolveAzureIdentity: () => { + if (state.resolveDeferred) { + return new Promise((resolve) => { + state.resolveSettler = { resolve }; + }); + } + if (state.resolveError) return Promise.reject(state.resolveError); + if (!state.resolveIdentity) return Promise.reject(new Error('no restored identity configured')); + return Promise.resolve(state.resolveIdentity); }, identityNamespace: (identity: Identity) => identity.kind === 'azure' ? `azure.${identity.homeAccountId ?? 'pending'}` : identity.id, @@ -188,6 +207,19 @@ vi.mock('./lib/remote', () => { deleted: ReadonlySet, ) => { if (state.prepareError) throw state.prepareError; + if (state.prepareDeferred) { + return new Promise<{ + board: Board; + taskIDs: ReadonlyMap; + deletedCanonicalIDs: ReadonlySet; + }>((resolve) => { + state.prepareResolve = () => resolve({ + board: nextBoard, + taskIDs: ids, + deletedCanonicalIDs: deleted, + }); + }); + } return { board: nextBoard, taskIDs: ids, deletedCanonicalIDs: deleted }; }); saveRemote = vi.fn(( @@ -200,6 +232,7 @@ vi.mock('./lib/remote', () => { isLiveCurrent?: () => boolean; } = {}, ) => { + state.remoteSaveCalls += 1; options.isLiveCurrent?.(); if (state.remoteSaveError) return onError(state.remoteSaveError); const ids = new Map(nextBoard.tasks.map((item) => [item.id, `canonical-${item.id}`])); @@ -237,9 +270,24 @@ vi.mock('./lib/remote', () => { readDurable?: () => unknown; cancelled?: () => boolean; }) => { + state.commitCalls += 1; options.readLive?.(); options.readDurable?.(); options.cancelled?.(); + if (state.commitDeferred) { + return new Promise((resolve) => { + state.commitResolve = () => resolve({ + candidate: options.candidate, + conflicts: [], + persisted: state.commitDeferredPersisted, + snapshot: state.commitDeferredPersisted + ? snapshot(options.candidate.board, 1) + : undefined, + recoveryPending: false, + writes: 1, + }); + }); + } if (state.commitMode === 'recovery') { return { candidate: options.candidate, conflicts: ['live board'], persisted: false, @@ -279,9 +327,25 @@ vi.mock('./lib/remote', () => { }; }, reconcileStartupBoardFetch: async (options: Record unknown>) => { + state.startupCalls += 1; if (state.startupMode === 'throw') throw new Error('startup fetch failed'); const remoteBoard = state.remoteBoard; const ids = new Map((remoteBoard?.tasks ?? []).map((item) => [item.id, `canonical-${item.id}`])); + if (state.startupDeferred) { + return new Promise<{ + remoteBoard: Board | null; + remoteTaskIDs: ReadonlyMap; + merged: null; + persisted: false; + }>((resolve) => { + state.startupResolve = () => resolve({ + remoteBoard, + remoteTaskIDs: ids, + merged: null, + persisted: false, + }); + }); + } if (state.startupMode === 'callbacks' && remoteBoard) { options.readLive(); options.readSnapshot(); @@ -552,6 +616,8 @@ beforeEach(() => { state.identity = manual; state.resolveIdentity = null; state.resolveError = null; + state.resolveDeferred = false; + state.resolveSettler = null; state.board = board(); state.importBoard = null; state.importError = false; @@ -559,6 +625,9 @@ beforeEach(() => { state.detect = false; state.detectDeferred = false; state.detectResolve = null; + state.startupDeferred = false; + state.startupResolve = null; + state.startupCalls = 0; state.remoteBoard = null; state.migratedRaw = false; state.pendingWrite = null; @@ -573,7 +642,14 @@ beforeEach(() => { state.persistenceFails = false; state.persistenceConflicts = []; state.remoteSaveError = null; + state.remoteSaveCalls = 0; state.prepareError = null; + state.prepareDeferred = false; + state.prepareResolve = null; + state.commitDeferred = false; + state.commitDeferredPersisted = true; + state.commitResolve = null; + state.commitCalls = 0; state.remoteSaveConflicts = []; state.labels = []; state.settings = { has_key: false, ai_base_url: '' }; @@ -638,6 +714,69 @@ describe('App DOM orchestration', () => { ); }); + it('ignores Azure restoration completion after unmount', async () => { + state.identity = { kind: 'azure', id: 'alice@example.com' }; + state.resolveDeferred = true; + const restored: Identity = { + kind: 'azure', id: 'alice@example.com', name: 'Alice Azure', homeAccountId: 'home-1', + }; + const resolved = render(); + await waitFor(() => expect(state.resolveSettler).not.toBeNull()); + const resolve = state.resolveSettler!.resolve; + resolved.unmount(); + await act(async () => resolve(restored)); + expect(state.savedIdentities).toEqual([]); + }); + + it('stops remote startup work when detection or fetch settles after unmount', async () => { + state.detectDeferred = true; + const detecting = render(); + await waitFor(() => expect(state.detectResolve).not.toBeNull()); + detecting.unmount(); + await act(async () => state.detectResolve!(true)); + expect(state.startupCalls).toBe(0); + + state.detectDeferred = false; + state.detect = true; + state.startupDeferred = true; + state.remoteBoard = board([task({ id: 'late-fetch', title: 'Late fetch' })]); + const fetching = render(); + await waitFor(() => expect(state.startupResolve).not.toBeNull()); + fetching.unmount(); + await act(async () => state.startupResolve!()); + expect(state.commitCalls).toBe(0); + }); + + it('stops dirty-board preparation and persistence after unmount', async () => { + state.detect = true; + state.dirty = true; + state.prepareDeferred = true; + const preparing = render(); + await waitFor(() => expect(state.prepareResolve).not.toBeNull()); + preparing.unmount(); + await act(async () => state.prepareResolve!()); + expect(state.commitCalls).toBe(0); + + state.prepareDeferred = false; + state.commitDeferred = true; + const persisting = render(); + await waitFor(() => expect(state.commitResolve).not.toBeNull()); + persisting.unmount(); + await act(async () => state.commitResolve!()); + expect(state.remoteSaveCalls).toBe(0); + + state.dirty = false; + state.remoteBoard = board([task({ id: 'late-remote', title: 'Late remote' })]); + state.commitDeferredPersisted = false; + state.commitResolve = null; + const dirtyWrites = state.dirtyWrites.length; + const adopting = render(); + await waitFor(() => expect(state.commitResolve).not.toBeNull()); + adopting.unmount(); + await act(async () => state.commitResolve!()); + expect(state.dirtyWrites).toHaveLength(dirtyWrites); + }); + it('adds, edits, moves, checks, ships, cancels, restores, and purges cards', async () => { const user = userEvent.setup(); render(); From 826feb1591595884a38c20590fae9a3e98057c08 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 1 Aug 2026 10:53:18 +0000 Subject: [PATCH 3/5] fix(ci): keep bootstrap control plane isolated --- scripts/ci/test-npm-ignore-scripts.sh | 5 +- src/App.dom.test.tsx | 147 +------------------------- 2 files changed, 8 insertions(+), 144 deletions(-) diff --git a/scripts/ci/test-npm-ignore-scripts.sh b/scripts/ci/test-npm-ignore-scripts.sh index 5316467..2922bf2 100644 --- a/scripts/ci/test-npm-ignore-scripts.sh +++ b/scripts/ci/test-npm-ignore-scripts.sh @@ -9,6 +9,9 @@ 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 @@ -17,6 +20,6 @@ NODE cd "$test_dir" npm ci --ignore-scripts test ! -e postinstall-ran - npm test + npm run test:unit npm run build ) diff --git a/src/App.dom.test.tsx b/src/App.dom.test.tsx index d4a00ae..2bac27a 100644 --- a/src/App.dom.test.tsx +++ b/src/App.dom.test.tsx @@ -10,10 +10,6 @@ const state = vi.hoisted(() => ({ identity: null as Identity | null, resolveIdentity: null as Identity | null, resolveError: null as unknown, - resolveDeferred: false, - resolveSettler: null as null | { - resolve: (identity: Identity) => void; - }, board: null as Board | null, importBoard: null as Board | null, importError: false, @@ -21,9 +17,6 @@ const state = vi.hoisted(() => ({ detect: false, detectDeferred: false, detectResolve: null as null | ((value: boolean) => void), - startupDeferred: false, - startupResolve: null as null | (() => void), - startupCalls: 0, remoteBoard: null as Board | null, migratedRaw: false, pendingWrite: null as null | Record, @@ -38,14 +31,7 @@ const state = vi.hoisted(() => ({ persistenceFails: false, persistenceConflicts: [] as string[], remoteSaveError: null as Error | null, - remoteSaveCalls: 0, prepareError: null as Error | null, - prepareDeferred: false, - prepareResolve: null as null | (() => void), - commitDeferred: false, - commitDeferredPersisted: true, - commitResolve: null as null | (() => void), - commitCalls: 0, remoteSaveConflicts: [] as string[], labels: [] as string[], settings: { has_key: false, ai_base_url: '' }, @@ -112,15 +98,10 @@ vi.mock('./lib/auth', () => { state.clearedIdentity += 1; state.identity = null; }, - resolveAzureIdentity: () => { - if (state.resolveDeferred) { - return new Promise((resolve) => { - state.resolveSettler = { resolve }; - }); - } - if (state.resolveError) return Promise.reject(state.resolveError); - if (!state.resolveIdentity) return Promise.reject(new Error('no restored identity configured')); - return Promise.resolve(state.resolveIdentity); + resolveAzureIdentity: async () => { + if (state.resolveError) throw state.resolveError; + if (!state.resolveIdentity) throw new Error('no restored identity configured'); + return state.resolveIdentity; }, identityNamespace: (identity: Identity) => identity.kind === 'azure' ? `azure.${identity.homeAccountId ?? 'pending'}` : identity.id, @@ -207,19 +188,6 @@ vi.mock('./lib/remote', () => { deleted: ReadonlySet, ) => { if (state.prepareError) throw state.prepareError; - if (state.prepareDeferred) { - return new Promise<{ - board: Board; - taskIDs: ReadonlyMap; - deletedCanonicalIDs: ReadonlySet; - }>((resolve) => { - state.prepareResolve = () => resolve({ - board: nextBoard, - taskIDs: ids, - deletedCanonicalIDs: deleted, - }); - }); - } return { board: nextBoard, taskIDs: ids, deletedCanonicalIDs: deleted }; }); saveRemote = vi.fn(( @@ -232,7 +200,6 @@ vi.mock('./lib/remote', () => { isLiveCurrent?: () => boolean; } = {}, ) => { - state.remoteSaveCalls += 1; options.isLiveCurrent?.(); if (state.remoteSaveError) return onError(state.remoteSaveError); const ids = new Map(nextBoard.tasks.map((item) => [item.id, `canonical-${item.id}`])); @@ -270,24 +237,9 @@ vi.mock('./lib/remote', () => { readDurable?: () => unknown; cancelled?: () => boolean; }) => { - state.commitCalls += 1; options.readLive?.(); options.readDurable?.(); options.cancelled?.(); - if (state.commitDeferred) { - return new Promise((resolve) => { - state.commitResolve = () => resolve({ - candidate: options.candidate, - conflicts: [], - persisted: state.commitDeferredPersisted, - snapshot: state.commitDeferredPersisted - ? snapshot(options.candidate.board, 1) - : undefined, - recoveryPending: false, - writes: 1, - }); - }); - } if (state.commitMode === 'recovery') { return { candidate: options.candidate, conflicts: ['live board'], persisted: false, @@ -327,25 +279,9 @@ vi.mock('./lib/remote', () => { }; }, reconcileStartupBoardFetch: async (options: Record unknown>) => { - state.startupCalls += 1; if (state.startupMode === 'throw') throw new Error('startup fetch failed'); const remoteBoard = state.remoteBoard; const ids = new Map((remoteBoard?.tasks ?? []).map((item) => [item.id, `canonical-${item.id}`])); - if (state.startupDeferred) { - return new Promise<{ - remoteBoard: Board | null; - remoteTaskIDs: ReadonlyMap; - merged: null; - persisted: false; - }>((resolve) => { - state.startupResolve = () => resolve({ - remoteBoard, - remoteTaskIDs: ids, - merged: null, - persisted: false, - }); - }); - } if (state.startupMode === 'callbacks' && remoteBoard) { options.readLive(); options.readSnapshot(); @@ -616,8 +552,6 @@ beforeEach(() => { state.identity = manual; state.resolveIdentity = null; state.resolveError = null; - state.resolveDeferred = false; - state.resolveSettler = null; state.board = board(); state.importBoard = null; state.importError = false; @@ -625,9 +559,6 @@ beforeEach(() => { state.detect = false; state.detectDeferred = false; state.detectResolve = null; - state.startupDeferred = false; - state.startupResolve = null; - state.startupCalls = 0; state.remoteBoard = null; state.migratedRaw = false; state.pendingWrite = null; @@ -642,14 +573,7 @@ beforeEach(() => { state.persistenceFails = false; state.persistenceConflicts = []; state.remoteSaveError = null; - state.remoteSaveCalls = 0; state.prepareError = null; - state.prepareDeferred = false; - state.prepareResolve = null; - state.commitDeferred = false; - state.commitDeferredPersisted = true; - state.commitResolve = null; - state.commitCalls = 0; state.remoteSaveConflicts = []; state.labels = []; state.settings = { has_key: false, ai_base_url: '' }; @@ -714,69 +638,6 @@ describe('App DOM orchestration', () => { ); }); - it('ignores Azure restoration completion after unmount', async () => { - state.identity = { kind: 'azure', id: 'alice@example.com' }; - state.resolveDeferred = true; - const restored: Identity = { - kind: 'azure', id: 'alice@example.com', name: 'Alice Azure', homeAccountId: 'home-1', - }; - const resolved = render(); - await waitFor(() => expect(state.resolveSettler).not.toBeNull()); - const resolve = state.resolveSettler!.resolve; - resolved.unmount(); - await act(async () => resolve(restored)); - expect(state.savedIdentities).toEqual([]); - }); - - it('stops remote startup work when detection or fetch settles after unmount', async () => { - state.detectDeferred = true; - const detecting = render(); - await waitFor(() => expect(state.detectResolve).not.toBeNull()); - detecting.unmount(); - await act(async () => state.detectResolve!(true)); - expect(state.startupCalls).toBe(0); - - state.detectDeferred = false; - state.detect = true; - state.startupDeferred = true; - state.remoteBoard = board([task({ id: 'late-fetch', title: 'Late fetch' })]); - const fetching = render(); - await waitFor(() => expect(state.startupResolve).not.toBeNull()); - fetching.unmount(); - await act(async () => state.startupResolve!()); - expect(state.commitCalls).toBe(0); - }); - - it('stops dirty-board preparation and persistence after unmount', async () => { - state.detect = true; - state.dirty = true; - state.prepareDeferred = true; - const preparing = render(); - await waitFor(() => expect(state.prepareResolve).not.toBeNull()); - preparing.unmount(); - await act(async () => state.prepareResolve!()); - expect(state.commitCalls).toBe(0); - - state.prepareDeferred = false; - state.commitDeferred = true; - const persisting = render(); - await waitFor(() => expect(state.commitResolve).not.toBeNull()); - persisting.unmount(); - await act(async () => state.commitResolve!()); - expect(state.remoteSaveCalls).toBe(0); - - state.dirty = false; - state.remoteBoard = board([task({ id: 'late-remote', title: 'Late remote' })]); - state.commitDeferredPersisted = false; - state.commitResolve = null; - const dirtyWrites = state.dirtyWrites.length; - const adopting = render(); - await waitFor(() => expect(state.commitResolve).not.toBeNull()); - adopting.unmount(); - await act(async () => state.commitResolve!()); - expect(state.dirtyWrites).toHaveLength(dirtyWrites); - }); - it('adds, edits, moves, checks, ships, cancels, restores, and purges cards', async () => { const user = userEvent.setup(); render(); From c27dfd82f3ef5770ac3b0563512e54b2a22722e5 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 1 Aug 2026 11:05:51 +0000 Subject: [PATCH 4/5] fix(ci): separate coverage reporting from gating --- .github/workflows/quality.yml | 2 +- package.json | 1 + scripts/ci/test_workflow_structure.py | 26 ++++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 6828831..8f00160 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -155,7 +155,7 @@ jobs: run: npm ci --ignore-scripts - name: Generate frontend coverage - run: npm test + run: npm run test:coverage:report-only - name: Generate Go coverage env: diff --git a/package.json b/package.json index a9178a0..96017bc 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "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/test_workflow_structure.py b/scripts/ci/test_workflow_structure.py index 199b7be..e0c5964 100644 --- a/scripts/ci/test_workflow_structure.py +++ b/scripts/ci/test_workflow_structure.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import json import re import unittest from pathlib import Path @@ -7,6 +8,7 @@ ROOT = Path(__file__).resolve().parents[2] QUALITY = (ROOT / ".github/workflows/quality.yml").read_text() SONAR = (ROOT / ".github/workflows/sonar-exact-revision.yml").read_text() +PACKAGE = json.loads((ROOT / "package.json").read_text()) class WorkflowStructureTest(unittest.TestCase): @@ -62,6 +64,30 @@ def test_candidate_coverage_has_no_secret_context(self): self.assertNotIn("GITHUB_TOKEN", job) self.assertIn("ref: ${{ github.event.pull_request.head.sha || github.sha }}", job) + 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_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) From c81f5993ad457eb00bfcf2f2311e3deea3f8a8bf Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 1 Aug 2026 12:13:48 +0000 Subject: [PATCH 5/5] fix(ci): harden push and fork scanning --- .github/workflows/quality.yml | 8 +++ .github/workflows/sonar-exact-revision.yml | 15 +++-- scripts/ci/create-coverage-manifest.cjs | 7 ++- scripts/ci/fetch-verified-base.sh | 33 ++++++++++ scripts/ci/test_ci_helpers.py | 70 +++++++++++++++++++++- scripts/ci/test_validate_workflow_run.cjs | 16 +++-- scripts/ci/test_workflow_structure.py | 12 ++++ scripts/ci/validate-workflow-run.cjs | 29 ++++++--- 8 files changed, 168 insertions(+), 22 deletions(-) create mode 100644 scripts/ci/fetch-verified-base.sh diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 8f00160..d5f2f61 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -135,6 +135,7 @@ jobs: - 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 @@ -164,6 +165,13 @@ jobs: 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 }} diff --git a/.github/workflows/sonar-exact-revision.yml b/.github/workflows/sonar-exact-revision.yml index 41628e7..64df3b8 100644 --- a/.github/workflows/sonar-exact-revision.yml +++ b/.github/workflows/sonar-exact-revision.yml @@ -83,19 +83,21 @@ jobs: 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 - bash ../trusted-main/scripts/ci/guard-control-plane.sh "$BASE_SHA" "$CANDIDATE_SHA" --classify >> "$GITHUB_OUTPUT" - echo "security_base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + 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" - echo 'maintenance=false' >> "$GITHUB_OUTPUT" + 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" @@ -205,6 +207,7 @@ jobs: 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: | @@ -212,6 +215,10 @@ jobs: 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)" diff --git a/scripts/ci/create-coverage-manifest.cjs b/scripts/ci/create-coverage-manifest.cjs index 745eea5..a64d443 100644 --- a/scripts/ci/create-coverage-manifest.cjs +++ b/scripts/ci/create-coverage-manifest.cjs @@ -89,7 +89,7 @@ function maintenanceState(baseSha, candidateSha) { } function securityBase(candidateSha, baseRef, pullRequest, eventBaseSha) { - if (pullRequest > 0) return 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 { @@ -128,6 +128,7 @@ function main() { 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, @@ -150,8 +151,8 @@ function main() { pull_request: pullRequest, base_sha: baseSha, base_ref: baseRef, - security_base_sha: securityBase(candidateSha, baseRef, pullRequest, baseSha), - maintenance: pullRequest > 0 ? maintenanceState(baseSha, candidateSha) : false, + security_base_sha: securityBaseSha, + maintenance: maintenanceState(securityBaseSha, candidateSha), }, reports: Object.fromEntries(REPORTS.map(([kind, path, limit]) => [kind, validateReport(kind, path, limit)])), tools: { diff --git a/scripts/ci/fetch-verified-base.sh b/scripts/ci/fetch-verified-base.sh new file mode 100644 index 0000000..bdeca7a --- /dev/null +++ b/scripts/ci/fetch-verified-base.sh @@ -0,0 +1,33 @@ +#!/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/test_ci_helpers.py b/scripts/ci/test_ci_helpers.py index 4b1b934..543ed4e 100644 --- a/scripts/ci/test_ci_helpers.py +++ b/scripts/ci/test_ci_helpers.py @@ -13,6 +13,7 @@ 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" @@ -115,6 +116,48 @@ def test_same_repo_pr_can_classify_maintenance_without_trusting_it(self): 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() @@ -221,6 +264,9 @@ def test_manifest_generation_binds_real_git_identity(self): 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") @@ -244,15 +290,37 @@ def test_manifest_generation_binds_real_git_identity(self): 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"], check=True) + 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) diff --git a/scripts/ci/test_validate_workflow_run.cjs b/scripts/ci/test_validate_workflow_run.cjs index 25120d1..5c02609 100644 --- a/scripts/ci/test_validate_workflow_run.cjs +++ b/scripts/ci/test_validate_workflow_run.cjs @@ -10,10 +10,12 @@ 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; @@ -28,7 +30,7 @@ const event = { 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: repository }, + head_repository: { id: 99, full_name: candidateRepository }, }, }; writeFileSync(eventPath, JSON.stringify(event)); @@ -40,8 +42,8 @@ const fixtures = new Map([ [`/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: repository }, - pull_requests: [{ number: 7, head: { sha: head, ref: 'feature', repo: { full_name: repository } }, base: { sha: base, ref: 'main' } }], + 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 }], @@ -51,10 +53,10 @@ const fixtures = new Map([ 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/RandomCodeSpace/kb/git/commits/${head}`, { sha: head, tree: { sha: tree }, parents: [{ sha: base }] }], + [`/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: repository } }, - base: { sha: base, ref: 'main', repo: { full_name: repository } }, + head: { sha: head, ref: 'feature', repo: { full_name: candidateRepository } }, + base: { sha: currentBase, ref: 'main', repo: { full_name: repository } }, merge_commit_sha: merge, }], ]); @@ -92,6 +94,8 @@ server.listen(0, '127.0.0.1', () => { 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; event.workflow_run.id = runId + 1; writeFileSync(eventPath, JSON.stringify(event)); diff --git a/scripts/ci/test_workflow_structure.py b/scripts/ci/test_workflow_structure.py index e0c5964..47f7e74 100644 --- a/scripts/ci/test_workflow_structure.py +++ b/scripts/ci/test_workflow_structure.py @@ -62,8 +62,20 @@ def test_candidate_coverage_has_no_secret_context(self): 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) diff --git a/scripts/ci/validate-workflow-run.cjs b/scripts/ci/validate-workflow-run.cjs index f999112..e4c0cae 100644 --- a/scripts/ci/validate-workflow-run.cjs +++ b/scripts/ci/validate-workflow-run.cjs @@ -28,6 +28,15 @@ function environmentInteger(name) { 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; +} + async function github(path) { const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com'; const response = await fetch(`${apiUrl}${path}`, { @@ -62,7 +71,7 @@ async function main() { 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'); - equal(trigger.head_repository?.full_name, repository, 'candidate 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'); @@ -83,7 +92,7 @@ async function main() { 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, repository, 'API head repository'); + 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); @@ -116,7 +125,8 @@ async function main() { 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/${encodedRepo}/git/commits/${triggerHeadSha}`); + const encodedCandidateRepo = candidateRepository.split('/').map(encodeURIComponent).join('/'); + const commit = await github(`/repos/${encodedCandidateRepo}/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'); @@ -136,16 +146,19 @@ async function main() { 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/${encodedRepo}/pulls/${pull.number}`); equal(pullDetails.head?.sha, triggerHeadSha, 'pull request API head SHA'); - equal(pullDetails.head?.repo?.full_name, repository, 'pull request API head repository'); + equal(pullDetails.head?.repo?.full_name, candidateRepository, 'pull request API head repository'); equal(pullDetails.base?.repo?.full_name, repository, 'pull request API base repository'); - baseSha = pullDetails.base?.sha; - baseRef = pullDetails.base?.ref; - candidateRef = pullDetails.head?.ref; + 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; @@ -167,7 +180,7 @@ async function main() { job_id: job.id, event: trigger.event, mode, - candidate_repository: repository, + candidate_repository: candidateRepository, candidate_sha: triggerHeadSha, candidate_tree: candidateTree, candidate_ref: candidateRef,