diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4b963e0153..45ea7f8a5e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,6 +19,16 @@ on: schedule: - cron: "42 6 * * 0" +# New pushes cancel older runs for the same pull request. Each job queues by +# workflow, job, and author, so one author runs one copy of each job at a time. +# GitHub orders jobs by when they start waiting, but does not guarantee FIFO. +# Each queue holds up to 100 pending jobs; later arrivals are canceled. +# Timeouts release queues after hung jobs. Bots and non-PR events use unique +# run IDs and do not queue together. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: analyze: name: Analyze (${{ matrix.language }}) @@ -28,6 +38,10 @@ jobs: # - https://gh.io/using-larger-runners (GitHub.com only) # Consider using larger runners or machines with greater resources for possible analysis time improvements. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: 45 + concurrency: + group: ${{ github.workflow }}-analyze-${{ matrix.language }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max permissions: # required for all workflows security-events: write diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 5419097d86..2398eb8a91 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -8,10 +8,24 @@ on: permissions: contents: read +# New pushes cancel older runs for the same pull request. Each job queues by +# workflow, job, and author, so one author runs one copy of each job at a time. +# GitHub orders jobs by when they start waiting, but does not guarantee FIFO. +# Each queue holds up to 100 pending jobs; later arrivals are canceled. +# Timeouts release queues after hung jobs. Bots and non-PR events use unique +# run IDs and do not queue together. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: check-claude-md-symlink: name: CLAUDE.md symlink guard runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-check-claude-md-symlink-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -42,9 +56,22 @@ jobs: check-ci-node-pin: name: CI Node pin guard runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-check-ci-node-pin-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 + - name: Install yq + shell: bash + run: | + curl --fail --location --silent --show-error \ + https://github.com/mikefarah/yq/releases/download/v4.54.1/yq_linux_amd64 \ + --output "$RUNNER_TEMP/yq" + chmod +x "$RUNNER_TEMP/yq" + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + # The guard's tests check the tree before the guard landed; fetch only # that commit instead of the full history. - name: Fetch the pre-guard commit @@ -55,7 +82,7 @@ jobs: with: node-version-file: .tool-versions - - name: Test the guard + - name: Test the Node pin guard run: node --test scripts/check-ci-node-pin.test.mjs # Every job that compiles trusted-server-core also runs the TSJS build, so @@ -63,9 +90,19 @@ jobs: - name: Verify jobs pin Node before compiling Rust run: node scripts/check-ci-node-pin.mjs + - name: Test the CI concurrency policy guard + run: node --test scripts/check-ci-concurrency.test.mjs + + - name: Require per-PR cancellation, per-author job queues and timeouts + run: scripts/check-ci-concurrency.sh + format-rust: name: cargo fmt runs-on: ubuntu-latest + timeout-minutes: 25 + concurrency: + group: ${{ github.workflow }}-format-rust-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -136,6 +173,10 @@ jobs: format-typescript: runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-format-typescript-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max defaults: run: working-directory: crates/trusted-server-js/lib @@ -167,6 +208,10 @@ jobs: format-docs: runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-format-docs-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max defaults: run: working-directory: docs @@ -208,6 +253,10 @@ jobs: docs-proposal-scripts: name: docs-proposal scripts runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-docs-proposal-scripts-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ca9d35ad41..b1d1262750 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -10,6 +10,16 @@ on: types: [opened, synchronize, reopened] workflow_dispatch: +# New pushes cancel older runs for the same pull request. Each job queues by +# workflow, job, and author, so one author runs one copy of each job at a time. +# GitHub orders jobs by when they start waiting, but does not guarantee FIFO. +# Each queue holds up to 100 pending jobs; later arrivals are canceled. +# Timeouts release queues after hung jobs. Bots and non-PR events use unique +# run IDs and do not queue together. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + env: ORIGIN_PORT: 8888 ARTIFACTS_DIR: /tmp/integration-test-artifacts @@ -23,6 +33,9 @@ jobs: name: prepare integration artifacts runs-on: ubuntu-latest timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-prepare-artifacts-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -60,6 +73,9 @@ jobs: needs: prepare-artifacts runs-on: ubuntu-latest timeout-minutes: 20 + concurrency: + group: ${{ github.workflow }}-integration-tests-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -114,6 +130,9 @@ jobs: needs: prepare-artifacts runs-on: ubuntu-latest timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-integration-tests-fastly-ec-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -158,6 +177,9 @@ jobs: # once the pinned Playwright version supports the newer image. runs-on: ubuntu-24.04 timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-browser-tests-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pbs-example.yml b/.github/workflows/pbs-example.yml index dced0c7b63..0282e5a066 100644 --- a/.github/workflows/pbs-example.yml +++ b/.github/workflows/pbs-example.yml @@ -17,11 +17,24 @@ on: permissions: contents: read +# New pushes cancel older runs for the same pull request. Each job queues by +# workflow, job, and author, so one author runs one copy of each job at a time. +# GitHub orders jobs by when they start waiting, but does not guarantee FIFO. +# Each queue holds up to 100 pending jobs; later arrivals are canceled. +# Timeouts release queues after hung jobs. Bots and non-PR events use unique +# run IDs and do not queue together. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: local-checks: name: PBS Terraform and Compose checks runs-on: ubuntu-latest timeout-minutes: 20 + concurrency: + group: ${{ github.workflow }}-local-checks-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max env: TF_IN_AUTOMATION: 'true' AWS_EC2_METADATA_DISABLED: 'true' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a956a9fc6..0642b6a959 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,11 +8,24 @@ on: branches: [main] pull_request: +# New pushes cancel older runs for the same pull request. Each job queues by +# workflow, job, and author, so one author runs one copy of each job at a time. +# GitHub orders jobs by when they start waiting, but does not guarantee FIFO. +# Each queue holds up to 100 pending jobs; later arrivals are canceled. +# Timeouts release queues after hung jobs. Bots and non-PR events use unique +# run IDs and do not queue together. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: test-rust: name: cargo test runs-on: ubuntu-latest timeout-minutes: 45 + concurrency: + group: ${{ github.workflow }}-test-rust-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -71,6 +84,9 @@ jobs: name: HTML EOF runtime (Fastly) runs-on: ubuntu-latest timeout-minutes: 25 + concurrency: + group: ${{ github.workflow }}-test-fastly-eof-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -119,6 +135,10 @@ jobs: test-axum: name: cargo test (axum native) runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-test-axum-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -192,6 +212,10 @@ jobs: test-cloudflare: name: cargo check (cloudflare native + wasm32-unknown-unknown) runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-test-cloudflare-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -226,6 +250,10 @@ jobs: test-spin: name: cargo check/build/test (spin native + wasm32-wasip1) runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: ${{ github.workflow }}-test-spin-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -272,6 +300,10 @@ jobs: test-parity: name: cargo test (cross-adapter parity) runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-test-parity-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -306,6 +338,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 45 + concurrency: + group: ${{ github.workflow }}-test-cli-${{ matrix.os }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: - uses: actions/checkout@v4 @@ -378,6 +414,10 @@ jobs: name: ts dev lint domains (changed lines) if: github.event_name == 'pull_request' runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-lint-domains-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max steps: # A PR checkout is GitHub's merge commit; depth 2 also fetches its # parents, so `HEAD^1` is the exact base commit the PR merges into. @@ -412,6 +452,10 @@ jobs: test-typescript: name: vitest runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-test-typescript-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max defaults: run: working-directory: crates/trusted-server-js/lib diff --git a/AGENTS.md b/AGENTS.md index 7688c1829f..28f815c938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -375,6 +375,7 @@ Every PR must pass: 11. Documentation proposal scripts: `shellcheck scripts/docs-proposal/*.sh && node --test scripts/docs-proposal/hunks.test.mjs && scripts/docs-proposal/test.sh` 12. URL-host linter on lines the PR adds (`lint-domains` job, PRs only); run it locally with `cargo run --manifest-path crates/trusted-server-cli/Cargo.toml --target --bin ts -- dev lint domains --changed-vs origin/main`, using the PR's base branch in place of `main` when it targets another branch 13. CI Node pin guard: `node --test scripts/check-ci-node-pin.test.mjs && node scripts/check-ci-node-pin.mjs` with the `.tool-versions` Node (every CI job that compiles Rust sets up the `.tool-versions` Node before its first cargo step) +14. CI concurrency policy guard: `node --test scripts/check-ci-concurrency.test.mjs && scripts/check-ci-concurrency.sh` (requires yq v4); every PR-triggered workflow must use `test.yml`'s per-PR cancellation block, and every non-reusable job must set a timeout and a per-author `queue: max` group. Matrix jobs must also include their matrix values in the group. CI also runs these HTML EOF checks. Run them locally only when changing EOF finalization, publisher or HTML-processor code: diff --git a/scripts/README.md b/scripts/README.md index 21fee51f52..c86cad849a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,6 +8,7 @@ their documented orchestration handles a probe deliberately. | `batch-sync.sh` | Endpoint, bearer token, EC ID, partner UID; `curl` and Python | Sends one authenticated batch-sync request. Its temporary response file is removed on exit. | | `benchmark.sh` | A running server; `curl`, `bc`, and optionally `hey` | May install `hey` through Homebrew. `--save` writes under `benchmark-results/`; it does not stop the server. | | `check-ci-node-pin.mjs` | Pinned Node; tests in `check-ci-node-pin.test.mjs` | Read-only. Fails when a CI job compiles Rust before an unconditional `actions/setup-node` step pinned to `.tool-versions`; `--root` checks another checkout. | +| `check-ci-concurrency.sh` | yq v4; tests in `check-ci-concurrency.test.mjs` | Read-only. Checks per-PR workflow cancellation, per-job author queues, matrix groups, and timeouts; `--root` checks another checkout and `--yq` selects yq. | | `profile.sh` | Fastly CLI, Rust WASM target, `curl`; endpoint and request options | Builds and starts the Fastly app, stops the owned process, and retains a profile under `benchmark-results/profiles/`. `--open` launches the local viewer. | | `generate-integration-viceroy-configs.sh` | Rust toolchain; optional origin port and artifact directory | Builds the native generator and writes Viceroy config under `target/integration-test-artifacts/`; generated files persist. | | `integration-tests.sh` | Docker, Viceroy, Rust WASM target, pinned Node | Builds WASM/native artifacts and two Docker images, generates Viceroy config, and runs native integration tests serially. Build products and images persist. | diff --git a/scripts/check-ci-concurrency.sh b/scripts/check-ci-concurrency.sh new file mode 100755 index 0000000000..36d3a06b89 --- /dev/null +++ b/scripts/check-ci-concurrency.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd "$script_dir/.." && pwd) +root=$repo_root +yq_bin=${YQ_BIN:-yq} + +usage() { + printf 'usage: check-ci-concurrency.sh [--root ROOT] [--yq PATH]\n' >&2 +} + +while (($#)); do + case "$1" in + --root) + [[ $# -ge 2 ]] || { usage; exit 2; } + root=$2 + shift 2 + ;; + --yq) + [[ $# -ge 2 ]] || { usage; exit 2; } + yq_bin=$2 + shift 2 + ;; + *) + usage + exit 2 + ;; + esac +done + +workflow_dir=$root/.github/workflows +reference=$workflow_dir/test.yml +if [[ ! -d "$workflow_dir" || ! -f "$reference" ]]; then + printf 'error: expected workflow directory and test.yml under %s\n' "$root" >&2 + exit 1 +fi +if ! command -v "$yq_bin" >/dev/null 2>&1; then + printf 'error: yq v4 is required to check workflow policies\n' >&2 + exit 1 +fi + +workflow_prefix='${{ github.workflow }}' +expected_workflow_group="${workflow_prefix}-\${{ github.event.pull_request.number || github.run_id }}" +expected_author_suffix="\${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }}" +matrix_prefix="\${{ matrix." +matrix_suffix=' }}-' +matrix_key_pattern='^[[:alnum:]_.-]+$' + +status=0 + +report_error() { + printf '::error file=%s::%s\n' "$1" "$2" + status=1 +} + +has_pull_request_trigger() { + "$yq_bin" -r ' + .on + | ((tag == "!!map" and has("pull_request")) + or (tag == "!!str" and . == "pull_request") + or (tag == "!!seq" and ([.[] | select(. == "pull_request")] | length > 0))) + ' "$1" +} + +job_value() { + local file=$1 + local job=$2 + local expression=$3 + YQ_JOB=$job "$yq_bin" -r ".jobs[strenv(YQ_JOB)].${expression}" "$file" +} + +validate_matrix_segment() { + local remaining=$1 + local matrix_key + + while [[ "$remaining" == "$matrix_prefix"* ]]; do + remaining=${remaining#"$matrix_prefix"} + [[ "$remaining" == *"$matrix_suffix"* ]] || return 1 + matrix_key=${remaining%%"$matrix_suffix"*} + [[ "$matrix_key" =~ $matrix_key_pattern ]] || return 1 + remaining=${remaining#*"$matrix_suffix"} + done + + [[ -z "$remaining" ]] +} + +validate_job_group() { + local file=$1 + local job=$2 + local group=$3 + local has_matrix=$4 + local prefix="${workflow_prefix}-${job}-" + local remainder + local matrix_segment + + if [[ "$group" != "$prefix"* ]]; then + report_error "$file" "Job '$job' concurrency group must start with the workflow name and its own job ID." + return + fi + remainder=${group#"$prefix"} + if [[ "$remainder" != *"$expected_author_suffix" ]]; then + report_error "$file" "Job '$job' concurrency group must use the pull-request author for human PRs and the run ID otherwise." + return + fi + matrix_segment=${remainder%"$expected_author_suffix"} + + if [[ "$has_matrix" == true ]]; then + if [[ -z "$matrix_segment" ]] || ! validate_matrix_segment "$matrix_segment"; then + report_error "$file" "Matrix job '$job' concurrency group must include each matrix leg before the per-author suffix." + fi + elif [[ -n "$matrix_segment" ]]; then + report_error "$file" "Non-matrix job '$job' concurrency group must not add a shared queue segment." + fi +} + +check_workflow() { + local file=$1 + local is_pr + local group + local cancel + local queue + local job + local timeout + local job_group + local job_queue + local has_matrix + + is_pr=$(has_pull_request_trigger "$file") + [[ "$is_pr" == true ]] || return 0 + + group=$("$yq_bin" -r '.concurrency.group // ""' "$file") + cancel=$("$yq_bin" -r '.concurrency."cancel-in-progress" // false' "$file") + queue=$("$yq_bin" -r '.concurrency.queue // ""' "$file") + if [[ "$group" != "$expected_workflow_group" || "$cancel" != true || "$queue" == max ]]; then + report_error "$file" "PR workflow concurrency group must be per-PR cancellation from test.yml, without queue: max." + fi + + while IFS= read -r job; do + [[ -n "$job" ]] || continue + if [[ "$(job_value "$file" "$job" "uses")" != null ]]; then + continue + fi + + timeout=$(job_value "$file" "$job" '"timeout-minutes" // 0') + if [[ ! "$timeout" =~ ^[1-9][0-9]*$ ]]; then + report_error "$file" "Job '$job' must set a positive timeout-minutes value." + fi + + job_group=$(job_value "$file" "$job" "concurrency.group") + job_queue=$(job_value "$file" "$job" "concurrency.queue") + if [[ "$job_queue" != max ]]; then + report_error "$file" "Job '$job' must set queue: max on its per-author concurrency group." + fi + has_matrix=$(job_value "$file" "$job" "strategy.matrix != null") + validate_job_group "$file" "$job" "$job_group" "$has_matrix" + done < <("$yq_bin" -r '.jobs | to_entries[] | select(.value.uses == null) | .key' "$file") +} + +while IFS= read -r -d '' workflow_file; do + check_workflow "$workflow_file" +done < <(find "$workflow_dir" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) + +exit "$status" diff --git a/scripts/check-ci-concurrency.test.mjs b/scripts/check-ci-concurrency.test.mjs new file mode 100644 index 0000000000..e69d0c8b25 --- /dev/null +++ b/scripts/check-ci-concurrency.test.mjs @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' +) +const guardPath = path.join(repositoryRoot, 'scripts/check-ci-concurrency.sh') +const workflowPrefix = '$' + '{{ github.workflow }}' +const workflowGroup = + workflowPrefix + + '-' + + '$' + + '{{ github.event.pull_request.number || github.run_id }}' +const authorGroupSuffix = + '$' + + "{{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }}" + +function authorGroup(job, matrix = '') { + return [workflowPrefix, job, matrix, authorGroupSuffix] + .filter(Boolean) + .join('-') +} + +function workflow(job, group, options = {}) { + const lines = [ + 'name: ' + job, + 'on:', + ' pull_request:', + 'concurrency:', + ' group: ' + workflowGroup, + ' cancel-in-progress: true', + 'jobs:', + ' ' + job + ':', + ' runs-on: ubuntu-latest', + ] + if (options.timeout !== false) lines.push(' timeout-minutes: 5') + if (options.matrix) { + lines.push(' strategy:', ' matrix:', ' os: [ubuntu-latest]') + } + lines.push(' concurrency:', ' group: ' + group) + if (options.queue !== false) lines.push(' queue: max') + lines.push(' steps:', ' - run: echo test') + return lines.join('\n') + '\n' +} + +function fixtureRoot() { + const root = mkdtempSync(path.join(os.tmpdir(), 'check-ci-concurrency-')) + const workflowsPath = path.join(root, '.github/workflows') + mkdirSync(workflowsPath, { recursive: true }) + writeFileSync( + path.join(workflowsPath, 'test.yml'), + workflow('test-rust', authorGroup('test-rust')) + ) + writeFileSync( + path.join(workflowsPath, 'format.yml'), + workflow('format-rust', authorGroup('format-rust')) + ) + writeFileSync( + path.join(workflowsPath, 'push-only.yml'), + [ + 'name: Push only', + 'on:', + ' push:', + 'jobs:', + ' build:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: echo test', + '', + ].join('\n') + ) + writeFileSync( + path.join(workflowsPath, 'reusable.yml'), + [ + 'name: Reusable caller', + 'on:', + ' pull_request:', + 'concurrency:', + ' group: ' + workflowGroup, + ' cancel-in-progress: true', + 'jobs:', + ' call:', + ' uses: example/reusable/.github/workflows/test.yml@main', + '', + ].join('\n') + ) + return { root, workflowsPath } +} + +function runGuard(root) { + return spawnSync('bash', [guardPath, '--root', root], { encoding: 'utf8' }) +} + +function withFixture(callback) { + const fixture = fixtureRoot() + try { + callback(fixture) + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + } +} + +function assertRejected(root, expectedMessage) { + const result = runGuard(root) + assert.equal( + result.error, + undefined, + 'should start the guard: ' + result.error + ) + assert.notEqual( + result.status, + 0, + 'should reject the policy:\n' + result.stdout + '\n' + result.stderr + ) + assert.match(result.stdout + result.stderr, expectedMessage) +} + +test('accepts per-PR cancellation and distinct per-author job queues', () => { + withFixture(({ root }) => { + const result = runGuard(root) + assert.equal( + result.error, + undefined, + 'should start the guard: ' + result.error + ) + assert.equal(result.status, 0, result.stdout + '\n' + result.stderr) + }) +}) + +test('rejects author groups that use the actor or queue bot PRs', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + const source = workflow('format-rust', authorGroup('format-rust')) + writeFileSync( + formatPath, + source.replace('pull_request.user.login', 'github.actor') + ) + assertRejected(root, /concurrency group/i) + writeFileSync(formatPath, source.replace("user.type != 'Bot' && ", '')) + assertRejected(root, /concurrency group/i) + }) +}) + +test('rejects a per-author group without the non-PR run-ID fallback', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + const group = authorGroup('format-rust').replace(' || github.run_id', '') + writeFileSync(formatPath, workflow('format-rust', group)) + assertRejected(root, /concurrency group/i) + }) +}) + +test('rejects copied job groups and groups without the workflow prefix', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + writeFileSync(formatPath, workflow('format-rust', authorGroup('test-rust'))) + assertRejected(root, /concurrency group/i) + writeFileSync( + formatPath, + workflow( + 'format-rust', + authorGroup('format-rust').replace(workflowPrefix + '-', '') + ) + ) + assertRejected(root, /concurrency group/i) + }) +}) + +test('requires matrix jobs to separate their legs in the author queue', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + writeFileSync( + formatPath, + workflow( + 'format-rust', + authorGroup('format-rust', '$' + '{{ matrix.os }}'), + { + matrix: true, + } + ) + ) + const passing = runGuard(root) + assert.equal( + passing.error, + undefined, + 'should start the guard: ' + passing.error + ) + assert.equal(passing.status, 0, passing.stdout + '\n' + passing.stderr) + + writeFileSync( + formatPath, + workflow('format-rust', authorGroup('format-rust'), { matrix: true }) + ) + assertRejected(root, /matrix job/i) + }) +}) + +test('requires every non-reusable PR job to set a timeout and queue max', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + writeFileSync( + formatPath, + workflow('format-rust', authorGroup('format-rust'), { timeout: false }) + ) + assertRejected(root, /timeout-minutes/) + writeFileSync( + formatPath, + workflow('format-rust', authorGroup('format-rust'), { queue: false }) + ) + assertRejected(root, /queue: max/) + }) +}) + +test('requires the exact per-PR cancellation group on every PR workflow', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + const wrongGroup = workflow( + 'format-rust', + authorGroup('format-rust') + ).replace( + 'github.event.pull_request.number || github.run_id', + 'github.actor' + ) + writeFileSync(formatPath, wrongGroup) + assertRejected(root, /workflow concurrency group/) + }) +})