diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4b963e015..45ea7f8a5 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 5419097d8..5a00f839e 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,6 +56,10 @@ 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 @@ -66,6 +84,10 @@ jobs: 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 @@ -97,6 +119,25 @@ jobs: cache: npm cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + # The concurrency guard runs here because `cargo fmt` is a required check. + # Pin the yq binary by checksum, so a replaced release asset cannot run in + # CI with this job's token. + - 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" + echo "8e34fc298390875de416e6a4afcb8cabeceb25d9aa8506c1a2f9353cf702ea5f $RUNNER_TEMP/yq" | sha256sum --check --strict + chmod +x "$RUNNER_TEMP/yq" + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + + - 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 + - name: Run cargo fmt uses: actions-rust-lang/rustfmt@v1 @@ -136,6 +177,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 +212,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 +257,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 8500af350..a7231cd84 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 @@ -64,6 +77,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 @@ -119,6 +135,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 @@ -159,6 +178,9 @@ jobs: # Playwright cannot install chromium on the ubuntu-latest 26.04 image. runs-on: ubuntu-24.04 timeout-minutes: 45 + concurrency: + group: ${{ github.workflow }}-trace-runtime-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 @@ -254,6 +276,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 dced0c7b6..0282e5a06 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 60af0bb5d..f2315fd21 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 @@ -79,6 +92,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 @@ -127,6 +143,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 @@ -208,6 +228,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 @@ -242,6 +266,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 @@ -288,6 +316,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 @@ -322,6 +354,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 @@ -394,6 +430,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. @@ -428,6 +468,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/.github/workflows/worker-auction-deadlines.yml b/.github/workflows/worker-auction-deadlines.yml index dda7e26e8..3335736c4 100644 --- a/.github/workflows/worker-auction-deadlines.yml +++ b/.github/workflows/worker-auction-deadlines.yml @@ -8,10 +8,23 @@ 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: worker-deadlines: runs-on: ubuntu-latest timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-worker-deadlines-${{ matrix.adapter }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.login || github.run_id }} + queue: max strategy: fail-fast: false matrix: diff --git a/AGENTS.md b/AGENTS.md index d90eda1f2..7164165eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -377,6 +377,7 @@ Every PR must pass: 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. Fastly absolute-deadline network fixtures under Viceroy (the harness builds the adapter test binary itself): `node crates/trusted-server-adapter-fastly/tests/deadline-origin.mjs "$(command -v viceroy)"` 15. Worker auction-deadline runtime fixtures (`worker-auction-deadlines.yml`): build and lint the runtime fixture, then run it under the local runtime. For Cloudflare, `cargo build --locked -p trusted-server-adapter-cloudflare --example deadline-runtime --target wasm32-unknown-unknown --features cloudflare` and `node scripts/test-worker-auction-deadlines.mjs cloudflare --runtime "$(command -v wrangler)"` (requires wrangler and the `Cargo.lock` wasm-bindgen CLI). For Spin, `cargo build --locked -p trusted-server-adapter-spin --example deadline-runtime --target wasm32-wasip1 --features spin` and `node scripts/test-worker-auction-deadlines.mjs spin --runtime "$(command -v spin)"`. CI lints each fixture with the same `cargo clippy` arguments plus `-- -D warnings` +16. 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 21fee51f5..c86cad849 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 000000000..49c23f3aa --- /dev/null +++ b/scripts/check-ci-concurrency.sh @@ -0,0 +1,175 @@ +#!/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" +} + +# Prints each matrix key used by the segment, one per line. Fails when the +# segment is anything other than a run of `${{ matrix. }}-` parts. +matrix_segment_keys() { + 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 + printf '%s\n' "$matrix_key" + 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 + local used_keys + local matrix_key + + 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" ]] || ! used_keys=$(matrix_segment_keys "$matrix_segment"); then + report_error "$file" "Matrix job '$job' concurrency group must include each matrix leg before the per-author suffix." + return + fi + # Every matrix dimension must key the group, or legs that share one value + # queue behind each other. Include-only matrices have no dimensions here. + while IFS= read -r matrix_key; do + [[ -n "$matrix_key" ]] || continue + if ! grep -qxF -- "$matrix_key" <<<"$used_keys"; then + report_error "$file" "Matrix job '$job' concurrency group must include matrix.$matrix_key before the per-author suffix." + fi + done < <(job_value "$file" "$job" 'strategy.matrix | select(tag == "!!map") | keys | .[] | select(. != "include" and . != "exclude")') + 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 + + timeout=$(job_value "$file" "$job" '"timeout-minutes" // 0') + if [[ ! "$timeout" =~ ^[1-9][0-9]*$ && "$timeout" != "\${{ "*" }}" ]]; 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 000000000..8bdfb55d0 --- /dev/null +++ b/scripts/check-ci-concurrency.test.mjs @@ -0,0 +1,356 @@ +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/) + }) +}) + +test('requires cancellation and rejects queue max on the workflow block', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + const source = workflow('format-rust', authorGroup('format-rust')) + writeFileSync( + formatPath, + source.replace('cancel-in-progress: true', 'cancel-in-progress: false') + ) + assertRejected(root, /workflow concurrency group/) + writeFileSync( + formatPath, + source.replace( + 'cancel-in-progress: true', + 'cancel-in-progress: true\n queue: max' + ) + ) + assertRejected(root, /workflow concurrency group/) + }) +}) + +test('checks workflows with string and list pull_request triggers', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + const source = workflow('format-rust', authorGroup('format-rust'), { + timeout: false, + }) + writeFileSync( + formatPath, + source.replace('on:\n pull_request:', 'on: pull_request') + ) + assertRejected(root, /timeout-minutes/) + writeFileSync( + formatPath, + source.replace('on:\n pull_request:', 'on: [push, pull_request]') + ) + assertRejected(root, /timeout-minutes/) + }) +}) + +test('rejects an extra segment in a non-matrix job group', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + writeFileSync( + formatPath, + workflow('format-rust', authorGroup('format-rust', 'extra')) + ) + assertRejected(root, /must not add a shared queue segment/) + }) +}) + +test('rejects a matrix segment that is not a plain matrix value', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + const segment = '$' + "{{ matrix.os || 'linux' }}" + writeFileSync( + formatPath, + workflow('format-rust', authorGroup('format-rust', segment), { + matrix: true, + }) + ) + assertRejected(root, /matrix job/i) + }) +}) + +test('requires every matrix dimension in the job group', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + const osSegment = '$' + '{{ matrix.os }}' + const nodeSegment = '$' + '{{ matrix.node }}' + const addNodeDimension = (source) => + source.replace( + ' os: [ubuntu-latest]', + ' os: [ubuntu-latest]\n node: [22, 24]' + ) + writeFileSync( + formatPath, + addNodeDimension( + workflow('format-rust', authorGroup('format-rust', osSegment), { + matrix: true, + }) + ) + ) + assertRejected(root, /matrix\.node/) + + writeFileSync( + formatPath, + addNodeDimension( + workflow( + 'format-rust', + authorGroup('format-rust', osSegment + '-' + nodeSegment), + { matrix: true } + ) + ) + ) + const passing = runGuard(root) + assert.equal(passing.status, 0, passing.stdout + '\n' + passing.stderr) + }) +}) + +test('accepts expression timeouts and rejects a zero timeout', () => { + withFixture(({ root, workflowsPath }) => { + const formatPath = path.join(workflowsPath, 'format.yml') + const source = workflow('format-rust', authorGroup('format-rust')) + writeFileSync( + formatPath, + source.replace( + 'timeout-minutes: 5', + 'timeout-minutes: ' + + '$' + + "{{ matrix.os == 'windows-latest' && 65 || 30 }}" + ) + ) + const passing = runGuard(root) + assert.equal(passing.status, 0, passing.stdout + '\n' + passing.stderr) + + writeFileSync( + formatPath, + source.replace('timeout-minutes: 5', 'timeout-minutes: 0') + ) + assertRejected(root, /timeout-minutes/) + }) +})