Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions .github/actions/protected-sonar/action.yml
Original file line number Diff line number Diff line change
@@ -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"
70 changes: 70 additions & 0 deletions .github/actions/protected-sonar/verify-sonar-task.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env node

const { readFileSync } = require('node:fs');

const SONAR_ORIGIN = 'https://sonarcloud.io';

function required(name) {
const value = process.env[name];
if (!value) throw new Error(`missing required environment variable ${name}`);
return value;
}

function properties(path) {
const result = new Map();
for (const line of readFileSync(path, 'utf8').split('\n')) {
if (!line || line.startsWith('#')) continue;
const split = line.indexOf('=');
if (split < 1) throw new Error(`malformed report-task line: ${line}`);
result.set(line.slice(0, split), line.slice(split + 1));
}
return result;
}

async function sonar(url) {
const token = required('SONAR_TOKEN');
const response = await fetch(url, {
headers: { Authorization: `Basic ${Buffer.from(`${token}:`).toString('base64')}` },
});
if (!response.ok) throw new Error(`Sonar API returned ${response.status} for ${new URL(url).pathname}`);
return response.json();
}

async function main() {
const candidateSha = required('CANDIDATE_SHA');
if (!/^[0-9a-f]{40}$/.test(candidateSha)) throw new Error('candidate SHA is invalid');
const report = properties(`${required('PROJECT_BASE_DIR')}/.scannerwork/report-task.txt`);
const serverUrl = new URL(report.get('serverUrl'));
if (serverUrl.origin !== SONAR_ORIGIN || serverUrl.username || serverUrl.password) {
throw new Error('unexpected Sonar server URL');
}
const ceTaskId = report.get('ceTaskId');
if (!/^[A-Za-z0-9_-]+$/.test(ceTaskId || '')) throw new Error('invalid compute-engine task id');
const task = await sonar(new URL(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`, SONAR_ORIGIN));
if (task.task?.status !== 'SUCCESS' || !task.task.analysisId) {
throw new Error(`compute-engine task is not successful: ${task.task?.status || 'missing'}`);
}
if (task.task.componentKey !== required('SONAR_PROJECT_KEY')) {
throw new Error(`compute-engine component ${task.task.componentKey} != ${required('SONAR_PROJECT_KEY')}`);
}

const query = new URL('/api/project_analyses/search', SONAR_ORIGIN);
query.searchParams.set('project', required('SONAR_PROJECT_KEY'));
query.searchParams.set('pageSize', '100');
if (required('ANALYSIS_MODE') === 'pull_request') query.searchParams.set('pullRequest', required('PULL_REQUEST'));
if (required('ANALYSIS_MODE') === 'branch') query.searchParams.set('branch', required('CANDIDATE_REF'));
const analyses = await sonar(query);
const analysis = analyses.analyses?.find((item) => item.key === task.task.analysisId);
if (!analysis) throw new Error(`analysis ${task.task.analysisId} not returned by project analysis API`);
if (analysis.revision !== candidateSha) throw new Error(`analysis revision ${analysis.revision} != ${candidateSha}`);
console.log(`verified Sonar task ${ceTaskId}, analysis ${analysis.key}, revision ${analysis.revision}`);
}

if (require.main === module) {
main().catch((error) => {
console.error(`verify-sonar-task: ${error.message}`);
process.exitCode = 1;
});
}

module.exports = { main };
185 changes: 144 additions & 41 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -58,36 +86,111 @@ 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:
args: >
-Dsonar.organization=${{ vars.SONAR_ORGANIZATION }}
-Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false

- name: 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: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.15.0
cache: npm

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
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 run test:coverage:report-only

- name: Generate Go coverage
env:
GO_PACKAGE_COVERAGE_THRESHOLD: '95.0'
GO_TOTAL_COVERAGE_THRESHOLD: '96.4'
GO_COVERAGE_PROFILE: coverage/go.out
run: npm run coverage:go

- name: Fetch verified pull request base
if: github.event_name == 'pull_request'
env:
BASE_REPOSITORY: ${{ github.repository }}
BASE_SHA: ${{ needs.regression.outputs.diff_base }}
run: sh scripts/ci/fetch-verified-base.sh "$BASE_REPOSITORY" "$BASE_SHA"

- name: Create candidate-bound coverage manifest
env:
CANDIDATE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
CANDIDATE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
CANDIDATE_REF: ${{ github.event.pull_request.head.ref || github.ref_name }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || 0 }}
BASE_SHA: ${{ needs.regression.outputs.diff_base }}
BASE_REF: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}
run: node scripts/ci/create-coverage-manifest.cjs

- name: Upload exact candidate coverage bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: candidate-head-coverage-${{ github.event.pull_request.head.sha || github.sha }}
path: |
coverage/lcov.info
coverage/go.out
coverage/manifest.json
if-no-files-found: error
compression-level: 9
retention-days: 7
include-hidden-files: false
Loading