diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 76a2e27..6f861c7 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -14,8 +14,7 @@ name: Policy repository self-audit on: pull_request: - push: - branches: [main] + merge_group: schedule: - cron: "23 3 * * *" workflow_dispatch: @@ -23,9 +22,14 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'merge_group' }} + jobs: audit: - runs-on: [self-hosted] + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Checker tests diff --git a/.github/workflows/ci-auditor.yml b/.github/workflows/ci-auditor.yml new file mode 100644 index 0000000..fcde11f --- /dev/null +++ b/.github/workflows/ci-auditor.yml @@ -0,0 +1,105 @@ +# This repository's own checks for the CI guardrails (WI-3087), and the +# ci-auditor image on tags. +# +# Public repository: the light jobs run on GitHub-hosted ubuntu-24.04 +# (operator, 2026-11-02). The image push uses only GITHUB_TOKEN +# (packages: write) and runs on tags only (rule CI-06). + +name: CI guardrails + +on: + pull_request: + merge_group: + push: + tags: ["v*"] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'merge_group' }} + +permissions: + contents: read + +jobs: + auditor: + name: auditor + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: auditor + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + enable-cache: true + - run: uv sync --frozen + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy + - run: uv run pytest -q + - name: Standard and exemptions are valid + run: uv run ci-auditor validate --fail-expired + + policy: + name: policy + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - name: actionlint + run: | + set -euo pipefail + v=1.7.7; sum=023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757 + curl -fsSL -o "$RUNNER_TEMP/al.tgz" "https://github.com/rhysd/actionlint/releases/download/v$v/actionlint_${v}_linux_amd64.tar.gz" + echo "$sum $RUNNER_TEMP/al.tgz" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/al.tgz" -C "$RUNNER_TEMP" actionlint + "$RUNNER_TEMP/actionlint" -config-file ci-standard/actionlint.yaml + - name: Lint this repository against the standard (dogfood, report only) + run: uv run --project auditor ci-auditor lint . --repo github-policy --no-private --format github + + image: + # Built in the merge queue too (push off), so a tag is never the image's + # first build (rule CI-21); pushed on tags only (rule CI-06). + name: image + if: github.event_name != 'pull_request' + needs: [auditor, policy] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + if: startsWith(github.ref, 'refs/tags/v') + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: auditor/Dockerfile + push: ${{ startsWith(github.ref, 'refs/tags/v') }} + tags: ghcr.io/thedancingdeveloper-org/ci-auditor:${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'ci' }} + provenance: false + + ci: + name: ci + if: always() + needs: [auditor, policy, image] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Gate + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + if echo "$RESULTS" | grep -qE '"(failure|cancelled)"'; then exit 1; fi diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..43dbc85 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,119 @@ +# Reusable container image build (WI-3087), BuildKit with a registry cache. +# +# image: +# uses: TheDancingDeveloper-org/github-policy/.github/workflows/image.yml@v1.0.0 +# with: {image: ghcr.io/thedancingdeveloper-org/egressy, push: "${{ github.event_name == 'push' }}"} +# permissions: {contents: read, packages: write} +# +# - Layers come from `-cache:-main`, written only on the +# default branch, so merge-queue and PR builds reuse it and never rebuild +# from scratch (CI review §3.5). Those builds never write the cache. +# - `push: false` (PR / merge_group) builds and loads only. `push: true` is +# for push to main, tags or dispatch, on a `publish` runner (rule CI-06). +# - Private dependencies go in as BuildKit secrets (`secret-env`), never +# build args (rule CI-23). +# - `remote-buildkit` points buildx at the shared in-cluster buildkitd when +# it exists (phase 2); empty uses the runner's docker driver. + +name: Image (reusable) + +on: + workflow_call: + inputs: + image: + description: Image name without tag, e.g. ghcr.io/thedancingdeveloper-org/egressy. + type: string + required: true + context: + type: string + default: . + file: + type: string + default: Dockerfile + target: + description: Dockerfile target; also the cache key. + type: string + default: "" + push: + description: Push by digest (main, tags, dispatch only). + type: boolean + default: false + tags: + description: Newline-separated tags to apply when pushing. + type: string + default: "" + runs-on: + description: JSON runs-on. docker for builds, publish when push is true. + type: string + default: '["self-hosted", "docker"]' + remote-buildkit: + description: tcp:// address of a shared buildkitd (optional). + type: string + default: "" + timeout-minutes: + type: number + default: 45 + secrets: + build-secrets: + description: Newline-separated id=value pairs passed as BuildKit secrets. + required: false + outputs: + digest: + value: ${{ jobs.image.outputs.digest }} + +permissions: + contents: read + +jobs: + image: + name: image + runs-on: ${{ fromJSON(inputs.runs-on) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + outputs: + digest: ${{ steps.build.outputs.digest }} + steps: + - uses: actions/checkout@v5 + + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + with: + driver: ${{ inputs.remote-buildkit != '' && 'remote' || 'docker-container' }} + endpoint: ${{ inputs.remote-buildkit }} + + - name: Log in to GHCR + if: ${{ startsWith(inputs.image, 'ghcr.io/') }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Cache refs + id: cache + shell: bash + env: + IMAGE: ${{ inputs.image }} + TARGET: ${{ inputs.target }} + IS_MAIN: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + run: | + ref="${IMAGE}-cache:${TARGET:-default}-main" + echo "from=type=registry,ref=${ref}" >> "$GITHUB_OUTPUT" + if [ "$IS_MAIN" = "true" ]; then + echo "to=type=registry,ref=${ref},mode=max" >> "$GITHUB_OUTPUT" + else + echo "to=" >> "$GITHUB_OUTPUT" + fi + + - name: Build + id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ${{ inputs.context }} + file: ${{ inputs.file }} + target: ${{ inputs.target }} + push: ${{ inputs.push }} + load: ${{ !inputs.push }} + tags: ${{ inputs.push && inputs.tags || format('{0}:ci-{1}', inputs.image, github.run_id) }} + cache-from: ${{ steps.cache.outputs.from }} + cache-to: ${{ steps.cache.outputs.to }} + secrets: ${{ secrets.build-secrets }} + provenance: false diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml new file mode 100644 index 0000000..ca0bb4a --- /dev/null +++ b/.github/workflows/policy.yml @@ -0,0 +1,148 @@ +# Reusable CI guardrails policy gate (WI-3087). Replaces the three +# migration-policy.sh copies, the runner-policy stub and vogt's inlined +# checks with one versioned workflow. +# +# Call it from the repository's CI workflow and let the `ci` gate need it +# (templates/policy-stub.yml), pinned to a release tag of this repository: +# +# policy: +# uses: TheDancingDeveloper-org/github-policy/.github/workflows/policy.yml@v1.0.0 +# with: {ref: v1.0.0} +# +# It runs, in the caller's context: +# 1. actionlint (pinned, checksum-verified) with ci-standard/actionlint.yaml; +# 2. `ci-auditor lint`: the static rules of ci-standard/rules.yaml (runner +# classes, timeouts, concurrency, no job-level continue-on-error, trigger +# matrix, pinned actions, cache, private deps, CodeQL), minus the live +# exemptions read from this repository's main; +# 3. the identity check: commit author and committer, tracked paths and +# content. Never commit message bodies (rules CI-16). +# +# Report-only by default: findings are warnings and the job passes. Set +# `enforce: true` once the repository is clean (README "Rollout") and +# findings of `enforce: block` rules fail the job. +# +# `ref` must repeat the tag in `uses:` (a reusable workflow cannot learn its +# own ref; rule CI-15 checks the two match, so a Dependabot bump of `uses:` +# alone shows as a finding). The rules come from that ref; only +# exemptions.yaml comes from main, so a merged exemption applies at once. + +name: Policy (reusable) + +on: + workflow_call: + inputs: + ref: + description: The github-policy tag or SHA in this call's `uses:` (repeat it). + type: string + required: true + enforce: + description: Fail on findings of `enforce block` rules (default report only). + type: boolean + default: false + runs-on: + description: JSON runs-on for the policy job. A runner class from rules.yaml. + type: string + default: '"ci-small"' + identity: + description: Run the identity check (author, committer, paths, content). + type: boolean + default: true + identity-content: + description: Also scan tracked file content for forbidden identifiers. + type: boolean + default: true + +permissions: + contents: read + +jobs: + policy: + name: policy + runs-on: ${{ fromJSON(inputs.runs-on) }} + timeout-minutes: 10 + env: + ACTIONLINT_VERSION: 1.7.7 + ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757 + steps: + - name: Check out the calling repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Check out the policy at the pinned ref + uses: actions/checkout@v5 + with: + repository: TheDancingDeveloper-org/github-policy + ref: ${{ inputs.ref }} + path: .ci-policy + + - name: Check out live exemptions (main) + uses: actions/checkout@v5 + with: + repository: TheDancingDeveloper-org/github-policy + ref: main + path: .ci-policy-live + sparse-checkout: ci-standard/exemptions.yaml + sparse-checkout-cone-mode: false + + - name: Install ci-auditor and actionlint + shell: bash + run: | + set -euo pipefail + python3 -m venv "$RUNNER_TEMP/cip" + "$RUNNER_TEMP/cip/bin/pip" install --quiet --require-hashes --no-deps -r .ci-policy/auditor/requirements.lock + # ci-auditor runs from source (no build backend to fetch), in isolated + # mode so nothing in the caller's checkout can shadow an import. + src="$GITHUB_WORKSPACE/.ci-policy/auditor/src" + cat > "$RUNNER_TEMP/cip/bin/ci-auditor" <> "$GITHUB_PATH" + + - name: Workflow rules (ci-auditor lint, includes actionlint) + shell: bash + env: + REPO: ${{ github.event.repository.name }} + PRIVATE: ${{ github.event.repository.private }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + ENFORCE: ${{ inputs.enforce }} + run: | + set -euo pipefail + args=(lint . --repo "$REPO" --default-branch "${DEFAULT_BRANCH:-main}" + --standard .ci-policy/ci-standard + --exemptions .ci-policy-live/ci-standard/exemptions.yaml + --actionlint "$RUNNER_TEMP/actionlint" --format github) + if [ "$PRIVATE" = "false" ]; then args+=(--no-private); fi + if [ "$ENFORCE" = "true" ]; then args+=(--enforce); fi + ci-auditor "${args[@]}" + + - name: Identity (author, committer, paths, content - never bodies) + if: inputs.identity + shell: bash + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + MG_BASE: ${{ github.event.merge_group.base_sha }} + PUSH_BEFORE: ${{ github.event.before }} + ENFORCE: ${{ inputs.enforce }} + CONTENT: ${{ inputs.identity-content }} + run: | + set -euo pipefail + case "$EVENT" in + pull_request|pull_request_target) base="$PR_BASE" ;; + merge_group) base="$MG_BASE" ;; + push) base="$PUSH_BEFORE" ;; + *) base="" ;; + esac + args=(identity . --base "$base" --head HEAD) + if [ "$CONTENT" != "true" ]; then args+=(--no-content); fi + if [ "$ENFORCE" = "true" ]; then args+=(--enforce); fi + ci-auditor "${args[@]}" diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml new file mode 100644 index 0000000..ebb3479 --- /dev/null +++ b/.github/workflows/release-dry-run.yml @@ -0,0 +1,77 @@ +# Reusable release dry-run gate (WI-3087, rules CI-21/CI-22). +# +# A dry run is the SAME release build with publishing off, not a parallel +# copy. The caller passes its own reusable release build workflow, which +# must accept `publish: boolean`; this wrapper adds the preflight checks a +# tag would otherwise find first: +# - the tag a release would cut matches the workspace version; +# - the build workflow file is valid (actionlint ran in policy.yml). +# +# Caller (templates/release-dry-run-stub.yml): +# on: +# schedule: [{cron: "17 3 * * *"}] +# pull_request: {paths: [".github/workflows/release*.yml", "packaging/**", "Dockerfile*", "scripts/release/**"]} +# workflow_dispatch: +# jobs: +# preflight: +# uses: TheDancingDeveloper-org/github-policy/.github/workflows/release-dry-run.yml@v1.0.0 +# build: +# needs: preflight +# uses: ./.github/workflows/release-build.yml +# with: {publish: false} +# +# GitHub cannot call a reusable workflow named by an input, so the build is +# a sibling job in the caller rather than a call made from here. + +name: Release dry run preflight (reusable) + +on: + workflow_call: + inputs: + runs-on: + type: string + default: '"ci-small"' + version-file: + description: File holding the release version (Cargo.toml, pyproject.toml, package.json). + type: string + default: Cargo.toml + outputs: + version: + value: ${{ jobs.preflight.outputs.version }} + +permissions: + contents: read + +jobs: + preflight: + name: preflight + runs-on: ${{ fromJSON(inputs.runs-on) }} + timeout-minutes: 5 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Version the next tag would carry + id: version + shell: bash + env: + FILE: ${{ inputs.version-file }} + run: | + set -euo pipefail + case "$FILE" in + *Cargo.toml|*pyproject.toml) + version=$(python3 -c "import sys,tomllib; d=tomllib.load(open(sys.argv[1],'rb')); print((d.get('workspace',{}).get('package') or d.get('package') or d.get('project') or {}).get('version',''))" "$FILE") ;; + *package.json) + version=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('version',''))" "$FILE") ;; + *) echo "unsupported version file $FILE" >&2; exit 1 ;; + esac + if [ -z "$version" ]; then echo "::error::no version in $FILE"; exit 1; fi + echo "version=$version" >> "$GITHUB_OUTPUT" + latest=$(git tag --list 'v*' --sort=-v:refname | head -1) + echo "next release v$version (latest tag ${latest:-none})" >> "$GITHUB_STEP_SUMMARY" + if [ "v$version" = "$latest" ] && [ "$(git rev-list -n1 "$latest")" != "$(git rev-parse HEAD)" ]; then + echo "::warning::v$version is already tagged at another commit: bump the version before the next tag" + fi diff --git a/.github/workflows/runner-policy-reusable.yml b/.github/workflows/runner-policy-reusable.yml index e063e1e..bb79e33 100644 --- a/.github/workflows/runner-policy-reusable.yml +++ b/.github/workflows/runner-policy-reusable.yml @@ -48,6 +48,7 @@ permissions: jobs: runner-policy: runs-on: [self-hosted] + timeout-minutes: 10 steps: - name: Check out the calling repository uses: actions/checkout@v5 diff --git a/.github/workflows/runner-policy.yml b/.github/workflows/runner-policy.yml index 65a1c1f..397b83f 100644 --- a/.github/workflows/runner-policy.yml +++ b/.github/workflows/runner-policy.yml @@ -8,8 +8,11 @@ name: Runner policy on: pull_request: - push: - branches: [main] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'merge_group' }} jobs: runner-policy: diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..78ac5e5 --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,112 @@ +# Reusable Rust CI (WI-3087): fmt, clippy, nextest, optional cargo-deny. +# +# The caller's CI workflow calls it and the `ci` gate needs it: +# +# rust: +# uses: TheDancingDeveloper-org/github-policy/.github/workflows/rust-ci.yml@v1.0.0 +# with: {packages: "-p graft-core -p graft-server"} +# +# Cache (rule CI-19): Swatinem/rust-cache, shared key per job class, saved +# only on the default branch. PR and merge-queue runs restore only; the +# cache-warm path is a push-to-main or nightly call with `warm: true`. +# The toolchain comes from the caller's rust-toolchain.toml (rustup reads it). +# No apt, no `cargo install`: nextest and cargo-deny are prebuilt binaries. + +name: Rust CI (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: JSON runs-on; a runner class from ci-standard/rules.yaml. + type: string + default: '["self-hosted", "rust-heavy"]' + packages: + description: cargo package selection (`-p a -p b`); empty means --workspace. + type: string + default: "" + features: + description: cargo feature flags, e.g. `--all-features`. + type: string + default: "" + nextest-profile: + description: nextest profile (`ci` in the PR set, a retries profile in merge_group). + type: string + default: default + deny: + description: Run cargo-deny (bans, licences, advisories with the offline DB). + type: boolean + default: false + warm: + description: Cache-warm mode (push to main / nightly) - build tests without running them. + type: boolean + default: false + timeout-minutes: + type: number + default: 30 + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + # If a caller sets RUSTC_WRAPPER=sccache, a dead cache server must not fail + # the build (rule CI-20). + SCCACHE_IGNORE_SERVER_IO_ERROR: "1" + +jobs: + rust: + name: rust + runs-on: ${{ fromJSON(inputs.runs-on) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + steps: + - uses: actions/checkout@v5 + + - name: Toolchain (from rust-toolchain.toml) + shell: bash + run: | + rustup show active-toolchain || rustup toolchain install + rustup component add rustfmt clippy + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: rust-ci + save-if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + cache-on-failure: false + + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + with: + tool: cargo-nextest,cargo-deny + + - name: fmt + if: ${{ !inputs.warm }} + run: cargo fmt --all --check + + - name: clippy + env: + PKGS: ${{ inputs.packages }} + FEATURES: ${{ inputs.features }} + shell: bash + run: | + # shellcheck disable=SC2086 # word-splitting the package list is intended + cargo clippy ${PKGS:---workspace} $FEATURES --all-targets --locked -- -D warnings + + - name: nextest + env: + PKGS: ${{ inputs.packages }} + FEATURES: ${{ inputs.features }} + PROFILE: ${{ inputs.nextest-profile }} + WARM: ${{ inputs.warm }} + shell: bash + run: | + # shellcheck disable=SC2086 + if [ "$WARM" = "true" ]; then + cargo nextest run ${PKGS:---workspace} $FEATURES --locked --no-run + else + cargo nextest run ${PKGS:---workspace} $FEATURES --locked --profile "$PROFILE" + fi + + - name: cargo-deny + if: ${{ inputs.deny && !inputs.warm }} + run: cargo deny --locked check --disable-fetch bans licenses advisories diff --git a/CODEOWNERS b/CODEOWNERS index bb4c073..6c828f7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2,3 +2,5 @@ /CODEOWNERS @TheDancingDeveloper-org/owners /runner-exceptions.json @TheDancingDeveloper-org/owners /scripts/ @TheDancingDeveloper-org/owners +/ci-standard/ @TheDancingDeveloper-org/owners +/auditor/ @TheDancingDeveloper-org/owners diff --git a/README.md b/README.md index fdff698..5aadc56 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ This repository is the source of truth for organization-wide CI policy. +**CI guardrails (WI-3087):** the CI standard as code is in +[`ci-standard/`](ci-standard/README.md). It holds the rules, exemptions, the +reusable `policy.yml`, `rust-ci.yml`, `image.yml` and `release-dry-run.yml`, and +the `ci-auditor` that audits every repository and feeds Grafana. New +repositories call `policy.yml`; the `runner-policy` gate below is the legacy +path, kept until its callers move. + - GitHub Actions jobs use approved self-hosted runner labels by default. - Public-hosted runners require an owner-approved, time-bounded exception in `runner-exceptions.json`. diff --git a/auditor/.gitignore b/auditor/.gitignore new file mode 100644 index 0000000..cdba89e --- /dev/null +++ b/auditor/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/auditor/Dockerfile b/auditor/Dockerfile new file mode 100644 index 0000000..6303f3d --- /dev/null +++ b/auditor/Dockerfile @@ -0,0 +1,29 @@ +# ci-auditor image (WI-3087). Built from the repository root: +# docker build -f auditor/Dockerfile -t ci-auditor . +# Contents: the auditor source, ci-standard/ (rules and actionlint config at +# this tag), PyYAML (hash-pinned) and actionlint (checksum-verified). +FROM python:3.13-slim-bookworm@sha256:281d39ff02e3b389401586134ff9e6572954687822a58055ec07a60ded4e0b7f + +ARG ACTIONLINT_VERSION=1.7.7 +ARG ACTIONLINT_SHA256=023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757 + +COPY --chmod=644 auditor/requirements.lock /tmp/requirements.lock +RUN pip install --no-cache-dir --require-hashes --no-deps -r /tmp/requirements.lock \ + && python3 -c "import urllib.request; urllib.request.urlretrieve('https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz', '/tmp/al.tgz')" \ + && echo "${ACTIONLINT_SHA256} /tmp/al.tgz" | sha256sum -c - \ + && tar -xzf /tmp/al.tgz -C /usr/local/bin actionlint \ + && rm -f /tmp/al.tgz /tmp/requirements.lock \ + && apt-get -qq update && apt-get -qq install -y --no-install-recommends git >/dev/null && rm -rf /var/lib/apt/lists/* + +COPY --chmod=755 auditor/src /opt/ci-auditor/src +COPY --chmod=755 ci-standard /opt/ci-auditor/ci-standard + +ENV PYTHONPATH=/opt/ci-auditor/src \ + CI_STANDARD_DIR=/opt/ci-auditor/ci-standard \ + ACTIONLINT=/usr/local/bin/actionlint \ + PYTHONUNBUFFERED=1 + +USER 65534:65534 +EXPOSE 9803 +ENTRYPOINT ["python3", "-m", "ci_auditor"] +CMD ["serve"] diff --git a/auditor/pyproject.toml b/auditor/pyproject.toml new file mode 100644 index 0000000..7292c0d --- /dev/null +++ b/auditor/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "ci-auditor" +version = "1.0.0" +description = "CI guardrails auditor and policy gate for TheDancingDeveloper-org (WI-3087)" +requires-python = ">=3.12" +dependencies = ["PyYAML==6.0.3"] + +[project.scripts] +ci-auditor = "ci_auditor.__main__:main" + +[dependency-groups] +dev = ["pytest==8.4.2", "mypy==1.18.2", "ruff==0.14.0", "types-PyYAML==6.0.12.20250915"] + +[build-system] +requires = ["hatchling==1.27.0"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ci_auditor"] + +[tool.mypy] +strict = true +files = ["src", "tests"] + +[tool.ruff] +line-length = 110 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "SIM"] +ignore = ["E501"] # the formatter owns line length; metric f-strings stay on one line + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/auditor/requirements.lock b/auditor/requirements.lock new file mode 100644 index 0000000..825cf60 --- /dev/null +++ b/auditor/requirements.lock @@ -0,0 +1,8 @@ +# Runtime dependency of ci-auditor, hash-pinned for `pip install --require-hashes` +# in the policy gate (until the ci-small runner image ships PyYAML). +pyyaml==6.0.3 \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f diff --git a/auditor/src/ci_auditor/__init__.py b/auditor/src/ci_auditor/__init__.py new file mode 100644 index 0000000..5ee8c22 --- /dev/null +++ b/auditor/src/ci_auditor/__init__.py @@ -0,0 +1,12 @@ +"""CI guardrails auditor (WI-3087). + +One package, two entry points: + +* ``ci-auditor lint`` - the static rules over one checkout; the policy gate + (github-policy ``policy.yml``) runs it inside every repository. +* ``ci-auditor serve`` - the org-wide auditor: reads workflows, branch rules + and recent runs for every repository, evaluates every rule minus the live + exemptions, and serves Prometheus metrics plus one JSON log line per finding. +""" + +__version__ = "1.0.0" diff --git a/auditor/src/ci_auditor/__main__.py b/auditor/src/ci_auditor/__main__.py new file mode 100644 index 0000000..1d7dd76 --- /dev/null +++ b/auditor/src/ci_auditor/__main__.py @@ -0,0 +1,183 @@ +"""ci-auditor command line: lint | serve | once | validate | identity.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from datetime import UTC, date, datetime +from pathlib import Path + +from . import __version__ +from . import workflow as wfm +from .evaluate import evaluate +from .github import run_actionlint +from .identity import check as identity_check +from .identity import default_forbidden +from .model import Result, Snapshot +from .standard import StandardError, default_standard_dir, load, parse_exemptions + + +def snapshot_from_checkout(root: Path, repo: str, private: bool, default_branch: str) -> Snapshot: + snap = Snapshot(repo=repo, full_name=repo, private=private, default_branch=default_branch) + wdir = root / ".github" / "workflows" + for path in sorted([*wdir.glob("*.yml"), *wdir.glob("*.yaml")]): + try: + snap.workflows.append(wfm.parse(path.name, path.read_text(encoding="utf-8"))) + except Exception as err: # noqa: BLE001 + snap.parse_errors[path.name] = f"{type(err).__name__}: {str(err).splitlines()[0][:160]}" + try: + out = subprocess.run(["git", "-C", str(root), "ls-files"], capture_output=True, text=True, check=True) + snap.tree = out.stdout.split() + except (subprocess.CalledProcessError, FileNotFoundError): + snap.tree = [ + str(p.relative_to(root)) for p in root.rglob("*") if p.is_file() and ".git" not in p.parts + ] + return snap + + +def _annotation(level: str, r: Result, message: str, workflow: str | None) -> str: + file = f" file=.github/workflows/{workflow}," if workflow else " " + title = f"{r.rule.id} {r.rule.title}".replace(",", " ") + return f"::{level}{file}title={title}::{message} - fix: {r.rule.fix}".replace("\n", " ") + + +def cmd_lint(args: argparse.Namespace) -> int: + std_dir = Path(args.standard) if args.standard else default_standard_dir() + std, exemptions = load(std_dir) + if args.exemptions: + exemptions = parse_exemptions(Path(args.exemptions).read_text(encoding="utf-8"), std) + root = Path(args.root) + snap = snapshot_from_checkout(root, args.repo or root.resolve().name, args.private, args.default_branch) + if args.actionlint: + run_actionlint(snap, args.actionlint, std_dir / "actionlint.yaml") + results = evaluate(snap, std, exemptions, date.fromisoformat(args.today), kinds=("static",)) + blocking = 0 + summary = [ + f"### CI guardrails ({'enforce' if args.enforce else 'report only'})", + "", + "| rule | status | findings |", + "|---|---|---|", + ] + for r in results: + if r.status in ("na", "unknown") and not r.findings: + continue + summary.append(f"| {r.rule.id} {r.rule.title} | {r.status} | {len(r.findings)} |") + level = "error" if args.enforce and r.rule.enforce == "block" else "warning" + for f in r.findings: + if args.format == "github": + print(_annotation(level, r, f.message, f.workflow)) + else: + where = "/".join(x for x in (f.workflow, f.job) if x) + print(f"{r.rule.id} [{r.status}] {where}: {f.message}") + if level == "error": + blocking += 1 + for f, e in r.exempted: + print(f"{r.rule.id} [exempt until {e.expires}, {e.owner}] {f.workflow or ''}: {f.message}") + step_summary = os.environ.get("GITHUB_STEP_SUMMARY") + if step_summary: + with open(step_summary, "a", encoding="utf-8") as fh: + fh.write("\n".join(summary) + "\n") + fails = sum(len(r.findings) for r in results) + print(f"ci-guardrails: {len(snap.workflows)} workflow(s), {fails} finding(s), {blocking} blocking") + return 1 if blocking else 0 + + +def cmd_identity(args: argparse.Namespace) -> int: + forbidden = default_forbidden() + [x for x in args.forbidden.split(",") if x] + found = identity_check(Path(args.root), args.base or None, args.head, forbidden, content=args.content) + level = "error" if args.enforce else "warning" + for f in found: + print(f"::{level} title=identity policy::{f.where}: {f.message}") + print( + f"identity policy: {len(found)} finding(s) (author/committer, paths{', content' if args.content else ''};" + " commit bodies are not read)" + ) + return 1 if found and args.enforce else 0 + + +def cmd_validate(args: argparse.Namespace) -> int: + from .checks import CHECKS + + std_dir = Path(args.standard) if args.standard else default_standard_dir() + try: + std, exemptions = load(std_dir) + except StandardError as err: + print(f"invalid: {err}", file=sys.stderr) + return 1 + missing = sorted({r.check for r in std.rules} - CHECKS.keys()) + unused = sorted(CHECKS.keys() - {r.check for r in std.rules}) + if missing or unused: + print(f"rules/checks mismatch: missing={missing} unused={unused}", file=sys.stderr) + return 1 + today = date.fromisoformat(args.today) + expired = [e for e in exemptions if e.expires < today] + for e in expired: + print(f"expired exemption: {e.repo} {e.rule} ({e.owner}) expired {e.expires}", file=sys.stderr) + print(f"ok: {len(std.rules)} rules, {len(std.classes)} runner classes, {len(exemptions)} exemptions") + return 1 if expired and args.fail_expired else 0 + + +def cmd_serve(args: argparse.Namespace) -> int: + from .server import Auditor, serve + + auditor = Auditor.from_env() + if args.once: + auditor.audit() + auditor.refresh_main() + sys.stdout.write(auditor.render()) + return 0 + serve(auditor) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="ci-auditor", description=__doc__) + parser.add_argument("--version", action="version", version=__version__) + sub = parser.add_subparsers(dest="cmd", required=True) + today = datetime.now(UTC).date().isoformat() + + lint = sub.add_parser("lint", help="static rules over one checkout (the policy gate)") + lint.add_argument("root", nargs="?", default=".") + lint.add_argument("--repo", default="") + lint.add_argument("--private", action=argparse.BooleanOptionalAction, default=True) + lint.add_argument("--default-branch", default="main") + lint.add_argument("--standard", default="") + lint.add_argument("--exemptions", default="", help="exemptions.yaml to use instead of the bundled one") + lint.add_argument("--actionlint", default="", help="actionlint binary (CI-17)") + lint.add_argument("--enforce", action="store_true", help="fail on findings of `enforce: block` rules") + lint.add_argument("--format", choices=("github", "text"), default="text") + lint.add_argument("--today", default=today) + lint.set_defaults(fn=cmd_lint) + + ident = sub.add_parser("identity", help="author/committer, path and content identity check") + ident.add_argument("root", nargs="?", default=".") + ident.add_argument("--base", default="") + ident.add_argument("--head", default="HEAD") + ident.add_argument("--forbidden", default="", help="extra comma-separated identifiers") + ident.add_argument("--content", action=argparse.BooleanOptionalAction, default=True) + ident.add_argument("--enforce", action="store_true") + ident.set_defaults(fn=cmd_identity) + + val = sub.add_parser("validate", help="validate rules.yaml and exemptions.yaml") + val.add_argument("--standard", default="") + val.add_argument("--today", default=today) + val.add_argument("--fail-expired", action="store_true") + val.set_defaults(fn=cmd_validate) + + srv = sub.add_parser("serve", help="org-wide auditor and Prometheus exporter") + srv.add_argument("--once", action="store_true", help="one audit, print metrics, exit") + srv.set_defaults(fn=cmd_serve) + + args = parser.parse_args(argv) + try: + return int(args.fn(args)) + except StandardError as err: + print(json.dumps({"msg": "ci_standard_invalid", "error": str(err)}), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/auditor/src/ci_auditor/checks.py b/auditor/src/ci_auditor/checks.py new file mode 100644 index 0000000..f632705 --- /dev/null +++ b/auditor/src/ci_auditor/checks.py @@ -0,0 +1,623 @@ +"""One function per rule `check:` in ci-standard/rules.yaml. + +Each check reads a Snapshot and returns an Outcome. It never raises for +missing data: data it needs but did not get makes it `unknown`; a repository +the rule does not apply to is `na`. +""" + +from __future__ import annotations + +import re +from collections import Counter +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal + +from . import workflow as wfm +from .model import Finding, Snapshot +from .standard import Standard +from .workflow import Job + +OutcomeStatus = Literal["pass", "fail", "na", "unknown"] +POLICY_WORKFLOW = "TheDancingDeveloper-org/github-policy/.github/workflows/policy.yml@" +SEMVER_TAG = re.compile(r"^v\d+(\.\d+){0,2}$") +SHA = re.compile(r"^[0-9a-f]{40}$") +HEAVY_STEP = re.compile( + r"docker\s+(buildx\s+)?build|docker\s+compose\s+.*\bup\b|docker-compose\s+.*\bup\b|" + r"playwright\s+test" +) +BODY_GREP = re.compile(r"(%B|%b|--format=%B|--pretty=%B|format:%B)") +PRIVATE_FETCH = re.compile( + r"(git\s+clone\s+\S*|cargo\s+install\s+.*--git\s+\S+|pip\s+install\s+\S*git\+\S+|uv\s+pip\s+install\s+\S*git\+\S+|" + r"npm\s+(i|install)\s+\S*github:\S+)" +) +GITHUB_REPO_URL = re.compile(r"github\.com[/:]([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(\.git)?(?=[\s/@#'\"]|$)") +FORGEJO_URL = re.compile(r"repo\.indexarr\.net[/:]") +ORG = "TheDancingDeveloper-org" +OPEN_OWNERS = {"actions", "github", ORG} + + +@dataclass +class Outcome: + status: OutcomeStatus + findings: list[Finding] = field(default_factory=list) + note: str = "" + + +def _done( + snap: Snapshot, + rule: str, + found: Sequence[tuple[str, str | None, str | None]], + applicable: bool = True, + note: str = "", +) -> Outcome: + if not applicable: + return Outcome("na", note=note) + unique = dict.fromkeys(found) # one finding per (message, workflow, job) + findings = [Finding(repo=snap.repo, rule=rule, message=m, workflow=w, job=j) for m, w, j in unique] + return Outcome("fail" if findings else "pass", findings, note) + + +def _all_jobs(snap: Snapshot) -> list[Job]: + return [j for wf in snap.workflows for j in wfm.jobs(wf)] + + +def _class_of(job: Job, std: Standard) -> list[str]: + sets = wfm.runs_on_sets(job) or [] + return [c.name for s in sets if (c := wfm.classify(s, std.classes)) is not None] + + +# --- triggers ---------------------------------------------------------------------------------------------- + + +def trigger_push_scope(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + ci = [wf for wf in snap.workflows if wfm.is_ci(wf)] + for wf in ci: + cfg = wfm.push_config(wf) + if cfg is None: + continue + branches = wfm.str_list(cfg.get("branches")) + if not branches and not cfg.get("tags") and not cfg.get("branches-ignore"): + found.append(("unfiltered `push:` runs CI on every branch", wf.file, None)) + continue + if cfg.get("branches-ignore"): + found.append(("`push: branches-ignore` runs CI on every other branch", wf.file, None)) + others = [b for b in branches if b != snap.default_branch] + if others: + found.append((f"push runs CI on branches {others}", wf.file, None)) + return _done(snap, rule, found, applicable=bool(ci)) + + +def trigger_no_main_retest(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + both = [wf for wf in snap.workflows if wfm.is_ci(wf) and wfm.pushes_branch(wf, snap.default_branch)] + for wf in both: + retest = [j.key for j in wfm.jobs(wf) if not _push_only(j)] + if retest: + found.append((f"re-runs {retest} on push to {snap.default_branch}", wf.file, None)) + return _done(snap, rule, found, applicable=bool(both)) + + +def _push_only(job: Job) -> bool: + c = job.condition.replace('"', "'") + return bool( + re.search(r"event_name\s*==\s*'push'", c) + or re.search(r"github\.ref\s*==\s*'refs/heads/", c) + or re.search(r"github\.ref_name\s*==\s*'", c) + ) + + +def trigger_merge_group_matches_queue(snap: Snapshot, std: Standard, rule: str) -> Outcome: + if not snap.merge_queue_known: + return Outcome("unknown", note="branch rules not visible") + found: list[tuple[str, str | None, str | None]] = [] + with_mg = [wf for wf in snap.workflows if "merge_group" in wfm.triggers(wf)] + if snap.merge_queue is None: + for wf in with_mg: + found.append(("merge_group trigger but the default branch has no merge queue", wf.file, None)) + return _done(snap, rule, found) + if snap.required_contexts is None: + return Outcome("unknown", note="required contexts not visible") + for ctx in snap.required_contexts: + for job in _producers(snap, ctx): + if "merge_group" not in wfm.triggers(job.workflow): + found.append( + ( + f"required `{ctx}` is produced by a workflow without merge_group", + job.workflow.file, + job.key, + ) + ) + return _done(snap, rule, found) + + +def trigger_heavy_off_pr(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + pr_jobs = [j for j in _all_jobs(snap) if wfm.can_run_on(j, wfm.PR_EVENTS)] + for job in pr_jobs: + heavy = {"docker", "bigmem", "publish"} & set(_class_of(job, std)) + step_hit = HEAVY_STEP.search(job.runs()) or any( + "docker/build-push-action" in str(s.get("uses", "")) for s in job.steps() + ) + if heavy or step_hit: + why = f"class {sorted(heavy)}" if heavy else "heavy step (image build / compose e2e / playwright)" + found.append((f"runs on pull_request: {why}", job.workflow.file, job.key)) + return _done(snap, rule, found, applicable=bool(snap.workflows)) + + +# --- runners ----------------------------------------------------------------------------------------------- + + +def runner_class_allowed(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + jobs = [j for j in _all_jobs(snap) if j.uses is None] + for job in jobs: + sets = wfm.runs_on_sets(job) + if sets is None: + found.append( + ( + f"runs-on is not resolvable to a class: {job.body.get('runs-on')!r}", + job.workflow.file, + job.key, + ) + ) + continue + for labels in sets: + cls = wfm.classify(labels, std.classes) + shown = sorted(labels) + if cls is None: + hint = " (bare self-hosted is not a class)" if labels == frozenset(["self-hosted"]) else "" + found.append((f"runs-on {shown} is not a runner class{hint}", job.workflow.file, job.key)) + elif cls.public_only and snap.private: + found.append((f"hosted runner {shown} in a private repository", job.workflow.file, job.key)) + elif cls.repos and snap.repo not in cls.repos: + found.append( + (f"class {cls.name} is reserved for {list(cls.repos)}", job.workflow.file, job.key) + ) + return _done(snap, rule, found, applicable=bool(jobs)) + + +def runner_publish_scope(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + publish = [j for j in _all_jobs(snap) if "publish" in _class_of(j, std)] + for job in publish: + events = (*wfm.PR_EVENTS, "merge_group") + if wfm.can_run_on(job, events): + on = [e for e in events if e in wfm.triggers(job.workflow)] + found.append((f"publish-class job can run on {on}", job.workflow.file, job.key)) + return _done(snap, rule, found, applicable=bool(publish)) + + +def runtime_runner_labels(snap: Snapshot, std: Standard, rule: str) -> Outcome: + if snap.runtime_jobs is None: + return Outcome("unknown", note="runs not collected") + bad: Counter[tuple[str, str, tuple[str, ...]]] = Counter() + for job in snap.runtime_jobs: + if not job.labels: + continue + cls = wfm.classify(frozenset(job.labels), std.classes) + if cls is None or (cls.public_only and snap.private): + bad[(job.workflow_path.rsplit("/", 1)[-1], job.job, tuple(sorted(job.labels)))] += 1 + found: list[tuple[str, str | None, str | None]] = [ + (f"{n} recent job(s) ran with labels {list(labels)}", wf, name) + for (wf, name, labels), n in sorted(bad.items()) + ] + return _done(snap, rule, found, applicable=bool(snap.runtime_jobs)) + + +# --- gate -------------------------------------------------------------------------------------------------- + + +def _producers(snap: Snapshot, context: str) -> list[Job]: + return [j for j in _all_jobs(snap) if wfm.produces(j, context)] + + +def gate_shape(snap: Snapshot, std: Standard, rule: str) -> Outcome: + if snap.required_contexts is None: + return Outcome("unknown", note="required contexts not visible") + found: list[tuple[str, str | None, str | None]] = [] + required = snap.required_contexts + if not required: + found.append(("the default branch requires no status checks", None, None)) + return _done(snap, rule, found, applicable=bool(snap.workflows)) + if "ci" not in required: + found.append((f"required contexts {sorted(required)} do not include the `ci` gate", None, None)) + extra = sorted(set(required) - set(std.required_contexts)) + if extra: + found.append( + (f"required contexts {extra} are not gate contexts {list(std.required_contexts)}", None, None) + ) + for job in _producers(snap, "ci"): + if not job.body.get("needs"): + found.append(("`ci` gate job has no needs", job.workflow.file, job.key)) + if "always()" not in job.condition: + found.append( + ( + "`ci` gate job lacks `if: always()` (a failed need would skip it, not fail it)", + job.workflow.file, + job.key, + ) + ) + return _done(snap, rule, found) + + +def gate_producers_unfiltered(snap: Snapshot, std: Standard, rule: str) -> Outcome: + if snap.required_contexts is None: + return Outcome("unknown", note="required contexts not visible") + found: list[tuple[str, str | None, str | None]] = [] + for ctx in snap.required_contexts: + producers = _producers(snap, ctx) + if not producers: + found.append((f"required `{ctx}` has no producing job on {snap.default_branch}", None, None)) + continue + for job in producers: + filtered = wfm.path_filtered(job.workflow, (*wfm.PR_EVENTS, "merge_group")) + if filtered: + found.append( + ( + f"required `{ctx}` comes from a workflow path-filtered on {filtered}", + job.workflow.file, + job.key, + ) + ) + return _done(snap, rule, found, applicable=bool(snap.required_contexts)) + + +def gate_merge_queue(snap: Snapshot, std: Standard, rule: str) -> Outcome: + if not snap.merge_queue_known: + return Outcome("unknown", note="branch rules not visible") + found: list[tuple[str, str | None, str | None]] = [] + mq = snap.merge_queue + if mq is None: + if not snap.private and snap.required_contexts: + found.append(("public repository with required checks has no merge queue", None, None)) + return _done(snap, rule, found, applicable=not snap.private and bool(snap.required_contexts)) + if mq.get("merge_method") != "REBASE": + found.append((f"merge queue merge_method is {mq.get('merge_method')}, not REBASE", None, None)) + if mq.get("grouping_strategy") != "ALLGREEN": + found.append((f"merge queue grouping is {mq.get('grouping_strategy')}, not ALLGREEN", None, None)) + return _done(snap, rule, found) + + +# --- jobs -------------------------------------------------------------------------------------------------- + + +def job_timeouts(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + jobs = [j for j in _all_jobs(snap) if j.uses is None] + for job in jobs: + value = job.body.get("timeout-minutes") + if value is None: + found.append(("no timeout-minutes", job.workflow.file, job.key)) + elif isinstance(value, int | float) and value > 180: + found.append((f"timeout-minutes {value} > 180", job.workflow.file, job.key)) + return _done(snap, rule, found, applicable=bool(jobs)) + + +def workflow_concurrency(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + scoped = [ + wf + for wf in snap.workflows + if any(e in wfm.triggers(wf) for e in (*wfm.PR_EVENTS, "push", "merge_group")) + ] + for wf in scoped: + top = wf.doc.get("concurrency") + per_job = [j for j in wfm.jobs(wf)] + if top is None and not (per_job and all(j.body.get("concurrency") is not None for j in per_job)): + found.append(("no concurrency group", wf.file, None)) + continue + if ( + "merge_group" in wfm.triggers(wf) + and isinstance(top, dict) + and top.get("cancel-in-progress") is True + ): + found.append(("cancel-in-progress: true also cancels merge-queue runs", wf.file, None)) + return _done(snap, rule, found, applicable=bool(scoped)) + + +def job_no_continue_on_error(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + jobs = _all_jobs(snap) + for job in jobs: + if "continue-on-error" in job.body and job.body["continue-on-error"] is not False: + found.append( + ( + f"job-level continue-on-error: {job.body['continue-on-error']!r}", + job.workflow.file, + job.key, + ) + ) + return _done(snap, rule, found, applicable=bool(jobs)) + + +def runtime_masked_failures(snap: Snapshot, std: Standard, rule: str) -> Outcome: + if snap.runtime_jobs is None or snap.required_contexts is None: + return Outcome("unknown", note="runs or required contexts not collected") + producer_files = {j.workflow.file for ctx in snap.required_contexts for j in _producers(snap, ctx)} + masked: Counter[tuple[str, str]] = Counter() + for job in snap.runtime_jobs: + file = job.workflow_path.rsplit("/", 1)[-1] + if file in producer_files and job.conclusion == "failure" and job.run_conclusion == "success": + masked[(file, job.job)] += 1 + found: list[tuple[str, str | None, str | None]] = [ + (f"failed in {n} run(s) that still concluded success", wf, name) + for (wf, name), n in sorted(masked.items()) + ] + return _done(snap, rule, found, applicable=bool(producer_files)) + + +# --- policy ------------------------------------------------------------------------------------------------ + + +def policy_gate_adopted(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + calls = [j for j in _all_jobs(snap) if j.uses and j.uses.startswith(POLICY_WORKFLOW)] + if not calls: + found.append(("does not call github-policy policy.yml", None, None)) + for job in calls: + ref = (job.uses or "").split("@", 1)[1] + if not (SEMVER_TAG.match(ref) or SHA.match(ref)): + found.append((f"policy.yml pinned to `{ref}`, not a tag or SHA", job.workflow.file, job.key)) + with_ = job.body.get("with") + given = with_.get("ref") if isinstance(with_, dict) else None + if given != ref: + found.append( + ( + f"policy.yml `with: ref` is {given!r}, must repeat the uses ref `{ref}`", + job.workflow.file, + job.key, + ) + ) + return _done(snap, rule, found, applicable=bool(snap.workflows)) + + +def policy_identity_scope(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + if snap.tree is not None: + for path in snap.tree: + if "migration-policy" in path.rsplit("/", 1)[-1]: + found.append((f"local identity-policy copy `{path}` (use the shared policy.yml)", None, None)) + for job in _all_jobs(snap): + text = job.runs() + if "git" in text and BODY_GREP.search(text) and "grep" in text: + found.append(("greps commit message bodies", job.workflow.file, job.key)) + return _done(snap, rule, found, applicable=bool(snap.workflows) or bool(snap.tree)) + + +def workflows_actionlint(snap: Snapshot, std: Standard, rule: str) -> Outcome: + if snap.actionlint is None: + return Outcome("unknown", note="actionlint not run here") + found: list[tuple[str, str | None, str | None]] = [] + for line in snap.actionlint: + file = line.split(":", 1)[0].rsplit("/", 1)[-1] if ":" in line else None + found.append((line, file, None)) + for file, err in snap.parse_errors.items(): + found.append((f"YAML does not parse: {err}", file, None)) + return _done(snap, rule, found, applicable=bool(snap.workflows) or bool(snap.parse_errors)) + + +def actions_pinned(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + for job in _all_jobs(snap): + uses = [str(s["uses"]) for s in job.steps() if isinstance(s.get("uses"), str)] + if job.uses: + uses.append(job.uses) + for ref in uses: + if ref.startswith(("./", "docker://")) or "@" not in ref: + continue + owner = ref.split("/", 1)[0] + if owner in OPEN_OWNERS: + continue + if not SHA.match(ref.rsplit("@", 1)[1]): + found.append((f"`{ref}` is not pinned to a commit SHA", job.workflow.file, job.key)) + return _done(snap, rule, found, applicable=bool(snap.workflows)) + + +# --- cache ------------------------------------------------------------------------------------------------- + + +def _main_only(value: object) -> bool: + if value is False or value == "false": + return True + v = str(value).replace('"', "'") + return bool(re.search(r"refs/heads/|ref_name\s*==\s*'main'|event_name\s*==\s*'(push|schedule)'", v)) + + +def cache_rust_save_main_only(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + seen = False + for job in _all_jobs(snap): + for step in job.steps(): + uses = str(step.get("uses", "")) + with_: dict[str, Any] = step.get("with") if isinstance(step.get("with"), dict) else {} # type: ignore[assignment] + if uses.startswith("Swatinem/rust-cache"): + seen = True + if not _main_only(with_.get("save-if", "")): + found.append(("rust-cache saves outside main (set save-if)", job.workflow.file, job.key)) + elif uses.startswith("actions/cache@"): + paths = str(with_.get("path", "")) + if re.search(r"(^|\s|/)target\b|\.cargo", paths): + seen = True + found.append( + ( + "actions/cache saves cargo dirs on every ref (use rust-cache, or " + "actions/cache/restore)", + job.workflow.file, + job.key, + ) + ) + return _done(snap, rule, found, applicable=seen) + + +def cache_sccache_optional(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + seen = False + for wf in snap.workflows: + if "sccache" not in wf.text: + continue + seen = True + for job in wfm.jobs(wf): + if "sccache" not in str(job.body) and "sccache" not in str(wf.doc.get("env", "")): + continue + flag = str(job.env().get("SCCACHE_IGNORE_SERVER_IO_ERROR", "")).lower() + if flag not in ("1", "true"): + found.append(("uses sccache without SCCACHE_IGNORE_SERVER_IO_ERROR=1", wf.file, job.key)) + return _done(snap, rule, found, applicable=seen) + + +# --- release ----------------------------------------------------------------------------------------------- + + +def _releases(snap: Snapshot) -> bool: + for wf in snap.workflows: + trig = wfm.triggers(wf) + cfg = wfm.push_config(wf) + if "release" in trig or (cfg is not None and (cfg.get("tags") or cfg.get("tags-ignore"))): + return True + return False + + +def _dry_runs(snap: Snapshot) -> list[str]: + out = [] + for wf in snap.workflows: + if set(wfm.triggers(wf)) <= {"workflow_call"}: + continue # a reusable building block, not a repository's dry run + label = f"{wf.file} {wf.doc.get('name', '')}".lower().replace("_", "-") + if "dry-run" in label or "dry run" in label or "dryrun" in label: + out.append(wf.file) + return out + + +def release_dry_run_present(snap: Snapshot, std: Standard, rule: str) -> Outcome: + if not _releases(snap): + return Outcome("na") + found: list[tuple[str, str | None, str | None]] = [] + dry = [wf for wf in snap.workflows if wf.file in _dry_runs(snap)] + if not dry: + found.append(("releases on tags but has no release dry-run workflow", None, None)) + elif not any("schedule" in wfm.triggers(wf) for wf in dry): + found.append(("release dry-run has no nightly schedule", dry[0].file, None)) + return _done(snap, rule, found) + + +def runtime_dry_run_green(snap: Snapshot, std: Standard, rule: str) -> Outcome: + files = set(_dry_runs(snap)) + if not files: + return Outcome("na") + if snap.runtime_runs is None: + return Outcome("unknown", note="runs not collected") + nightly = sorted( + ( + r + for r in snap.runtime_runs + if r.workflow_path.rsplit("/", 1)[-1] in files + and r.event == "schedule" + and r.head_branch == snap.default_branch + and r.conclusion + ), + key=lambda r: r.created_at, + ) + if not nightly: + return Outcome("unknown", note="no scheduled dry-run in the window") + found: list[tuple[str, str | None, str | None]] = [] + last = nightly[-1] + if last.conclusion != "success": + found.append( + ( + f"latest nightly dry-run ({last.created_at}) concluded {last.conclusion}", + last.workflow_path.rsplit("/", 1)[-1], + None, + ) + ) + streak = best = 0 + for run in nightly: + streak = streak + 1 if run.conclusion != "success" else 0 + best = max(best, streak) + if best >= 3: + found.append((f"{best} nightly dry-runs failed in a row", None, None)) + return _done(snap, rule, found) + + +# --- deps -------------------------------------------------------------------------------------------------- + + +def deps_no_private_fetch(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + for job in _all_jobs(snap): + texts = [job.runs()] + [str(s.get("with", "")) for s in job.steps()] + for text in texts: + for match in PRIVATE_FETCH.finditer(text): + fetch = match.group(0) + if FORGEJO_URL.search(fetch): + found.append( + (f"fetches from Forgejo at CI time: `{fetch[:120]}`", job.workflow.file, job.key) + ) + continue + for owner, name, _ in GITHUB_REPO_URL.findall(fetch): + if ( + owner.lower() == ORG.lower() + and snap.org_visibility is not None + and snap.org_visibility.get(name, True) + ): + found.append((f"fetches private repo {name} at CI time", job.workflow.file, job.key)) + for step in job.steps(): + with_ = step.get("with") + if isinstance(with_, dict) and "secrets." in str(with_.get("build-args", "")): + found.append( + ("secret passed as a Docker build-arg (use `secrets:`)", job.workflow.file, job.key) + ) + return _done(snap, rule, found, applicable=bool(snap.workflows)) + + +def codeql_release_only(snap: Snapshot, std: Standard, rule: str) -> Outcome: + found: list[tuple[str, str | None, str | None]] = [] + codeql = [wf for wf in snap.workflows if "github/codeql-action" in wf.text] + for wf in codeql: + bad = [] + for event, cfg in wfm.triggers(wf).items(): + if event in ("release", "workflow_dispatch"): + continue + if event == "push" and isinstance(cfg, dict) and cfg.get("tags") and not cfg.get("branches"): + continue + bad.append(event) + if bad: + found.append((f"CodeQL runs on {bad}", wf.file, None)) + if snap.required_contexts: + for ctx in snap.required_contexts: + if "codeql" in ctx.lower() or ctx.lower().startswith("analyze"): + found.append((f"`{ctx}` (CodeQL) is a required check", None, None)) + return _done(snap, rule, found, applicable=bool(codeql) or bool(snap.required_contexts)) + + +Check = Callable[[Snapshot, Standard, str], Outcome] +CHECKS: dict[str, Check] = { + fn.__name__: fn + for fn in ( + trigger_push_scope, + trigger_no_main_retest, + trigger_merge_group_matches_queue, + trigger_heavy_off_pr, + runner_class_allowed, + runner_publish_scope, + runtime_runner_labels, + gate_shape, + gate_producers_unfiltered, + gate_merge_queue, + job_timeouts, + workflow_concurrency, + job_no_continue_on_error, + runtime_masked_failures, + policy_gate_adopted, + policy_identity_scope, + workflows_actionlint, + actions_pinned, + cache_rust_save_main_only, + cache_sccache_optional, + release_dry_run_present, + runtime_dry_run_green, + deps_no_private_fetch, + codeql_release_only, + ) +} diff --git a/auditor/src/ci_auditor/evaluate.py b/auditor/src/ci_auditor/evaluate.py new file mode 100644 index 0000000..c4f45d5 --- /dev/null +++ b/auditor/src/ci_auditor/evaluate.py @@ -0,0 +1,41 @@ +"""Evaluate every rule for one snapshot, minus unexpired exemptions.""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import date + +from .checks import CHECKS +from .model import Exemption, Kind, Result, Snapshot +from .standard import Standard + + +def live(exemptions: Iterable[Exemption], today: date) -> list[Exemption]: + """An exemption suppresses on its expiry day and not after.""" + return [e for e in exemptions if e.expires >= today] + + +def evaluate( + snap: Snapshot, + std: Standard, + exemptions: Iterable[Exemption], + today: date, + kinds: tuple[Kind, ...] = ("static", "settings", "runtime"), +) -> list[Result]: + active = live(exemptions, today) + results: list[Result] = [] + for rule in std.active: + if rule.kind not in kinds: + continue + outcome = CHECKS[rule.check](snap, std, rule.id) + result = Result(repo=snap.repo, rule=rule, status=outcome.status, note=outcome.note) + for finding in outcome.findings: + cover = next((e for e in active if e.covers(finding)), None) + if cover is None: + result.findings.append(finding) + else: + result.exempted.append((finding, cover)) + if outcome.status == "fail" and not result.findings: + result.status = "exempt" + results.append(result) + return results diff --git a/auditor/src/ci_auditor/github.py b/auditor/src/ci_auditor/github.py new file mode 100644 index 0000000..d3ebeb5 --- /dev/null +++ b/auditor/src/ci_auditor/github.py @@ -0,0 +1,346 @@ +"""Read-only GitHub REST collection for the auditor. + +What a read-only token can see, and what it cannot (README "Visibility"): + +* workflows (git tree + blobs), required checks (the branch's protection + summary plus /rules/branches, which includes org rulesets that apply), + the merge queue rule, recent runs and their jobs - all readable. +* org rulesets as objects, runner groups and their repo/workflow + restrictions, and the runner inventory need admin:org; they are not read. +""" + +from __future__ import annotations + +import base64 +import json +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, cast + +from . import workflow as wfm +from .model import RuntimeJob, RuntimeRun, Snapshot + +API = "https://api.github.com" + + +class GitHubError(RuntimeError): + pass + + +@dataclass +class GitHub: + token: str + rate_reserve: int = 500 + api_requests: dict[tuple[str, int], int] = field(default_factory=dict) + remaining: int | None = None + reset: int | None = None + _etags: dict[str, tuple[str, Any]] = field(default_factory=dict) + _immutable: dict[str, Any] = field(default_factory=dict) + _lock: threading.Lock = field(default_factory=threading.Lock) + + def _request(self, url: str, kind: str, accept: str) -> tuple[int, Any]: + if ( + self.remaining is not None + and self.remaining < self.rate_reserve + and time.time() < (self.reset or 0) + ): + raise GitHubError(f"rate reserve reached ({self.remaining} left)") + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": accept, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ci-auditor", + }, + ) + cached = self._etags.get(url) + if cached: + req.add_header("If-None-Match", cached[0]) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + status, headers, raw = resp.status, resp.headers, resp.read() + except urllib.error.HTTPError as err: + status, headers, raw = err.code, err.headers, b"" + with self._lock: + self.api_requests[(kind, status)] = self.api_requests.get((kind, status), 0) + 1 + if headers.get("x-ratelimit-remaining") is not None: + self.remaining = int(headers["x-ratelimit-remaining"]) + self.reset = int(headers.get("x-ratelimit-reset", "0")) + if status == 304 and cached: + return 200, cached[1] + if status >= 400: + return status, None + body: Any = raw.decode("utf-8", "replace") if accept.endswith(".raw") else json.loads(raw or b"null") + if headers.get("ETag"): + self._etags[url] = (headers["ETag"], body) + return status, body + + def get( + self, path: str, kind: str, *, optional: bool = False, accept: str = "application/vnd.github+json" + ) -> Any: + """GET a path. `optional` turns 403/404 into None (a visibility gap).""" + status, body = self._request(path if path.startswith("http") else API + path, kind, accept) + if status >= 400: + if optional and status in (403, 404): + return None + raise GitHubError(f"GET {path}: HTTP {status}") + return body + + def immutable(self, key: str, path: str, kind: str) -> Any: + """GET once and keep forever (blobs by sha, jobs of completed runs).""" + if key not in self._immutable: + self._immutable[key] = self.get(path, kind) + return self._immutable[key] + + def paged(self, path: str, kind: str, limit: int = 1000) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + page = 1 + sep = "&" if "?" in path else "?" + while len(out) < limit: + batch = self.get(f"{path}{sep}per_page=100&page={page}", kind) + if not isinstance(batch, list) or not batch: + break + out += batch + if len(batch) < 100: + break + page += 1 + return out + + +@dataclass(frozen=True) +class RepoRef: + repo: str + full_name: str + private: bool + archived: bool + default_branch: str + pushed_at: str = "" + + +def list_repos(gh: GitHub, org: str, extra: list[str]) -> list[RepoRef]: + raw = gh.paged(f"/orgs/{org}/repos?type=all", "repos") + for full in extra: + body = gh.get(f"/repos/{full}", "repo", optional=True) + if isinstance(body, dict): + raw.append(body) + return [ + RepoRef( + repo=str(r["name"]), + full_name=str(r["full_name"]), + private=bool(r["private"]), + archived=bool(r["archived"]), + default_branch=str(r.get("default_branch") or "main"), + pushed_at=str(r.get("pushed_at") or ""), + ) + for r in raw + ] + + +def _workflows(gh: GitHub, ref: RepoRef, snap: Snapshot) -> None: + tree = gh.get(f"/repos/{ref.full_name}/git/trees/{ref.default_branch}?recursive=1", "tree", optional=True) + if not isinstance(tree, dict): + snap.gaps.append("tree") + return + entries = [cast(dict[str, Any], e) for e in tree.get("tree", []) if isinstance(e, dict)] + snap.tree = [str(e["path"]) for e in entries if e.get("type") == "blob"] + if tree.get("truncated"): + snap.gaps.append("tree_truncated") + for entry in entries: + path = str(entry.get("path", "")) + parent, _, name = path.rpartition("/") + if parent != ".github/workflows" or not name.endswith((".yml", ".yaml")): + continue + blob = gh.immutable( + f"blob:{entry['sha']}", f"/repos/{ref.full_name}/git/blobs/{entry['sha']}", "blob" + ) + text = base64.b64decode(blob["content"]).decode("utf-8", "replace") + try: + snap.workflows.append(wfm.parse(name, text)) + except Exception as err: # noqa: BLE001 - any parse failure is a finding, not a crash + snap.parse_errors[name] = f"{type(err).__name__}: {str(err).splitlines()[0][:160]}" + + +def _branch_rules(gh: GitHub, ref: RepoRef, snap: Snapshot) -> None: + contexts: set[str] = set() + branch = gh.get(f"/repos/{ref.full_name}/branches/{ref.default_branch}", "branch", optional=True) + rules = gh.get(f"/repos/{ref.full_name}/rules/branches/{ref.default_branch}", "rules", optional=True) + if not isinstance(branch, dict) or rules is None: + snap.gaps.append("branch_rules") + return + protection = branch.get("protection") or {} + rsc = protection.get("required_status_checks") or {} + contexts.update(str(c) for c in rsc.get("contexts") or []) + snap.merge_queue_known = True + for rule in rules if isinstance(rules, list) else []: + params = rule.get("parameters") or {} + if rule.get("type") == "required_status_checks": + contexts.update(str(c["context"]) for c in params.get("required_status_checks") or []) + elif rule.get("type") == "merge_queue": + snap.merge_queue = cast(dict[str, Any], params) + snap.required_contexts = sorted(contexts) + + +def _runtime(gh: GitHub, ref: RepoRef, snap: Snapshot, days: int, max_runs: int) -> None: + if ref.pushed_at and datetime.fromisoformat(ref.pushed_at.replace("Z", "+00:00")).timestamp() < ( + time.time() - days * 86400 + ): + snap.runtime_runs, snap.runtime_jobs = [], [] # no push in the window: nothing ran worth reading + return + since = datetime.fromtimestamp(time.time() - days * 86400, UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + body = gh.get( + f"/repos/{ref.full_name}/actions/runs?per_page=100&created=%3E%3D{since}", "runs", optional=True + ) + if not isinstance(body, dict): + snap.gaps.append("runs") + return + runs = [cast(dict[str, Any], r) for r in body.get("workflow_runs", [])] + snap.runtime_runs = [ + RuntimeRun( + workflow_path=str(r.get("path", "")), + event=str(r.get("event", "")), + head_branch=str(r.get("head_branch") or ""), + conclusion=str(r.get("conclusion") or ""), + created_at=str(r["created_at"]), + ) + for r in runs + ] + jobs: list[RuntimeJob] = [] + completed = [r for r in runs if r.get("status") == "completed"][:max_runs] + for run in completed: + jd = gh.immutable( + f"jobs:{run['id']}", + f"/repos/{ref.full_name}/actions/runs/{run['id']}/jobs?per_page=100&filter=latest", + "jobs", + ) + for job in jd.get("jobs", []): + jobs.append( + RuntimeJob( + workflow_path=str(run.get("path", "")), + workflow_name=str(run.get("name") or ""), + job=str(job.get("name", "")), + labels=tuple(str(x) for x in job.get("labels") or []), + conclusion=str(job.get("conclusion") or ""), + run_id=int(run["id"]), + run_conclusion=str(run.get("conclusion") or ""), + event=str(run.get("event", "")), + head_branch=str(run.get("head_branch") or ""), + ) + ) + snap.runtime_jobs = jobs + + +def run_actionlint(snap: Snapshot, binary: str | None, config: Path | None = None) -> None: + exe = binary or shutil.which("actionlint") + if not exe or not snap.workflows: + return + with tempfile.TemporaryDirectory() as tmp: + wdir = Path(tmp) / ".github" / "workflows" + wdir.mkdir(parents=True) + for wf in snap.workflows: + (wdir / wf.file).write_text(wf.text, encoding="utf-8") + subprocess.run(["git", "init", "-q", tmp], check=False, capture_output=True) + extra = ["-config-file", str(config.resolve())] if config is not None and config.exists() else [] + proc = subprocess.run( + [ + exe, + "-shellcheck=", + "-pyflakes=", + "-no-color", + "-oneline", + "-ignore", + 'label ".+" is unknown', + *extra, + ], + cwd=tmp, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + if proc.returncode not in (0, 1): + snap.gaps.append("actionlint") + return + # Runner labels are CI-05's job; actionlint's own label check is ignored above. + snap.actionlint = [line.strip()[:300] for line in proc.stdout.splitlines() if line.strip()] + + +def collect( + gh: GitHub, + ref: RepoRef, + org_visibility: dict[str, bool], + *, + run_days: int = 7, + max_runs: int = 20, + actionlint: str | None = None, + actionlint_config: Path | None = None, +) -> Snapshot: + snap = Snapshot( + repo=ref.repo, + full_name=ref.full_name, + private=ref.private, + default_branch=ref.default_branch, + org_visibility=org_visibility, + ) + steps: list[Callable[[], None]] = [ + lambda: _workflows(gh, ref, snap), + lambda: _branch_rules(gh, ref, snap), + lambda: _runtime(gh, ref, snap, run_days, max_runs), + ] + for step in steps: + try: + step() + except GitHubError as err: + snap.gaps.append("api") + print(json.dumps({"msg": "ci_audit_error", "repo": ref.repo, "error": str(err)}), file=sys.stderr) + run_actionlint(snap, actionlint, actionlint_config) + return snap + + +def main_status(gh: GitHub, ref: RepoRef, contexts: list[str]) -> dict[str, str]: + """Conclusion of each required context on the default-branch head.""" + out = dict.fromkeys(contexts, "missing") + if not contexts: + return out + runs = gh.get( + f"/repos/{ref.full_name}/commits/{ref.default_branch}/check-runs?per_page=100&filter=latest", + "check_runs", + optional=True, + ) + for run in (runs or {}).get("check_runs", []): + name = str(run.get("name", "")) + if name in out: + out[name] = str(run.get("conclusion") or run.get("status") or "pending") + statuses = gh.get(f"/repos/{ref.full_name}/commits/{ref.default_branch}/status", "status", optional=True) + for st in (statuses or {}).get("statuses", []): + if st.get("context") in out and out[st["context"]] == "missing": + out[str(st["context"])] = str(st.get("state")) + return out + + +def fetch_text(gh: GitHub, full_name: str, path: str, ref: str = "main") -> str | None: + body = gh.get( + f"/repos/{full_name}/contents/{path}?ref={ref}", + "contents", + optional=True, + accept="application/vnd.github.raw", + ) + return body if isinstance(body, str) else None + + +def token_from_env() -> str: + token = os.environ.get("GITHUB_TOKEN", "") + if not token: + raise SystemExit("GITHUB_TOKEN is required") + return token diff --git a/auditor/src/ci_auditor/identity.py b/auditor/src/ci_auditor/identity.py new file mode 100644 index 0000000..d30c529 --- /dev/null +++ b/auditor/src/ci_auditor/identity.py @@ -0,0 +1,66 @@ +"""Identity policy: commit author and committer, tracked paths and content. + +Never the commit message body. Bodies carry graft `Claimed-Author:` and +`Co-Authored-By:` trailers, transcripts and paths that workers do not +control; grepping them closed 8 batches in two weeks (CI review §3.7, §3.10). +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +def default_forbidden() -> list[str]: + # Built from parts so this file never matches itself. + return ["".join(("sp", "roo", "ty"))] + + +@dataclass(frozen=True) +class IdentityFinding: + where: str + message: str + + +def _git(root: Path, *args: str) -> str: + return subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, check=True).stdout + + +def commits(root: Path, base: str | None, head: str) -> list[str]: + if base and set(base) != {"0"}: + try: + return _git(root, "rev-list", "--reverse", f"{base}..{head}").split() + except subprocess.CalledProcessError: + pass + return _git(root, "rev-list", "--max-count=1", head).split() + + +def check( + root: Path, base: str | None, head: str, forbidden: list[str], content: bool = True +) -> list[IdentityFinding]: + needles = [f.lower() for f in forbidden if f] + found: list[IdentityFinding] = [] + for sha in commits(root, base, head): + # %an %ae %cn %ce only - deliberately not %B. + meta = _git(root, "show", "-s", "--format=%an%x00%ae%x00%cn%x00%ce", sha).strip().split("\x00") + for field, value in zip( + ("author name", "author email", "committer name", "committer email"), meta, strict=False + ): + if any(n in value.lower() for n in needles): + found.append(IdentityFinding(sha[:12], f"forbidden identifier in {field}")) + paths = _git(root, "ls-files", "-z").split("\x00") + for path in paths: + if path and any(n in path.lower() for n in needles): + found.append(IdentityFinding(path, "forbidden identifier in a tracked path")) + if content: + for needle in needles: + proc = subprocess.run( + ["git", "-C", str(root), "grep", "-Iil", "--", needle], + capture_output=True, + text=True, + check=False, + ) + for path in proc.stdout.split(): + found.append(IdentityFinding(path, "forbidden identifier in tracked content")) + return found diff --git a/auditor/src/ci_auditor/model.py b/auditor/src/ci_auditor/model.py new file mode 100644 index 0000000..2a701d1 --- /dev/null +++ b/auditor/src/ci_auditor/model.py @@ -0,0 +1,129 @@ +"""Plain data shared by the checks, the collector and the renderers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date +from typing import Any, Literal + +Severity = Literal["high", "medium", "low"] +Kind = Literal["static", "settings", "runtime"] +Status = Literal["pass", "fail", "exempt", "na", "unknown"] +STATUSES: tuple[Status, ...] = ("pass", "fail", "exempt", "na", "unknown") + + +@dataclass(frozen=True) +class RunnerClass: + name: str + forms: tuple[frozenset[str], ...] + public_only: bool = False + repos: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Rule: + id: str + area: str + title: str + statement: str + severity: Severity + kind: Kind + check: str + machine: str + fix: str + enforce: Literal["report", "block"] + retired: bool = False + + +@dataclass(frozen=True) +class Exemption: + repo: str + rule: str + reason: str + owner: str + created: date + expires: date + workflow: str | None = None + job: str | None = None + + def covers(self, finding: Finding) -> bool: + if self.repo != finding.repo or self.rule != finding.rule: + return False + if self.workflow is not None and self.workflow != finding.workflow: + return False + return self.job is None or self.job == finding.job + + +@dataclass(frozen=True) +class Finding: + repo: str + rule: str + message: str + workflow: str | None = None + job: str | None = None + + +@dataclass +class Result: + repo: str + rule: Rule + status: Status + findings: list[Finding] = field(default_factory=list) + exempted: list[tuple[Finding, Exemption]] = field(default_factory=list) + note: str = "" + + +@dataclass(frozen=True) +class Workflow: + """One workflow file: its name under .github/workflows and parsed body.""" + + file: str + doc: dict[str, Any] + text: str + + +@dataclass(frozen=True) +class RuntimeJob: + workflow_path: str + workflow_name: str + job: str + labels: tuple[str, ...] + conclusion: str + run_id: int + run_conclusion: str + event: str + head_branch: str + + +@dataclass(frozen=True) +class RuntimeRun: + workflow_path: str + event: str + head_branch: str + conclusion: str + created_at: str + + +@dataclass +class Snapshot: + """Everything the checks may read about one repository. + + ``None`` means "not collected / not visible" and makes a dependent rule + ``unknown`` rather than passing it. + """ + + repo: str + full_name: str + private: bool + default_branch: str = "main" + workflows: list[Workflow] = field(default_factory=list) + parse_errors: dict[str, str] = field(default_factory=dict) + tree: list[str] | None = None + required_contexts: list[str] | None = None + merge_queue: dict[str, Any] | None = None + merge_queue_known: bool = False + runtime_jobs: list[RuntimeJob] | None = None + runtime_runs: list[RuntimeRun] | None = None + actionlint: list[str] | None = None + org_visibility: dict[str, bool] | None = None # repo name -> private + gaps: list[str] = field(default_factory=list) diff --git a/auditor/src/ci_auditor/render.py b/auditor/src/ci_auditor/render.py new file mode 100644 index 0000000..59ff7b9 --- /dev/null +++ b/auditor/src/ci_auditor/render.py @@ -0,0 +1,140 @@ +"""Prometheus text and JSON-lines output.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable +from datetime import UTC, date, datetime, time + +from .model import STATUSES, Exemption, Result, Snapshot + + +def _esc(value: object) -> str: + return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ") + + +def _lbl(**labels: object) -> str: + return "{" + ",".join(f'{k}="{_esc(v)}"' for k, v in labels.items()) + "}" + + +def expiry_epoch(day: date) -> float: + """End of the expiry day, UTC: the exemption still holds on that day.""" + return datetime.combine(day, time(23, 59, 59), tzinfo=UTC).timestamp() + + +def metrics( + results: Iterable[Result], + snapshots: Iterable[Snapshot], + exemptions: Iterable[Exemption], + now: float, + last_run: float, + last_success: float, + duration: float, + api_requests: dict[tuple[str, int], int], + errors: int, +) -> str: + out: list[str] = [] + add = out.append + results = list(results) + add("# HELP ci_conformance 1 for the current status of each repo x rule (one-hot over status).") + add("# TYPE ci_conformance gauge") + for r in results: + for status in STATUSES: + add( + f"ci_conformance{_lbl(repo=r.repo, rule=r.rule.id, title=r.rule.title, area=r.rule.area, severity=r.rule.severity, enforce=r.rule.enforce, status=status)} {int(r.status == status)}" + ) + add("# HELP ci_conformance_findings Non-exempt findings per repo x rule.") + add("# TYPE ci_conformance_findings gauge") + for r in results: + add( + f"ci_conformance_findings{_lbl(repo=r.repo, rule=r.rule.id, severity=r.rule.severity)} {len(r.findings)}" + ) + add("# HELP ci_conformance_exempted_findings Findings suppressed by a live exemption.") + add("# TYPE ci_conformance_exempted_findings gauge") + for r in results: + if r.exempted: + add(f"ci_conformance_exempted_findings{_lbl(repo=r.repo, rule=r.rule.id)} {len(r.exempted)}") + add("# HELP ci_exemption_expiry_seconds Seconds until the exemption expires (negative: expired).") + add("# TYPE ci_exemption_expiry_seconds gauge") + for e in exemptions: + add( + f"ci_exemption_expiry_seconds{_lbl(repo=e.repo, rule=e.rule, workflow=e.workflow or '', job=e.job or '', owner=e.owner)} {expiry_epoch(e.expires) - now:.0f}" + ) + add("# HELP ci_repo_info Repositories audited.") + add("# TYPE ci_repo_info gauge") + for s in snapshots: + add( + f"ci_repo_info{_lbl(repo=s.repo, full_name=s.full_name, visibility='private' if s.private else 'public', merge_queue=('unknown' if not s.merge_queue_known else ('yes' if s.merge_queue else 'no')), workflows=len(s.workflows))} 1" + ) + for gap in s.gaps: + add(f"ci_audit_gap{_lbl(repo=s.repo, source=gap)} 1") + add("# TYPE ci_audit_last_run_timestamp gauge") + add(f"ci_audit_last_run_timestamp {last_run:.0f}") + add("# TYPE ci_audit_last_success_timestamp gauge") + add(f"ci_audit_last_success_timestamp {last_success:.0f}") + add("# TYPE ci_audit_duration_seconds gauge") + add(f"ci_audit_duration_seconds {duration:.1f}") + add("# TYPE ci_audit_errors_total counter") + add(f"ci_audit_errors_total {errors}") + add("# TYPE ci_audit_api_requests_total counter") + for (kind, code), n in sorted(api_requests.items()): + add(f"ci_audit_api_requests_total{_lbl(endpoint=kind, status=code)} {n}") + return "\n".join(out) + "\n" + + +def main_checks(statuses: dict[str, dict[str, str]]) -> str: + """ci_main_required_check{repo,context,conclusion} for the default-branch head.""" + out = [ + "# HELP ci_main_required_check 1 for the conclusion of each required context on the default-branch head.", + "# TYPE ci_main_required_check gauge", + ] + for repo, contexts in sorted(statuses.items()): + for ctx, conclusion in sorted(contexts.items()): + out.append(f"ci_main_required_check{_lbl(repo=repo, context=ctx, conclusion=conclusion)} 1") + return "\n".join(out) + "\n" + + +def finding_lines(results: Iterable[Result], ts: datetime) -> list[str]: + """One JSON object per finding (and per exempted finding), for Loki.""" + lines = [] + stamp = ts.isoformat(timespec="seconds") + for r in results: + base = { + "ts": stamp, + "msg": "ci_finding", + "repo": r.repo, + "rule": r.rule.id, + "title": r.rule.title, + "severity": r.rule.severity, + "enforce": r.rule.enforce, + } + for f in r.findings: + lines.append( + json.dumps( + { + **base, + "status": "fail", + "workflow": f.workflow, + "job": f.job, + "message": f.message, + "fix": r.rule.fix, + }, + sort_keys=True, + ) + ) + for f, e in r.exempted: + lines.append( + json.dumps( + { + **base, + "status": "exempt", + "workflow": f.workflow, + "job": f.job, + "message": f.message, + "exemption_owner": e.owner, + "exemption_expires": e.expires.isoformat(), + }, + sort_keys=True, + ) + ) + return lines diff --git a/auditor/src/ci_auditor/server.py b/auditor/src/ci_auditor/server.py new file mode 100644 index 0000000..a533271 --- /dev/null +++ b/auditor/src/ci_auditor/server.py @@ -0,0 +1,244 @@ +"""The long-running auditor: re-audits hourly, serves /metrics, /healthz, /findings. + +Why a long-running exporter and not a CronJob plus pushgateway: +* the estate has no pushgateway, and pushed series never go stale, so a dead + CronJob would keep showing the last conformance forever; +* scrape `up` and ci_audit_last_success_timestamp give a real liveness signal; +* the in-process ETag and blob caches make each hourly pass cheap (blobs and + completed-run jobs are fetched once), which a fresh CronJob pod would lose; +* it is the same shape as gha-exporter and github-org-monitor next to it. +""" + +from __future__ import annotations + +import json +import os +import sys +import threading +import time +from dataclasses import dataclass, field +from datetime import UTC, date, datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from . import render +from .evaluate import evaluate +from .github import GitHub, RepoRef, collect, fetch_text, list_repos, main_status, token_from_env +from .model import Exemption, Result, Snapshot +from .standard import Standard, StandardError, default_standard_dir, load, parse_exemptions + + +def log(**fields: object) -> None: + fields.setdefault("ts", datetime.now(UTC).isoformat(timespec="seconds")) + print(json.dumps(fields, sort_keys=True, default=str), flush=True) + + +@dataclass +class Auditor: + gh: GitHub + std: Standard + exemptions: list[Exemption] + org: str + extra_repos: list[str] + skip: set[str] + exemptions_repo: str + exemptions_ref: str + run_days: int = 7 + max_runs: int = 20 + actionlint: str | None = None + actionlint_config: Path | None = None + include_archived: bool = False + repos: list[RepoRef] = field(default_factory=list) + snapshots: list[Snapshot] = field(default_factory=list) + results: list[Result] = field(default_factory=list) + main: dict[str, dict[str, str]] = field(default_factory=dict) + exemptions_valid: bool = True + last_run: float = 0.0 + last_success: float = 0.0 + duration: float = 0.0 + errors: int = 0 + lock: threading.Lock = field(default_factory=threading.Lock) + + @classmethod + def from_env(cls) -> Auditor: + std_dir = ( + Path(os.environ["CI_STANDARD_DIR"]) + if os.environ.get("CI_STANDARD_DIR") + else default_standard_dir() + ) + std, bundled = load(std_dir) + env = os.environ.get + return cls( + gh=GitHub(token=token_from_env(), rate_reserve=int(env("RATE_RESERVE", "500"))), + std=std, + exemptions=bundled, + org=env("GITHUB_ORG", "TheDancingDeveloper-org"), + extra_repos=[x for x in env("EXTRA_REPOS", "thedancingdeveloper/fluent-gpui").split(",") if x], + skip={x for x in env("SKIP_REPOS", "").split(",") if x}, + exemptions_repo=env("EXEMPTIONS_REPO", "TheDancingDeveloper-org/github-policy"), + exemptions_ref=env("EXEMPTIONS_REF", "main"), + run_days=int(env("RUN_DAYS", "7")), + max_runs=int(env("MAX_RUNS_PER_REPO", "20")), + actionlint=env("ACTIONLINT") or None, + actionlint_config=std_dir / "actionlint.yaml", + include_archived=env("INCLUDE_ARCHIVED") == "1", + ) + + def refresh_exemptions(self) -> None: + """Live exemptions from github-policy main; keep the last good set on error.""" + text = fetch_text(self.gh, self.exemptions_repo, "ci-standard/exemptions.yaml", self.exemptions_ref) + if text is None: + log(msg="ci_exemptions_unavailable", repo=self.exemptions_repo) + return + try: + self.exemptions = parse_exemptions(text, self.std) + self.exemptions_valid = True + except StandardError as err: + self.exemptions_valid = False + log(msg="ci_exemptions_invalid", error=str(err)) + + def audit(self) -> None: + start = time.time() + self.last_run = start + try: + self.refresh_exemptions() + repos = [ + r + for r in list_repos(self.gh, self.org, self.extra_repos) + if (self.include_archived or not r.archived) and r.repo not in self.skip + ] + visibility = {r.repo: r.private for r in repos} + snapshots, results = [], [] + today = date.today() + for ref in repos: + snap = collect( + self.gh, + ref, + visibility, + run_days=self.run_days, + max_runs=self.max_runs, + actionlint=self.actionlint, + actionlint_config=self.actionlint_config, + ) + snapshots.append(snap) + results += evaluate(snap, self.std, self.exemptions, today) + stamp = datetime.now(UTC) + for line in render.finding_lines(results, stamp): + print(line, flush=True) + with self.lock: + self.repos, self.snapshots, self.results = repos, snapshots, results + self.last_success = time.time() + fails = sum(1 for r in results if r.status == "fail") + log( + msg="ci_audit_done", + repos=len(repos), + results=len(results), + failing=fails, + seconds=round(time.time() - start, 1), + ) + except Exception as err: # noqa: BLE001 - keep serving the last good audit + self.errors += 1 + log(msg="ci_audit_error", error=f"{type(err).__name__}: {err}") + finally: + self.duration = time.time() - start + + def refresh_main(self) -> None: + """Required contexts on each default-branch head (cheap; every 10 min).""" + with self.lock: + pairs = [ + (ref, snap.required_contexts or []) + for ref, snap in zip(self.repos, self.snapshots, strict=False) + ] + out: dict[str, dict[str, str]] = {} + for ref, contexts in pairs: + if not contexts: + continue + try: + out[ref.repo] = main_status(self.gh, ref, contexts) + except Exception as err: # noqa: BLE001 + self.errors += 1 + log(msg="ci_main_status_error", repo=ref.repo, error=str(err)) + with self.lock: + self.main = out + + def render(self) -> str: + with self.lock: + body = render.metrics( + self.results, + self.snapshots, + self.exemptions, + time.time(), + self.last_run, + self.last_success, + self.duration, + dict(self.gh.api_requests), + self.errors, + ) + body += render.main_checks(self.main) + body += f"# TYPE ci_exemptions_valid gauge\nci_exemptions_valid {int(self.exemptions_valid)}\n" + if self.gh.remaining is not None: + body += f"# TYPE ci_audit_ratelimit_remaining gauge\nci_audit_ratelimit_remaining {self.gh.remaining}\n" + return body + + def findings_json(self) -> str: + with self.lock: + return json.dumps( + [ + { + "repo": r.repo, + "rule": r.rule.id, + "status": r.status, + "note": r.note, + "findings": [f.__dict__ for f in r.findings], + } + for r in self.results + if r.status != "pass" + ], + indent=1, + ) + + +def serve(auditor: Auditor) -> None: + port = int(os.environ.get("METRICS_PORT", "9803")) + audit_every = int(os.environ.get("AUDIT_INTERVAL_SECONDS", "3600")) + main_every = int(os.environ.get("MAIN_INTERVAL_SECONDS", "600")) + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + path = self.path.split("?")[0] + if path == "/metrics": + body, ctype, code = auditor.render().encode(), "text/plain; version=0.0.4", 200 + elif path == "/findings": + body, ctype, code = auditor.findings_json().encode(), "application/json", 200 + elif path == "/healthz": + ok = auditor.last_success == 0 or time.time() - auditor.last_success < 3 * audit_every + body, ctype, code = (b"ok\n" if ok else b"stale\n"), "text/plain", 200 if ok else 503 + else: + body, ctype, code = b"not found\n", "text/plain", 404 + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args: object) -> None: + pass + + server = ThreadingHTTPServer(("0.0.0.0", port), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + log(msg="ci_auditor_serving", port=port, org=auditor.org, extra=auditor.extra_repos) + next_audit = next_main = 0.0 + while True: + now = time.time() + if now >= next_audit: + auditor.audit() + next_audit = time.time() + audit_every + next_main = 0.0 + if now >= next_main: + auditor.refresh_main() + next_main = time.time() + main_every + time.sleep(15) + + +if __name__ == "__main__": + sys.exit("use: python -m ci_auditor serve") diff --git a/auditor/src/ci_auditor/standard.py b/auditor/src/ci_auditor/standard.py new file mode 100644 index 0000000..642e552 --- /dev/null +++ b/auditor/src/ci_auditor/standard.py @@ -0,0 +1,205 @@ +"""Load and validate ci-standard/rules.yaml and ci-standard/exemptions.yaml.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Any, cast + +import yaml + +from .model import Exemption, Rule, RunnerClass + +RULE_ID = re.compile(r"^CI-\d{2}$") +REPO_NAME = re.compile(r"^[A-Za-z0-9_.-]+$") +WORKFLOW_FILE = re.compile(r"^[A-Za-z0-9_.-]+\.ya?ml$") +SEVERITIES = {"high", "medium", "low"} +KINDS = {"static", "settings", "runtime"} +RULE_FIELDS = {"id", "area", "title", "statement", "severity", "kind", "check", "machine", "fix", "enforce"} + + +class StandardError(ValueError): + """rules.yaml or exemptions.yaml does not match its schema.""" + + +@dataclass(frozen=True) +class Standard: + rules: tuple[Rule, ...] + classes: tuple[RunnerClass, ...] + required_contexts: tuple[str, ...] + max_exemption_days: int + + def rule(self, rule_id: str) -> Rule: + for rule in self.rules: + if rule.id == rule_id: + return rule + raise KeyError(rule_id) + + @property + def active(self) -> tuple[Rule, ...]: + return tuple(r for r in self.rules if not r.retired) + + +def default_standard_dir() -> Path: + """ci-standard/ next to the package (repo checkout) or the image copy.""" + for candidate in ( + Path(__file__).resolve().parents[3] / "ci-standard", + Path("/opt/ci-auditor/ci-standard"), + ): + if (candidate / "rules.yaml").exists(): + return candidate + raise StandardError("ci-standard/rules.yaml not found; pass --standard") + + +def _mapping(value: object, where: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise StandardError(f"{where}: expected a mapping") + return cast(dict[str, Any], value) + + +def parse_rules(text: str) -> Standard: + doc = _mapping(yaml.safe_load(text), "rules.yaml") + if doc.get("version") != 1: + raise StandardError("rules.yaml: version must be 1") + + classes: list[RunnerClass] = [] + for name, raw in _mapping(doc.get("runner_classes"), "runner_classes").items(): + body = _mapping(raw, f"runner_classes.{name}") + forms = body.get("forms") + if not isinstance(forms, list) or not forms: + raise StandardError(f"runner_classes.{name}: forms must be a non-empty list") + parsed = [] + for form in forms: + if not isinstance(form, list) or not form or not all(isinstance(x, str) for x in form): + raise StandardError(f"runner_classes.{name}: each form is a list of labels") + if form == ["self-hosted"]: + raise StandardError(f"runner_classes.{name}: bare self-hosted is not a class") + parsed.append(frozenset(form)) + classes.append( + RunnerClass( + name=str(name), + forms=tuple(parsed), + public_only=bool(body.get("public_only", False)), + repos=tuple(str(r) for r in body.get("repos", [])), + ) + ) + + rules: list[Rule] = [] + seen: set[str] = set() + for i, raw in enumerate(doc.get("rules") or []): + body = _mapping(raw, f"rules[{i}]") + missing = RULE_FIELDS - body.keys() + if missing: + raise StandardError(f"rules[{i}] ({body.get('id')}): missing {sorted(missing)}") + rid = str(body["id"]) + if not RULE_ID.match(rid) or rid in seen: + raise StandardError(f"rules[{i}]: bad or duplicate id {rid!r}") + seen.add(rid) + if body["severity"] not in SEVERITIES: + raise StandardError(f"{rid}: severity must be one of {sorted(SEVERITIES)}") + if body["kind"] not in KINDS: + raise StandardError(f"{rid}: kind must be one of {sorted(KINDS)}") + if body["enforce"] not in ("report", "block"): + raise StandardError(f"{rid}: enforce must be report or block") + if body["kind"] != "static" and body["enforce"] == "block": + raise StandardError(f"{rid}: only static rules can block (the gate cannot see settings or runs)") + rules.append( + Rule( + id=rid, + area=str(body["area"]), + title=str(body["title"]), + statement=" ".join(str(body["statement"]).split()), + severity=body["severity"], + kind=body["kind"], + check=str(body["check"]), + machine=" ".join(str(body["machine"]).split()), + fix=" ".join(str(body["fix"]).split()), + enforce=body["enforce"], + retired=bool(body.get("retired", False)), + ) + ) + if not rules: + raise StandardError("rules.yaml: no rules") + return Standard( + rules=tuple(rules), + classes=tuple(classes), + required_contexts=tuple(str(c) for c in doc.get("required_contexts", [])), + max_exemption_days=int(doc.get("max_exemption_days", 180)), + ) + + +def _date(value: object, where: str) -> date: + if isinstance(value, date): + return value + if isinstance(value, str): + try: + return date.fromisoformat(value) + except ValueError: + pass + raise StandardError(f"{where}: not a YYYY-MM-DD date") + + +def parse_exemptions(text: str, standard: Standard) -> list[Exemption]: + """Parse and validate. Raises on any malformed entry: a bad exemption file + must be loud, never silently suppress or silently drop.""" + doc = _mapping(yaml.safe_load(text) or {}, "exemptions.yaml") + if doc.get("version") != 1: + raise StandardError("exemptions.yaml: version must be 1") + known = {r.id for r in standard.rules} + out: list[Exemption] = [] + allowed = {"repo", "rule", "reason", "owner", "created", "expires", "workflow", "job"} + for i, raw in enumerate(doc.get("exemptions") or []): + body = _mapping(raw, f"exemptions[{i}]") + where = f"exemptions[{i}] ({body.get('repo')}/{body.get('rule')})" + extra = body.keys() - allowed + if extra: + raise StandardError(f"{where}: unknown keys {sorted(extra)}") + for key in ("repo", "rule", "reason", "owner", "created", "expires"): + if not body.get(key): + raise StandardError(f"{where}: {key} is required") + repo, rule = str(body["repo"]), str(body["rule"]) + if not REPO_NAME.match(repo): + raise StandardError(f"{where}: repo is a bare repository name") + if rule not in known: + raise StandardError(f"{where}: unknown rule {rule}") + reason = " ".join(str(body["reason"]).split()) + if len(reason) < 40: + raise StandardError(f"{where}: reason must say why and what ends it (>= 40 chars)") + workflow = body.get("workflow") + if workflow is not None and not WORKFLOW_FILE.match(str(workflow)): + raise StandardError(f"{where}: workflow is a file name like ci.yml") + job = body.get("job") + if job is not None and workflow is None: + raise StandardError(f"{where}: job needs workflow") + created = _date(body["created"], f"{where}.created") + expires = _date(body["expires"], f"{where}.expires") + if expires <= created: + raise StandardError(f"{where}: expires must be after created") + if (expires - created).days > standard.max_exemption_days: + raise StandardError(f"{where}: longer than {standard.max_exemption_days} days") + out.append( + Exemption( + repo=repo, + rule=rule, + reason=reason, + owner=str(body["owner"]), + created=created, + expires=expires, + workflow=str(workflow) if workflow is not None else None, + job=str(job) if job is not None else None, + ) + ) + return out + + +def load(standard_dir: Path) -> tuple[Standard, list[Exemption]]: + standard = parse_rules((standard_dir / "rules.yaml").read_text(encoding="utf-8")) + exemptions_path = standard_dir / "exemptions.yaml" + exemptions = ( + parse_exemptions(exemptions_path.read_text(encoding="utf-8"), standard) + if exemptions_path.exists() + else [] + ) + return standard, exemptions diff --git a/auditor/src/ci_auditor/workflow.py b/auditor/src/ci_auditor/workflow.py new file mode 100644 index 0000000..33f3319 --- /dev/null +++ b/auditor/src/ci_auditor/workflow.py @@ -0,0 +1,249 @@ +"""Read GitHub Actions workflow files: triggers, jobs, runs-on, producers.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Any, cast + +import yaml + +from .model import RunnerClass, Workflow + +EXPR = re.compile(r"\$\{\{\s*(.*?)\s*\}\}") +MATRIX_REF = re.compile(r"^\$\{\{\s*matrix\.([A-Za-z0-9_-]+)\s*\}\}$") +INPUT_JSON_REF = re.compile(r"^\$\{\{\s*fromJSON\(\s*inputs\.([A-Za-z0-9_-]+)\s*\)\s*\}\}$") +PR_EVENTS = ("pull_request", "pull_request_target") + + +def parse(file: str, text: str) -> Workflow: + loaded = yaml.safe_load(text) + if not isinstance(loaded, dict): + raise ValueError("not a mapping") + return Workflow(file=file, doc=cast(dict[str, Any], loaded), text=text) + + +def triggers(wf: Workflow) -> dict[str, Any]: + """`on:` normalised to {event: config-or-None}. PyYAML reads `on` as True.""" + doc = cast(dict[Any, Any], wf.doc) + raw = doc.get("on", doc.get(True)) + if isinstance(raw, str): + return {raw: None} + if isinstance(raw, list): + return {str(e): None for e in raw} + if isinstance(raw, dict): + return {str(k): v for k, v in raw.items()} + return {} + + +def str_list(value: object) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [str(v) for v in value] + return [] + + +def push_config(wf: Workflow) -> dict[str, Any] | None: + """The push trigger's config ({} for a bare `push:`), or None without push.""" + trig = triggers(wf) + if "push" not in trig: + return None + cfg = trig["push"] + return cast(dict[str, Any], cfg) if isinstance(cfg, dict) else {} + + +def pushes_branch(wf: Workflow, branch: str) -> bool: + cfg = push_config(wf) + if cfg is None: + return False + branches = str_list(cfg.get("branches")) + if not branches and not cfg.get("branches-ignore"): + # Only tags (or nothing at all, which means every branch). + return not cfg.get("tags") and not cfg.get("tags-ignore") + return not branches or any(b == branch or b in ("*", "**") for b in branches) + + +def is_ci(wf: Workflow) -> bool: + trig = triggers(wf) + return any(e in trig for e in (*PR_EVENTS, "merge_group")) + + +def path_filtered(wf: Workflow, events: tuple[str, ...]) -> list[str]: + """Events among `events` whose trigger config carries paths/paths-ignore.""" + out = [] + for event, cfg in triggers(wf).items(): + if event in events and isinstance(cfg, dict) and (cfg.get("paths") or cfg.get("paths-ignore")): + out.append(event) + return out + + +@dataclass(frozen=True) +class Job: + key: str + body: dict[str, Any] + workflow: Workflow + + @property + def name(self) -> str: + name = self.body.get("name") + return str(name) if isinstance(name, str) and name else self.key + + @property + def uses(self) -> str | None: + uses = self.body.get("uses") + return str(uses) if isinstance(uses, str) else None + + @property + def condition(self) -> str: + cond = self.body.get("if") + return str(cond) if cond is not None else "" + + def steps(self) -> list[dict[str, Any]]: + steps = self.body.get("steps") + return ( + [cast(dict[str, Any], s) for s in steps if isinstance(s, dict)] if isinstance(steps, list) else [] + ) + + def runs(self) -> str: + return "\n".join(str(s.get("run", "")) for s in self.steps()) + + def env(self) -> dict[str, Any]: + merged: dict[str, Any] = {} + for scope in (self.workflow.doc.get("env"), self.body.get("env")): + if isinstance(scope, dict): + merged.update(scope) + return merged + + +def jobs(wf: Workflow) -> Iterator[Job]: + body = wf.doc.get("jobs") + if not isinstance(body, dict): + return + for key, job in body.items(): + if isinstance(job, dict): + yield Job(key=str(key), body=cast(dict[str, Any], job), workflow=wf) + + +def runs_on_sets(job: Job) -> list[frozenset[str]] | None: + """The label sets a job can ask for. None if unresolvable (an expression + that is not a literal-matrix lookup).""" + raw = job.body.get("runs-on") + if isinstance(raw, dict): # {group: ..., labels: ...} + raw = raw.get("labels") + if raw is None: + return None + if isinstance(raw, str): + match = MATRIX_REF.match(raw.strip()) + if match: + return _matrix_values(job, match.group(1)) + match = INPUT_JSON_REF.match(raw.strip()) + if match: + return _input_default(job, match.group(1)) + if EXPR.search(raw): + return None + return [frozenset([raw])] + if isinstance(raw, list): + labels = [str(x) for x in raw] + if any(EXPR.search(x) for x in labels): + return None + return [frozenset(labels)] + return None + + +def _input_default(job: Job, name: str) -> list[frozenset[str]] | None: + """A reusable workflow's `runs-on: ${{ fromJSON(inputs.x) }}` is judged by + the input's default; what callers pass is seen at runtime (CI-07).""" + call = triggers(job.workflow).get("workflow_call") + inputs = call.get("inputs") if isinstance(call, dict) else None + spec = inputs.get(name) if isinstance(inputs, dict) else None + default = spec.get("default") if isinstance(spec, dict) else None + if not isinstance(default, str): + return None + try: + value = json.loads(default) + except ValueError: + return None + if isinstance(value, str): + return [frozenset([value])] + if isinstance(value, list) and all(isinstance(x, str) for x in value): + return [frozenset(value)] + return None + + +def _matrix_values(job: Job, key: str) -> list[frozenset[str]] | None: + strategy = job.body.get("strategy") + matrix = strategy.get("matrix") if isinstance(strategy, dict) else None + if not isinstance(matrix, dict): + return None + values: list[Any] = [] + if isinstance(matrix.get(key), list): + values += matrix[key] + for extra in matrix.get("include") or []: + if isinstance(extra, dict) and key in extra: + values.append(extra[key]) + if not values: + return None + out = [] + for value in values: + if isinstance(value, str) and not EXPR.search(value): + out.append(frozenset([value])) + elif isinstance(value, list) and all(isinstance(x, str) for x in value): + out.append(frozenset(value)) + else: + return None + return out + + +def classify(labels: frozenset[str], classes: tuple[RunnerClass, ...]) -> RunnerClass | None: + for cls in classes: + if labels in cls.forms: + return cls + return None + + +def excludes_event(cond: str, event: str) -> bool: + """Heuristic: does a job `if:` keep the job off `event`? + + True when the condition says `event_name != ''`, pins event_name to + other events only (`== 'push'`, `== 'merge_group' || == 'push'`), or pins + the ref to main or a tag (a PR or queue run never has those refs). + """ + c = cond.replace('"', "'") + if not c: + return False + if re.search(rf"event_name\s*!=\s*'{event}'", c): + return True + positives = re.findall(r"event_name\s*==\s*'([a-z_]+)'", c) + if positives and event not in positives: + return True + pinned_ref = ( + re.search(r"github\.ref\s*==\s*'refs/(heads/main|tags/)", c) + or re.search(r"github\.ref_name\s*==\s*'main'", c) + or "startsWith(github.ref, 'refs/tags/" in c + ) + return bool(pinned_ref) and "||" not in c + + +def excludes_events(cond: str, events: tuple[str, ...]) -> bool: + return all(excludes_event(cond, e) for e in events) + + +def can_run_on(job: Job, events: tuple[str, ...]) -> bool: + trig = triggers(job.workflow) + present = tuple(e for e in events if e in trig) + return bool(present) and not excludes_events(job.condition, present) + + +def produces(job: Job, context: str) -> bool: + """Does this job produce the check-run named `context`?""" + name = job.name + if context in (name, job.key): + return True + if context.startswith(f"{name} (") or context.startswith(f"{job.key} ("): + return True # matrix leg + return job.uses is not None and (context.startswith(f"{name} / ") or context.startswith(f"{job.key} / ")) diff --git a/auditor/tests/test_checks.py b/auditor/tests/test_checks.py new file mode 100644 index 0000000..40a23fd --- /dev/null +++ b/auditor/tests/test_checks.py @@ -0,0 +1,443 @@ +"""Each rule's check against a passing and a failing workflow.""" + +from __future__ import annotations + +import textwrap +from datetime import date +from pathlib import Path + +import pytest + +from ci_auditor import workflow as wfm +from ci_auditor.checks import CHECKS +from ci_auditor.evaluate import evaluate +from ci_auditor.model import Exemption, RuntimeJob, RuntimeRun, Snapshot +from ci_auditor.standard import Standard, load + +STD_DIR = Path(__file__).resolve().parents[2] / "ci-standard" + + +@pytest.fixture(scope="module") +def std() -> Standard: + return load(STD_DIR)[0] + + +GOOD = """ +name: CI +on: + pull_request: + merge_group: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'merge_group' }} +jobs: + lint: + runs-on: ci-small + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - run: cargo clippy + e2e: + if: github.event_name == 'merge_group' + runs-on: [self-hosted, docker] + timeout-minutes: 40 + steps: + - run: docker build . + ci: + if: always() + needs: [lint, e2e] + runs-on: ci-small + timeout-minutes: 5 + steps: + - run: echo gate + policy: + uses: TheDancingDeveloper-org/github-policy/.github/workflows/policy.yml@v1.0.0 + with: {ref: v1.0.0} +""" + + +def snap(*files: tuple[str, str], private: bool = True, **kw: object) -> Snapshot: + s = Snapshot(repo="demo", full_name="TheDancingDeveloper-org/demo", private=private) + for name, text in files: + s.workflows.append(wfm.parse(name, textwrap.dedent(text))) + for k, v in kw.items(): + setattr(s, k, v) + return s + + +def run(std: Standard, rule: str, s: Snapshot) -> tuple[str, list[str]]: + r = std.rule(rule) + out = CHECKS[r.check](s, std, r.id) + return out.status, [f.message for f in out.findings] + + +def test_every_rule_has_a_check_and_back(std: Standard) -> None: + assert {r.check for r in std.rules} == set(CHECKS) + assert [r.id for r in std.rules] == [f"CI-{i:02d}" for i in range(1, len(std.rules) + 1)] + + +def test_good_workflow_passes_every_static_rule(std: Standard) -> None: + s = snap(("ci.yml", GOOD), tree=[".github/workflows/ci.yml"]) + s.actionlint = [] + results = {r.rule.id: r for r in evaluate(s, std, [], date(2026, 11, 2), kinds=("static",))} + failing = {k: [f.message for f in r.findings] for k, r in results.items() if r.status == "fail"} + assert failing == {} + + +def test_push_everywhere_fails(std: Standard) -> None: + bad = GOOD.replace("on:\n pull_request:", "on:\n push:\n branches: ['**']\n pull_request:") + status, msgs = run(std, "CI-01", snap(("ci.yml", bad))) + assert status == "fail" and "['**']" in msgs[0] + bare = GOOD.replace("on:\n pull_request:", "on:\n push:\n pull_request:") + assert run(std, "CI-01", snap(("ci.yml", bare)))[0] == "fail" + + +def test_main_retest(std: Standard) -> None: + bad = GOOD.replace("on:\n pull_request:", "on:\n push:\n branches: [main]\n pull_request:") + status, msgs = run(std, "CI-02", snap(("ci.yml", bad))) + assert status == "fail" and "lint" in msgs[0] + publish_only = """ + on: {push: {branches: [main]}, pull_request: {}} + jobs: + image: + if: github.event_name == 'push' + runs-on: [self-hosted, publish] + timeout-minutes: 30 + steps: [{run: docker push x}] + """ + assert run(std, "CI-02", snap(("build.yml", publish_only)))[0] == "pass" + + +def test_merge_group_vs_queue(std: Standard) -> None: + s = snap(("ci.yml", GOOD), merge_queue_known=True, merge_queue=None, required_contexts=["ci"]) + assert run(std, "CI-03", s)[0] == "fail" + s.merge_queue = {"merge_method": "REBASE"} + assert run(std, "CI-03", s)[0] == "pass" + assert run(std, "CI-03", snap(("ci.yml", GOOD)))[0] == "unknown" + + +def test_heavy_on_pr(std: Standard) -> None: + bad = GOOD.replace(" if: github.event_name == 'merge_group'\n", "") + status, msgs = run(std, "CI-04", snap(("ci.yml", bad))) + assert status == "fail" and "docker" in msgs[0] + + +@pytest.mark.parametrize( + "runs_on,private,ok", + [ + ("ci-small", True, True), + ("[self-hosted]", True, False), + ("[self-hosted, linux, x64]", True, False), + ("[self-hosted, docker, publish]", True, True), + ("ubuntu-24.04", False, True), + ("ubuntu-24.04", True, False), + ("ubuntu-latest", False, False), + ("[self-hosted, graft-dev]", True, False), + ("${{ inputs.runner }}", True, False), + ], +) +def test_runner_classes(std: Standard, runs_on: str, private: bool, ok: bool) -> None: + wf = f""" + on: workflow_dispatch + jobs: + j: + runs-on: {runs_on} + timeout-minutes: 5 + steps: [{{run: 'true'}}] + """ + status, _ = run(std, "CI-05", snap(("w.yml", wf), private=private)) + assert (status == "pass") is ok + + +def test_runner_matrix_resolves(std: Standard) -> None: + wf = """ + on: pull_request + jobs: + t: + strategy: {matrix: {os: [ubuntu-24.04, windows-2025]}} + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: [{run: 'true'}] + """ + assert run(std, "CI-05", snap(("w.yml", wf), private=False))[0] == "pass" + assert run(std, "CI-05", snap(("w.yml", wf), private=True))[0] == "fail" + + +def test_publish_scope(std: Standard) -> None: + wf = """ + on: [pull_request, push] + jobs: + e2e: + runs-on: [self-hosted, docker, publish] + timeout-minutes: 5 + steps: [{run: 'true'}] + """ + status, msgs = run(std, "CI-06", snap(("e2e.yml", wf))) + assert status == "fail" and "pull_request" in msgs[0] + + +def test_runtime_labels(std: Standard) -> None: + jobs = [ + RuntimeJob( + ".github/workflows/ci.yml", + "CI", + "lint", + ("self-hosted",), + "success", + 1, + "success", + "pull_request", + "graft/batch-1", + ), + RuntimeJob( + ".github/workflows/ci.yml", + "CI", + "fmt", + ("ci-small",), + "success", + 1, + "success", + "pull_request", + "graft/batch-1", + ), + ] + status, msgs = run(std, "CI-07", snap(("ci.yml", GOOD), runtime_jobs=jobs)) + assert status == "fail" and msgs == ["1 recent job(s) ran with labels ['self-hosted']"] + assert run(std, "CI-07", snap(("ci.yml", GOOD)))[0] == "unknown" + + +def test_gate_shape(std: Standard) -> None: + assert run(std, "CI-08", snap(("ci.yml", GOOD), required_contexts=["ci", "policy"]))[0] == "pass" + status, msgs = run(std, "CI-08", snap(("ci.yml", GOOD), required_contexts=["rust", "policy"])) + assert status == "fail" and any("`ci`" in m for m in msgs) and any("rust" in m for m in msgs) + no_always = GOOD.replace(" if: always()\n", "") + status, msgs = run(std, "CI-08", snap(("ci.yml", no_always), required_contexts=["ci"])) + assert status == "fail" and "always()" in msgs[0] + + +def test_gate_producers(std: Standard) -> None: + filtered = GOOD.replace(" pull_request:\n", " pull_request:\n paths: ['src/**']\n") + status, msgs = run(std, "CI-09", snap(("ci.yml", filtered), required_contexts=["ci"])) + assert status == "fail" and "path-filtered" in msgs[0] + status, msgs = run(std, "CI-09", snap(("ci.yml", GOOD), required_contexts=["build"])) + assert status == "fail" and "no producing job" in msgs[0] + reusable = snap(("ci.yml", GOOD), required_contexts=["policy / policy"]) + assert run(std, "CI-09", reusable)[0] == "pass" + + +def test_merge_queue(std: Standard) -> None: + s = snap( + ("ci.yml", GOOD), + merge_queue_known=True, + required_contexts=["ci"], + merge_queue={"merge_method": "SQUASH", "grouping_strategy": "ALLGREEN"}, + ) + status, msgs = run(std, "CI-10", s) + assert status == "fail" and "SQUASH" in msgs[0] + public = snap( + ("ci.yml", GOOD), private=False, merge_queue_known=True, merge_queue=None, required_contexts=["ci"] + ) + assert run(std, "CI-10", public)[0] == "fail" + private = snap( + ("ci.yml", GOOD), private=True, merge_queue_known=True, merge_queue=None, required_contexts=["ci"] + ) + assert run(std, "CI-10", private)[0] == "na" + + +def test_timeouts_concurrency_continue_on_error(std: Standard) -> None: + bad = """ + on: {pull_request: {}, merge_group: {}} + concurrency: {group: x, cancel-in-progress: true} + jobs: + a: + runs-on: ci-small + continue-on-error: true + steps: [{run: 'true'}] + """ + s = snap(("ci.yml", bad)) + assert run(std, "CI-11", s) == ("fail", ["no timeout-minutes"]) + assert run(std, "CI-12", s)[1] == ["cancel-in-progress: true also cancels merge-queue runs"] + assert run(std, "CI-13", s)[0] == "fail" + + +def test_masked_failures(std: Standard) -> None: + jobs = [ + RuntimeJob( + ".github/workflows/ci.yml", + "CI", + "lint", + ("ci-small",), + "failure", + 7, + "success", + "merge_group", + "gh-readonly-queue/main/x", + ) + ] + s = snap(("ci.yml", GOOD), runtime_jobs=jobs, required_contexts=["ci"]) + assert run(std, "CI-14", s)[0] == "fail" + + +def test_policy_gate(std: Standard) -> None: + assert run(std, "CI-15", snap(("ci.yml", GOOD)))[0] == "pass" + at_main = GOOD.replace("policy.yml@v1.0.0", "policy.yml@main") + assert "not a tag or SHA" in run(std, "CI-15", snap(("ci.yml", at_main)))[1][0] + + +def test_identity_scope(std: Standard) -> None: + s = snap(("ci.yml", GOOD), tree=["ci/migration-policy.sh"]) + assert run(std, "CI-16", s)[0] == "fail" + grep_body = """ + on: pull_request + jobs: + p: + runs-on: ci-small + timeout-minutes: 5 + steps: [{run: "git show -s --format=%B HEAD | grep -i name"}] + """ + assert run(std, "CI-16", snap(("p.yml", grep_body), tree=[]))[0] == "fail" + + +def test_actionlint(std: Standard) -> None: + s = snap(("ci.yml", GOOD), actionlint=[".github/workflows/ci.yml:3:1: unexpected key [syntax-check]"]) + status, _ = run(std, "CI-17", s) + assert status == "fail" + assert run(std, "CI-17", snap(("ci.yml", GOOD)))[0] == "unknown" + + +def test_pinned_actions(std: Standard) -> None: + unpinned = GOOD.replace( + "Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6", "Swatinem/rust-cache@v2" + ) + status, msgs = run(std, "CI-18", snap(("ci.yml", unpinned))) + assert status == "fail" and "rust-cache@v2" in msgs[0] + + +def test_rust_cache_and_sccache(std: Standard) -> None: + always = GOOD.replace("save-if: ${{ github.ref == 'refs/heads/main' }}", "shared-key: x") + assert run(std, "CI-19", snap(("ci.yml", always)))[0] == "fail" + scc = """ + on: pull_request + env: {RUSTC_WRAPPER: sccache} + jobs: + b: + runs-on: [self-hosted, rust-heavy] + timeout-minutes: 30 + steps: [{run: cargo build}] + """ + assert run(std, "CI-20", snap(("b.yml", scc)))[0] == "fail" + ok = scc.replace("RUSTC_WRAPPER: sccache", "RUSTC_WRAPPER: sccache, SCCACHE_IGNORE_SERVER_IO_ERROR: '1'") + assert run(std, "CI-20", snap(("b.yml", ok)))[0] == "pass" + assert run(std, "CI-20", snap(("ci.yml", GOOD)))[0] == "na" + + +RELEASE = """ +on: {push: {tags: ['v*']}} +jobs: + build: {runs-on: [self-hosted, publish], timeout-minutes: 60, steps: [{run: make}]} +""" +DRY = """ +name: Release dry run +on: {schedule: [{cron: '0 3 * * *'}], pull_request: {paths: ['release/**']}} +jobs: + dry: {runs-on: [self-hosted, docker], timeout-minutes: 60, steps: [{run: make}]} +""" + + +def test_release_dry_run(std: Standard) -> None: + assert run(std, "CI-21", snap(("ci.yml", GOOD)))[0] == "na" + assert run(std, "CI-21", snap(("release.yml", RELEASE)))[0] == "fail" + assert run(std, "CI-21", snap(("release.yml", RELEASE), ("release-dry-run.yml", DRY)))[0] == "pass" + + +def test_dry_run_green(std: Standard) -> None: + runs = [ + RuntimeRun(".github/workflows/release-dry-run.yml", "schedule", "main", c, f"2026-11-0{i}T03:00:00Z") + for i, c in enumerate(["failure", "failure", "failure", "success"], start=1) + ] + s = snap(("release.yml", RELEASE), ("release-dry-run.yml", DRY), runtime_runs=runs) + status, msgs = run(std, "CI-22", s) + assert status == "fail" and "3 nightly" in msgs[0] + s.runtime_runs = runs[-1:] + assert run(std, "CI-22", s)[0] == "pass" + + +def test_private_deps(std: Standard) -> None: + wf = """ + on: pull_request + jobs: + b: + runs-on: [self-hosted, docker] + timeout-minutes: 30 + steps: + - run: git clone https://github.com/TheDancingDeveloper-org/msp-klaudia.git + - run: cargo install --git https://repo.indexarr.net/indexarr/tool + - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 + with: {build-args: "TOKEN=${{ secrets.T }}"} + """ + s = snap(("b.yml", wf), org_visibility={"msp-klaudia": True}) + status, msgs = run(std, "CI-23", s) + assert status == "fail" and len(msgs) == 3 + s.org_visibility = {"msp-klaudia": False} + assert len(run(std, "CI-23", s)[1]) == 2 + + +def test_codeql(std: Standard) -> None: + wf = """ + on: {pull_request: {}, push: {tags: ['v*']}} + jobs: + analyze: {runs-on: [self-hosted, bigmem], timeout-minutes: 60, steps: [{uses: github/codeql-action/init@v3}]} + """ + assert run(std, "CI-24", snap(("codeql.yml", wf)))[1] == ["CodeQL runs on ['pull_request']"] + ok = wf.replace("pull_request: {}, ", "workflow_dispatch: {}, ") + assert run(std, "CI-24", snap(("codeql.yml", ok)))[0] == "pass" + + +def test_exemption_suppresses_until_expiry(std: Standard) -> None: + bad = GOOD.replace( + "runs-on: ci-small\n timeout-minutes: 10", "runs-on: [self-hosted]\n timeout-minutes: 10" + ) + s = snap(("ci.yml", bad)) + ex = Exemption( + repo="demo", + rule="CI-05", + reason="x" * 40, + owner="oversight", + created=date(2026, 11, 1), + expires=date(2026, 11, 10), + workflow="ci.yml", + job="lint", + ) + on_day = {r.rule.id: r for r in evaluate(s, std, [ex], date(2026, 11, 10), kinds=("static",))} + assert on_day["CI-05"].status == "exempt" and len(on_day["CI-05"].exempted) == 1 + after = {r.rule.id: r for r in evaluate(s, std, [ex], date(2026, 11, 11), kinds=("static",))} + assert after["CI-05"].status == "fail" + other_job = Exemption(**{**ex.__dict__, "job": "e2e"}) + scoped = {r.rule.id: r for r in evaluate(s, std, [other_job], date(2026, 11, 5), kinds=("static",))} + assert scoped["CI-05"].status == "fail" + + +def test_policy_ref_must_repeat_uses(std: Standard) -> None: + drift = GOOD.replace("with: {ref: v1.0.0}", "with: {ref: v0.9.0}") + status, msgs = run(std, "CI-15", snap(("ci.yml", drift))) + assert status == "fail" and "must repeat" in msgs[0] + + +def test_reusable_runs_on_input_default(std: Standard) -> None: + wf = """ + on: + workflow_call: + inputs: + runs-on: {type: string, default: '["self-hosted", "rust-heavy"]'} + jobs: + r: + runs-on: ${{ fromJSON(inputs.runs-on) }} + timeout-minutes: 30 + steps: [{run: cargo test}] + """ + assert run(std, "CI-05", snap(("rust-ci.yml", wf)))[0] == "pass" + bare = wf.replace('["self-hosted", "rust-heavy"]', '["self-hosted"]') + assert run(std, "CI-05", snap(("rust-ci.yml", bare)))[0] == "fail" diff --git a/auditor/tests/test_standard_and_render.py b/auditor/tests/test_standard_and_render.py new file mode 100644 index 0000000..465031b --- /dev/null +++ b/auditor/tests/test_standard_and_render.py @@ -0,0 +1,206 @@ +"""rules.yaml / exemptions.yaml validation, rendering, identity and the lint CLI.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from datetime import UTC, date, datetime +from pathlib import Path + +import pytest + +from ci_auditor import render +from ci_auditor.__main__ import main +from ci_auditor.evaluate import evaluate +from ci_auditor.identity import check as identity_check +from ci_auditor.model import Exemption, Snapshot +from ci_auditor.standard import StandardError, load, parse_exemptions, parse_rules + +ROOT = Path(__file__).resolve().parents[2] +STD_DIR = ROOT / "ci-standard" + + +def test_shipped_standard_is_valid() -> None: + std, exemptions = load(STD_DIR) + assert len(std.rules) >= 24 + assert {"ci-small", "rust-heavy", "docker", "publish", "windows", "hosted"} <= { + c.name for c in std.classes + } + assert all(e.rule in {r.id for r in std.rules} for e in exemptions) + assert main(["validate", "--standard", str(STD_DIR), "--today", "2026-11-02"]) == 0 + + +def test_only_static_rules_block() -> None: + std, _ = load(STD_DIR) + assert all(r.kind == "static" for r in std.rules if r.enforce == "block") + + +def test_bare_self_hosted_class_rejected() -> None: + text = (STD_DIR / "rules.yaml").read_text().replace("forms: [[ci-small]]", "forms: [[self-hosted]]") + with pytest.raises(StandardError, match="bare self-hosted"): + parse_rules(text) + + +GOOD_EX = """ +version: 1 +exemptions: + - {repo: demo, rule: CI-05, reason: "a reason that is long enough to say why and what ends it", + owner: oversight, created: 2026-11-01, expires: 2026-12-01} +""" + + +@pytest.mark.parametrize( + "change,match", + [ + (("CI-05", "CI-99"), "unknown rule"), + (("CI-05", "CI-*"), "unknown rule"), + (("expires: 2026-12-01", "expires: 2027-12-01"), "longer than"), + (("expires: 2026-12-01", "expires: 2026-10-01"), "after created"), + (("owner: oversight, ", ""), "owner is required"), + (('reason: "a reason that is long enough to say why and what ends it"', "reason: short"), ">= 40"), + (("owner: oversight,", "owner: oversight, job: lint,"), "job needs workflow"), + (("repo: demo", "repo: org/demo"), "bare repository"), + ], +) +def test_bad_exemptions_are_loud(change: tuple[str, str], match: str) -> None: + std, _ = load(STD_DIR) + assert len(parse_exemptions(GOOD_EX, std)) == 1 + with pytest.raises(StandardError, match=match): + parse_exemptions(GOOD_EX.replace(*change), std) + + +def test_metrics_render() -> None: + std, _ = load(STD_DIR) + snap = Snapshot(repo="demo", full_name="o/demo", private=True) + results = evaluate(snap, std, [], date(2026, 11, 2), kinds=("static",)) + ex = Exemption("demo", "CI-05", "r" * 40, "oversight", date(2026, 11, 1), date(2026, 11, 2)) + now = datetime(2026, 11, 2, 0, 0, 0, tzinfo=UTC).timestamp() + text = render.metrics(results, [snap], [ex], now, now, now, 1.5, {("repos", 200): 3}, 0) + assert 'ci_conformance{repo="demo",rule="CI-01"' in text + assert text.count('rule="CI-01",title=') == 5 # one-hot over five statuses + assert ( + 'ci_exemption_expiry_seconds{repo="demo",rule="CI-05",workflow="",job="",owner="oversight"} 86399' + in text + ) + assert "ci_audit_last_run_timestamp" in text and 'visibility="private"' in text + assert ( + render.main_checks({"demo": {"ci": "failure"}}).splitlines()[-1] + == 'ci_main_required_check{repo="demo",context="ci",conclusion="failure"} 1' + ) + + +def test_finding_lines_are_json() -> None: + std, _ = load(STD_DIR) + from ci_auditor import workflow as wfm + + snap = Snapshot(repo="demo", full_name="o/demo", private=True) + snap.workflows.append( + wfm.parse( + "w.yml", "on: pull_request\njobs:\n a:\n runs-on: [self-hosted]\n steps: [{run: 'true'}]\n" + ) + ) + lines = render.finding_lines( + evaluate(snap, std, [], date(2026, 11, 2), kinds=("static",)), datetime(2026, 11, 2, tzinfo=UTC) + ) + parsed = [json.loads(line) for line in lines] + assert {"CI-05", "CI-11", "CI-12"} <= {p["rule"] for p in parsed} + assert all(p["msg"] == "ci_finding" and p["repo"] == "demo" for p in parsed) + + +def _git(root: Path, *args: str, env: dict[str, str] | None = None) -> None: + subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True, env=env) + + +def _repo(tmp_path: Path) -> Path: + _git(tmp_path, "init", "-q", "-b", "main") + _git(tmp_path, "config", "user.name", "worker") + _git(tmp_path, "config", "user.email", "worker@example.com") + (tmp_path / "a.txt").write_text("hello\n") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-q", "-m", "base") + return tmp_path + + +def test_identity_ignores_commit_bodies(tmp_path: Path) -> None: + root = _repo(tmp_path) + (root / "b.txt").write_text("more\n") + _git(root, "add", ".") + _git( + root, "commit", "-q", "-m", "change\n\nClaimed-Author: Forbidden Person " + ) + assert identity_check(root, "HEAD~1", "HEAD", ["forbiddenperson"]) == [] + + +def test_identity_flags_author_path_and_content(tmp_path: Path) -> None: + root = _repo(tmp_path) + (root / "forbiddenperson.md").write_text("ok\n") + (root / "c.txt").write_text("contact forbiddenperson here\n") + _git(root, "add", ".") + _git(root, "-c", "user.email=forbiddenperson@example.com", "commit", "-q", "-m", "x") + found = {f.message for f in identity_check(root, "HEAD~1", "HEAD", ["forbiddenperson"])} + assert found == { + "forbidden identifier in author email", + "forbidden identifier in committer email", + "forbidden identifier in a tracked path", + "forbidden identifier in tracked content", + } + no_content = { + f.message for f in identity_check(root, "HEAD~1", "HEAD", ["forbiddenperson"], content=False) + } + assert "forbidden identifier in tracked content" not in no_content + + +def test_lint_cli_report_vs_enforce(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + wdir = tmp_path / ".github" / "workflows" + wdir.mkdir(parents=True) + (wdir / "ci.yml").write_text( + "on: pull_request\njobs:\n a:\n runs-on: [self-hosted]\n steps: [{run: 'true'}]\n" + ) + base = [ + "lint", + str(tmp_path), + "--repo", + "demo", + "--standard", + str(STD_DIR), + "--today", + "2026-11-02", + "--format", + "github", + ] + assert main(base) == 0 + out = capsys.readouterr().out + assert "::warning file=.github/workflows/ci.yml,title=CI-05" in out + assert main([*base, "--enforce"]) == 1 + assert "::error file=.github/workflows/ci.yml,title=CI-05" in capsys.readouterr().out + + +def test_actionlint_config_lists_every_class_label() -> None: + import yaml + + std, _ = load(STD_DIR) + custom = {label for c in std.classes if not c.public_only for form in c.forms for label in form} - { + "self-hosted" + } + config = yaml.safe_load((STD_DIR / "actionlint.yaml").read_text()) + assert set(config["self-hosted-runner"]["labels"]) == custom + + +@pytest.mark.skipif(shutil.which("actionlint") is None, reason="actionlint not installed") +def test_run_actionlint_with_relative_config(monkeypatch: pytest.MonkeyPatch) -> None: + from ci_auditor import workflow as wfm + from ci_auditor.github import run_actionlint + + monkeypatch.chdir(ROOT) + snap = Snapshot(repo="demo", full_name="o/demo", private=True) + snap.workflows.append( + wfm.parse( + "w.yml", + "on: push\njobs:\n a:\n runs-on: ci-small\n steps:\n" + " - run: echo ${{ github.nope }}\n", + ) + ) + run_actionlint(snap, None, Path("ci-standard/actionlint.yaml")) + assert snap.gaps == [] + assert snap.actionlint and "nope" in snap.actionlint[0] diff --git a/auditor/uv.lock b/auditor/uv.lock new file mode 100644 index 0000000..10aa4dd --- /dev/null +++ b/auditor/uv.lock @@ -0,0 +1,231 @@ +version = 1 +revision = 5 +requires-python = ">=3.12" + +[[package]] +name = "ci-auditor" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml", specifier = "==6.0.3" }] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = "==1.18.2" }, + { name = "pytest", specifier = "==8.4.2" }, + { name = "ruff", specifier = "==0.14.0" }, + { name = "types-pyyaml", specifier = "==6.0.12.20250915" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/b9/9bd84453ed6dd04688de9b3f3a4146a1698e8faae2ceeccce4e14c67ae17/ruff-0.14.0.tar.gz", hash = "sha256:62ec8969b7510f77945df916de15da55311fade8d6050995ff7f680afe582c57", size = 5452071, upload-time = "2025-10-07T18:21:55.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/4e/79d463a5f80654e93fa653ebfb98e0becc3f0e7cf6219c9ddedf1e197072/ruff-0.14.0-py3-none-linux_armv6l.whl", hash = "sha256:58e15bffa7054299becf4bab8a1187062c6f8cafbe9f6e39e0d5aface455d6b3", size = 12494532, upload-time = "2025-10-07T18:21:00.373Z" }, + { url = "https://files.pythonhosted.org/packages/ee/40/e2392f445ed8e02aa6105d49db4bfff01957379064c30f4811c3bf38aece/ruff-0.14.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:838d1b065f4df676b7c9957992f2304e41ead7a50a568185efd404297d5701e8", size = 13160768, upload-time = "2025-10-07T18:21:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/75/da/2a656ea7c6b9bd14c7209918268dd40e1e6cea65f4bb9880eaaa43b055cd/ruff-0.14.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:703799d059ba50f745605b04638fa7e9682cc3da084b2092feee63500ff3d9b8", size = 12363376, upload-time = "2025-10-07T18:21:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/1ffef5a1875add82416ff388fcb7ea8b22a53be67a638487937aea81af27/ruff-0.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ba9a8925e90f861502f7d974cc60e18ca29c72bb0ee8bfeabb6ade35a3abde7", size = 12608055, upload-time = "2025-10-07T18:21:10.72Z" }, + { url = "https://files.pythonhosted.org/packages/4a/32/986725199d7cee510d9f1dfdf95bf1efc5fa9dd714d0d85c1fb1f6be3bc3/ruff-0.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e41f785498bd200ffc276eb9e1570c019c1d907b07cfb081092c8ad51975bbe7", size = 12318544, upload-time = "2025-10-07T18:21:13.741Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ed/4969cefd53315164c94eaf4da7cfba1f267dc275b0abdd593d11c90829a3/ruff-0.14.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30a58c087aef4584c193aebf2700f0fbcfc1e77b89c7385e3139956fa90434e2", size = 14001280, upload-time = "2025-10-07T18:21:16.411Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ad/96c1fc9f8854c37681c9613d825925c7f24ca1acfc62a4eb3896b50bacd2/ruff-0.14.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f8d07350bc7af0a5ce8812b7d5c1a7293cf02476752f23fdfc500d24b79b783c", size = 15027286, upload-time = "2025-10-07T18:21:19.577Z" }, + { url = "https://files.pythonhosted.org/packages/b3/00/1426978f97df4fe331074baf69615f579dc4e7c37bb4c6f57c2aad80c87f/ruff-0.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eec3bbbf3a7d5482b5c1f42d5fc972774d71d107d447919fca620b0be3e3b75e", size = 14451506, upload-time = "2025-10-07T18:21:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/9c1cea6e493c0cf0647674cca26b579ea9d2a213b74b5c195fbeb9678e15/ruff-0.14.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16b68e183a0e28e5c176d51004aaa40559e8f90065a10a559176713fcf435206", size = 13437384, upload-time = "2025-10-07T18:21:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/29/b4/4cd6a4331e999fc05d9d77729c95503f99eae3ba1160469f2b64866964e3/ruff-0.14.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb732d17db2e945cfcbbc52af0143eda1da36ca8ae25083dd4f66f1542fdf82e", size = 13447976, upload-time = "2025-10-07T18:21:28.83Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c0/ac42f546d07e4f49f62332576cb845d45c67cf5610d1851254e341d563b6/ruff-0.14.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c958f66ab884b7873e72df38dcabee03d556a8f2ee1b8538ee1c2bbd619883dd", size = 13682850, upload-time = "2025-10-07T18:21:31.842Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c4/4b0c9bcadd45b4c29fe1af9c5d1dc0ca87b4021665dfbe1c4688d407aa20/ruff-0.14.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7eb0499a2e01f6e0c285afc5bac43ab380cbfc17cd43a2e1dd10ec97d6f2c42d", size = 12449825, upload-time = "2025-10-07T18:21:35.074Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a8/e2e76288e6c16540fa820d148d83e55f15e994d852485f221b9524514730/ruff-0.14.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c63b2d99fafa05efca0ab198fd48fa6030d57e4423df3f18e03aa62518c565f", size = 12272599, upload-time = "2025-10-07T18:21:38.08Z" }, + { url = "https://files.pythonhosted.org/packages/18/14/e2815d8eff847391af632b22422b8207704222ff575dec8d044f9ab779b2/ruff-0.14.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:668fce701b7a222f3f5327f86909db2bbe99c30877c8001ff934c5413812ac02", size = 13193828, upload-time = "2025-10-07T18:21:41.216Z" }, + { url = "https://files.pythonhosted.org/packages/44/c6/61ccc2987cf0aecc588ff8f3212dea64840770e60d78f5606cd7dc34de32/ruff-0.14.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a86bf575e05cb68dcb34e4c7dfe1064d44d3f0c04bbc0491949092192b515296", size = 13628617, upload-time = "2025-10-07T18:21:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/73/e6/03b882225a1b0627e75339b420883dc3c90707a8917d2284abef7a58d317/ruff-0.14.0-py3-none-win32.whl", hash = "sha256:7450a243d7125d1c032cb4b93d9625dea46c8c42b4f06c6b709baac168e10543", size = 12367872, upload-time = "2025-10-07T18:21:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/41/77/56cf9cf01ea0bfcc662de72540812e5ba8e9563f33ef3d37ab2174892c47/ruff-0.14.0-py3-none-win_amd64.whl", hash = "sha256:ea95da28cd874c4d9c922b39381cbd69cb7e7b49c21b8152b014bd4f52acddc2", size = 13464628, upload-time = "2025-10-07T18:21:50.318Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/65880dfd0e13f7f13a775998f34703674a4554906167dce02daf7865b954/ruff-0.14.0-py3-none-win_arm64.whl", hash = "sha256:f42c9495f5c13ff841b1da4cb3c2a42075409592825dada7c5885c2c844ac730", size = 12565142, upload-time = "2025-10-07T18:21:53.577Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/ci-standard/README.md b/ci-standard/README.md new file mode 100644 index 0000000..b541101 --- /dev/null +++ b/ci-standard/README.md @@ -0,0 +1,244 @@ +# CI guardrails + +The CI standard for every repository in TheDancingDeveloper-org, plus +`thedancingdeveloper/fluent-gpui`, as code (WI-3087). The source is the CI review +of 2026-11-02 (`~/Working/Active/ci-review-2026-11.md` §3–§5) and the delivery +process (`.claude/rules/delivery-process.md`, `graft-only.md`). + +Operator decisions (2026-11-02): + +- Every repo follows the guardrails. A repo outside them needs a **specific + exemption**: one rule, a reason, an owner and an expiry. +- Conformance is **audited continuously**, with a Grafana dashboard and alerts. +- **Public repos may use GitHub-hosted runners** for light jobs. Private repos + stay self-hosted. +- graft's merge queue moves from squash to rebase. Oversight changes that + ruleset; until then a dated exemption covers graft CI-10. + +## What is here + +| Path | What | +|---|---| +| `rules.yaml` | The rules: `CI-01`…`CI-24`, each with a statement, severity, kind, machine check and fix. It also holds the runner classes and the allowed required contexts. | +| `exemptions.yaml` | The exemptions in force (schema `exemptions.schema.json`). The auditor and the gate read it from `main`. | +| `actionlint.yaml` | The class labels, so actionlint accepts them. | +| `../auditor/` | `ci-auditor`, the one checker (Python, typed, tested). `lint` is the in-repo gate; `serve` is the org-wide exporter. | +| `../.github/workflows/policy.yml` | The reusable policy gate: actionlint, `ci-auditor lint` and the identity check. | +| `../.github/workflows/rust-ci.yml` | The reusable Rust job: fmt, clippy, nextest and cargo-deny, using rust-cache saved from main only. | +| `../.github/workflows/image.yml` | The reusable image build: BuildKit with a registry cache written from main only, and push by digest. | +| `../.github/workflows/release-dry-run.yml` | The reusable release preflight. It goes with the repo's own build, called with `publish: false`. | +| `../templates/` | Caller shapes: `ci-gate.yml` (the standard CI workflow), `policy-stub.yml` and `release-dry-run-stub.yml`. | + +Every reusable workflow is versioned by tag (`v1.0.0`, …). Callers pin a tag or +a SHA, never `@main` (CI-15), and Dependabot (`github-actions` ecosystem) bumps +the pin. `policy.yml` takes `with: ref:`, which must repeat the `uses:` ref; a +reusable workflow cannot see its own ref. CI-15 flags a mismatch. + +## The rules + +| ID | Area | Rule | Sev | Kind | Gate | +|---|---|---|---|---|---| +| CI-01 | triggers | No branch-wide push CI (`push: ['**']`, bare `push:`, `dev`) | medium | static | block | +| CI-02 | triggers | No re-test on push to main; push publishes and warms caches only | medium | static | block | +| CI-03 | triggers | `merge_group` trigger if and only if there is a merge queue | low | settings | report | +| CI-04 | triggers | Expensive suites (docker, e2e, Playwright) not on pull_request | medium | static | report | +| CI-05 | runners | `runs-on` names exactly one class. No bare `[self-hosted]`. Hosted only in public repos. | high | static | block | +| CI-06 | runners | `publish` class only on main, tags or dispatch | high | static | block | +| CI-07 | runners | Jobs that actually ran asked for a class | medium | runtime | report | +| CI-08 | gate | One required gate `ci` (`if: always()`, `needs`) | high | settings | report | +| CI-09 | gate | Every required context has a producer that is not path-filtered | high | settings | report | +| CI-10 | gate | Merge queue REBASE + ALLGREEN; public repos have a queue | medium | settings | report | +| CI-11 | jobs | `timeout-minutes` on every job, at most 180 | medium | static | block | +| CI-12 | jobs | `concurrency` on every workflow; never cancel merge-queue runs | medium | static | block | +| CI-13 | jobs | No job-level `continue-on-error` | high | static | block | +| CI-14 | jobs | No masked failures (a job failed, its run succeeded) | medium | runtime | report | +| CI-15 | policy | Calls `policy.yml`, pinned to a tag or SHA | medium | static | report | +| CI-16 | policy | Identity checks author and committer only; no local copy that reads bodies | medium | static | block | +| CI-17 | policy | Workflows pass actionlint | medium | static | block | +| CI-18 | policy | Third-party actions pinned by SHA | low | static | report | +| CI-19 | cache | `Swatinem/rust-cache` saved from main only | low | static | report | +| CI-20 | cache | sccache optional (`SCCACHE_IGNORE_SERVER_IO_ERROR=1`) | low | static | block | +| CI-21 | release | A repo that tags has a release dry-run (nightly and PR path) | medium | static | report | +| CI-22 | release | Nightly dry-run green; never 3 red in a row | medium | runtime | report | +| CI-23 | deps | No private source fetched at CI time; no secret build-args | high | static | block | +| CI-24 | deps | CodeQL on releases only, never required | medium | static | block | + +**Gate** is what `policy.yml` does with a finding once the repo sets +`enforce: true`: +- `block` fails the job. +- `report` only warns. + +Settings and runtime rules can't block, because the gate can't see branch +protection or past runs. They show in the dashboard and alerts instead. + +### Runner classes (CI-05) + +| Class | `runs-on` | Use for | +|---|---|---| +| `ci-small` | `ci-small` (ARC scale set, WI-3080) | policy, fmt, lint, type-check, unit tests, gate jobs. No Docker. | +| `rust-heavy` | `[self-hosted, rust-heavy]` | large cargo builds, clippy, nextest | +| `docker` | `[self-hosted, docker]` | compose e2e, smoke tests, image builds without push | +| `bigmem` | `[self-hosted, bigmem]` | CodeQL on tags, parity recordings | +| `publish` | `[self-hosted, publish]` or `[self-hosted, docker, publish]` | pushing, deploying, signing (main, tags, dispatch) | +| `tailnet` | `[self-hosted, tailnet]` | jobs that reach estate hosts | +| `windows` | `[self-hosted, windows]` | Windows builds and signing | +| `graft-dev` | `[self-hosted, graft-dev]` | the graft repo only | +| `hosted` | `ubuntu-24.04`, `ubuntu-24.04-arm`, `windows-2025`, `macos-15` | public repos, light jobs, no secrets. Pinned images, never `-latest`. | + +## Exemptions + +```yaml +- repo: graft # bare repository name + rule: CI-10 # exactly one rule; no wildcards + workflow: ci.yml # optional: narrow to a file + job: e2e # optional: narrow to a job (needs workflow) + reason: >- # >= 40 chars: why, and what ends it + ... + owner: oversight # GitHub handle or `oversight`; removes it + created: 2026-11-02 + expires: 2026-11-16 # at most 180 days after created +``` + +- An expired exemption suppresses nothing. +- The dashboard lists every exemption with its time left, and an alert fires 7 + days before expiry. +- The auditor rejects a malformed file loudly (`ci_exemptions_valid 0`, plus a + log line) and keeps the last good set. +- This repository's own CI runs `ci-auditor validate --fail-expired`. +- To add an exemption, open a PR here; CODEOWNERS reviews it. It takes effect + at the next audit (within an hour), with no redeploy. + +The four `runner-exceptions.json` entries (cadastre, FarmEggs) are carried over +as CI-05 exemptions. `runner-exceptions.json` stays for the legacy +`runner-policy-reusable.yml` gate until every caller has moved to `policy.yml`. + +## The auditor (`ci-auditor serve`) + +- **Language: Python 3.13, typed (`mypy --strict`), tested (pytest).** The + estate's exporters (gha-exporter, github-org-monitor) and policy scripts are + Python. The work is YAML parsing and REST calls, not CPU. It has a single + dependency, PyYAML. The same package is the in-repo gate (`lint`), so the + dashboard and the gate can't disagree. +- **Shape: a long-running exporter, not a CronJob plus pushgateway.** It + re-audits every hour and refreshes the main-branch check status every 10 + minutes. + - The estate has no pushgateway, and pushed series never go stale, so a dead + job would keep showing last week's conformance. + - A long-running pod has a real liveness signal (`up`, + `ci_audit_last_success_timestamp`). + - It keeps its ETag, blob and completed-run caches warm, so hourly passes cost + a fraction of the first. +- **Per repository, it reads:** + - the default branch's tree and workflow blobs; + - the branch summary (`protection.required_status_checks`); + - `/rules/branches/{branch}`, which includes org rulesets that apply, and + gives the required contexts and merge-queue parameters; + - the last 7 days of runs, with jobs for the latest 20 completed runs (job + `labels`, conclusions); + - the head check-runs on the default branch. + + It runs actionlint on the workflow text, then evaluates every rule minus the + live exemptions. +- **Metrics:** + - `ci_conformance{repo,rule,title,area,severity,enforce,status}`: one-hot over + pass, fail, exempt, na and unknown; + - `ci_conformance_findings`, `ci_exemption_expiry_seconds{repo,rule,workflow,job,owner}`; + - `ci_main_required_check{repo,context,conclusion}`; + - `ci_repo_info`, `ci_audit_gap{repo,source}`; + - `ci_audit_last_run_timestamp`, `ci_audit_last_success_timestamp`; + - `ci_audit_duration_seconds`, `ci_audit_api_requests_total`, `ci_exemptions_valid`. +- **Logs:** one JSON line per finding on stdout (`"msg": "ci_finding"`, with + repo, rule, severity, status, workflow, job, message and fix), for Loki. +- **Endpoints:** `/metrics`, `/healthz`, and `/findings` (JSON of every result + that isn't a pass). +- **Image:** `ghcr.io/thedancingdeveloper-org/ci-auditor:`, built by + `.github/workflows/ci-auditor.yml` on tag push. It bundles the rules of that + tag; exemptions come from `main` at run time. +- **Deployment:** ops `apps/ci-auditor/` (ArgoCD). Its read-only GitHub token + comes in through ESO. + +Run it locally: + +```bash +cd auditor && uv sync +uv run ci-auditor validate +uv run ci-auditor lint ~/Working/Active/egressy --repo egressy --no-private --actionlint "$(command -v actionlint)" +GITHUB_TOKEN=... uv run ci-auditor serve --once # one audit; metrics and finding lines on stdout +``` + +### Visibility: what a read-only token can and can't see + +The auditor sees: +- workflows; +- required contexts (branch protection summary plus applied rulesets); +- merge-queue parameters; +- runs and jobs, with their labels; +- check-runs. + +A **fine-grained token** needs Contents, Metadata, Actions and Administration, +all read-only. Administration is optional: without it the branch summary still +carries the required contexts. A fine-grained token is scoped to **one owner**, +so `thedancingdeveloper/fluent-gpui` (a personal-account repo) needs a classic +token or a second token. Until then it is read as a public repo, where rulesets +and runs are still visible. + +It can't see the following, because they need `admin:org`, which no estate +token has (CI review §6): +- the org rulesets as objects (only their effect on each branch); +- the runner inventory and runner labels (taken from job records instead); +- runner groups, and which repos and workflows they admit. So the standard's + §3.3 rule 2 ("special runners must not accept generic work") is **not + audited**. CI-07 sees the effect, a generic job landing on a special runner, + but not the group configuration. + +## Rollout: report first, then enforce + +Baseline from the first live audit (2026-11-02 21:55Z, 80 repos, 391 s): +- 608 failing repo × rule results; +- the most common failures are CI-15 (69 repos), CI-08 (67), CI-11 (66), + CI-02 and CI-12 (65 each), and CI-05 (60). + +Most of the 80 repos are small and inactive. The graft repos below carry the +load. + +**Phase 0 (this change):** +- the standard, the auditor and the dashboards; +- nothing blocks; +- the old `runner-policy-reusable.yml` stays as it is. + +**Phase 1, report only (weeks 1–2).** Each repo adopts `policy.yml` in report +mode (`templates/ci-gate.yml` or `policy-stub.yml`). Findings show as PR +annotations and in Grafana. Repos that have none yet get exemptions only where +a fix is genuinely blocked. + +**Phase 2, enforce per repo.** When a repo has no `block`-rule findings for 7 +days: +1. set `enforce: true` in its policy call; +2. make `ci` its only required context (with `policy` folded into the gate). + +From then on, drift without an exemption fails the PR. The dashboard's +"enforcing" column counts repos with `enforce: true`. + +**Phase 3, org default.** New repos start from `templates/ci-gate.yml` with +`enforce: true`. The legacy runner-policy gate and `runner-exceptions.json` are +deleted when no repo calls them. + +### Migration order (S < 1 day, M 1–3 days, L > 3 days) + +The review's 16 quick wins (QW) are folded in. + +| # | Repo / area | Change | Rules | QW | Effort | +|---|---|---|---|---|---| +| 0 | github-policy | Tag `v1.0.0`; publish the ci-auditor image; deploy the auditor and dashboards (ops) | all | 1, 11, 12 | S (this change) | +| 1 | runners (ops) | Separate runner groups for publish, bigmem, graft-dev and windows; drop the generic labels; fix `github-runner-job-completed.sh` `home_dir`; assert `/tmp` 1777 in the job-started hook; bake PyYAML and actionlint into the ci-small image | CI-05, CI-07 | 2, 3 | M | +| 2 | email-rs | Drop `push: ['**']`; concurrency and timeouts; one `ci` gate that folds in `release-dry-run`; merge queue (public); adopt policy.yml | CI-01, 02, 08, 10, 11, 12, 15 | 4 | S | +| 3 | rustnzb | Fix `desktop/src-tauri/Cargo.lock`; quarantine 3 e2e specs; e2e, desktop, smoke and dry-run move from `[docker, publish]` to `[docker]`; repair the nightly dry-run; adopt policy.yml | CI-06, 13, 22, 05, 15, 16 | 5, 6 | M | +| 4 | vogt | Drop `push: main` from ci.yml; e2e `cache-from` the build.yml registry cache, with e2e in merge_group only; mypy over touched tests in worker gates; adopt policy.yml (vogt's stricter checks stay as inputs); `ci` as the required gate | CI-02, 04, 08, 19, 15 | 7, 13, 14 | M | +| 5 | rustTorrent | Drop push CI on main and dev; delete `dev`; adopt policy.yml (fixes the Windows and trailer collisions); Windows lane on the shared release build | CI-01, 02, 16, 22, 24 | 7, 11 | M | +| 6 | myaiagent | `ruff format` in the land gate; class labels for docker queue relief; keep direct merge; postgres on tmpfs | CI-02, 04, 05, 08 | 14 | S | +| 7 | graft | Merge queue SQUASH → REBASE (oversight; delete the CI-10 exemption); remove or relabel `bench-tests` and the macOS jobs; quarantine `restart-timing` and 4 flaky tests; remove `CARGO_BUILD_JOBS: 4`; sccache optional | CI-10, 05, 13, 20, 03 | 8, 9, 15 | M | +| 8 | egressy | Required `build` comes from a gate job (not path-filtered); GHCR secret guard; adopt policy.yml; merge queue | CI-08, 09, 06, 10, 16 | 10 | S | +| 9 | fluent-gpui | Merge queue on, or remove the dead `merge_group`; `ubuntu-24.04` instead of `-latest`; adopt rust-ci.yml | CI-03, 05, 10, 15 | — | S | +| 10 | the rest (about 70 small repos) | One batch PR each from `templates/ci-gate.yml`; or, for repos with no CI, CI-08 exemptions with an owner and a 90-day expiry | CI-08, 11, 12, 15 | — | M in total | +| 11 | all | rust-ci, cache-warm and image adoption; in-cluster BuildKit (`remote-buildkit` input) | CI-19, 04 | — | L | +| — | process | Oversight cuts batches at 6 lands and never lets a backlog pass ~12 | — | 16 | S | diff --git a/ci-standard/actionlint.yaml b/ci-standard/actionlint.yaml new file mode 100644 index 0000000..ba028bd --- /dev/null +++ b/ci-standard/actionlint.yaml @@ -0,0 +1,14 @@ +# actionlint config for the CI guardrails (WI-3087): the custom labels of +# the runner classes in rules.yaml, so actionlint's runner-label check +# accepts them. The policy gate and the auditor both pass this file with +# -config-file. auditor/tests keep it in sync with rules.yaml. +self-hosted-runner: + labels: + - bigmem + - ci-small + - docker + - graft-dev + - publish + - rust-heavy + - tailnet + - windows diff --git a/ci-standard/exemptions.schema.json b/ci-standard/exemptions.schema.json new file mode 100644 index 0000000..bbcc19f --- /dev/null +++ b/ci-standard/exemptions.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/TheDancingDeveloper-org/github-policy/ci-standard/exemptions.schema.json", + "title": "CI guardrails exemptions", + "type": "object", + "required": ["version", "exemptions"], + "additionalProperties": false, + "properties": { + "version": {"const": 1}, + "exemptions": { + "type": "array", + "items": { + "type": "object", + "required": ["repo", "rule", "reason", "owner", "created", "expires"], + "additionalProperties": false, + "properties": { + "repo": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+$", "description": "Repository name (no owner)."}, + "rule": {"type": "string", "pattern": "^CI-[0-9]{2}$", "description": "One rule id from rules.yaml; no wildcard."}, + "workflow": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+\\.ya?ml$", "description": "Optional: workflow file name under .github/workflows."}, + "job": {"type": "string", "minLength": 1, "description": "Optional: job key; requires workflow."}, + "reason": {"type": "string", "minLength": 40, "description": "Why, and what ends it."}, + "owner": {"type": "string", "minLength": 2, "description": "GitHub handle or 'oversight'."}, + "created": {"type": "string", "format": "date"}, + "expires": {"type": "string", "format": "date", "description": "At most max_exemption_days after created."} + }, + "dependentRequired": {"job": ["workflow"]} + } + } + } +} diff --git a/ci-standard/exemptions.yaml b/ci-standard/exemptions.yaml new file mode 100644 index 0000000..9489ee3 --- /dev/null +++ b/ci-standard/exemptions.yaml @@ -0,0 +1,102 @@ +# CI guardrails exemptions (WI-3087). +# +# A repository outside the standard needs a specific exemption: one rule, one +# repository, a reason, an owner and an expiry (operator, 2026-11-02). The +# auditor reads this file from `main` on every audit, so a merged change takes +# effect within an hour without a redeploy; the policy gate reads it from +# `main` too. +# +# - `rule` is one CI-NN id from rules.yaml. There is no wildcard. +# - `workflow` / `job` narrow it (file name under .github/workflows, job key). +# Without them the exemption covers the whole repository for that rule. +# - `expires` is at most `max_exemption_days` (rules.yaml) after `created`. +# An expired exemption suppresses nothing, and shows as expired. +# - `owner` is the GitHub handle (or `oversight`) who will remove it. +# - Validated by schema exemptions.schema.json and auditor/tests. +# +# Schema: exemptions.schema.json. Keep entries sorted by repo, then rule. +version: 1 +exemptions: + - repo: cadastre + rule: CI-05 + workflow: ci.yaml + reason: >- + Public repository whose own tests (tests/test_release_workflow.py) assert + every non-release job is NOT self-hosted, because pull_request builds + untrusted fork code and the self-hosted pool is persistent and on the + tailnet. Hosted runners are now allowed for public repos, so the + remaining gap is only the `-latest` label; pin ubuntu-24.04 and drop this. + Carried over from runner-exceptions.json. + owner: thedancingdeveloper + created: 2026-11-02 + expires: 2027-02-17 + - repo: cadastre + rule: CI-05 + workflow: publish.yml + reason: >- + Same tested invariant as cadastre/ci.yaml (self-hosted only behind + refs/tags/v*). Carried over from runner-exceptions.json. + owner: thedancingdeveloper + created: 2026-11-02 + expires: 2027-02-17 + - repo: FarmEggs + rule: CI-05 + workflow: ci.yml + job: mobile + reason: >- + flutter build apk needs a JDK and the Android SDK, which no self-hosted + runner image ships (verified 2026-08-17). Move when the runner image + ships them. Carried over from runner-exceptions.json. + owner: thedancingdeveloper + created: 2026-11-02 + expires: 2026-11-17 + - repo: FarmEggs + rule: CI-05 + workflow: ci.yml + job: web + reason: >- + The browser suite needs google-chrome at a pinned path plus npm for + playwright-core, absent from every runner image (verified 2026-08-17). + Carried over from runner-exceptions.json. + owner: thedancingdeveloper + created: 2026-11-02 + expires: 2026-11-17 + - repo: github-policy + rule: CI-05 + workflow: runner-policy-reusable.yml + reason: >- + The legacy runner-policy gate requests bare [self-hosted] in every + caller's context; relabelling it changes runner selection for all + legacy callers at once. It is deleted when the last caller moves to + policy.yml (README "Rollout", phase 3). + owner: thedancingdeveloper + created: 2026-11-02 + expires: 2027-04-30 + - repo: github-policy + rule: CI-15 + reason: >- + This repository is the policy: ci-auditor.yml runs the checker from its + own source on every PR, which is stricter than calling a pinned release + of itself. Revisit if the self-check is ever removed. + owner: thedancingdeveloper + created: 2026-11-02 + expires: 2027-04-30 + - repo: github-policy + rule: CI-21 + reason: >- + The only tag build here is the ci-auditor image, and ci-auditor.yml + builds that same image (push off) in every merge-queue run, so a tag is + never its first build. CI-21 recognises dry runs by name and cannot see + that; drop this if a separate release build is ever added. + owner: thedancingdeveloper + created: 2026-11-02 + expires: 2027-04-30 + - repo: graft + rule: CI-10 + reason: >- + The merge queue is SQUASH; the operator decided (2026-11-02) it moves to + REBASE and oversight is making that ruleset change separately. Delete + this entry when the ruleset says REBASE. + owner: oversight + created: 2026-11-02 + expires: 2026-11-16 diff --git a/ci-standard/rules.yaml b/ci-standard/rules.yaml new file mode 100644 index 0000000..88cac4d --- /dev/null +++ b/ci-standard/rules.yaml @@ -0,0 +1,490 @@ +# CI guardrails: the standard, as code (WI-3087). +# +# Every rule here is evaluated by the auditor (auditor/, the `ci-auditor` +# exporter) for every org repository, and the `static` ones also by the +# reusable `policy.yml` gate inside each repository. The rule IDs are stable: +# exemptions, metrics, alerts and dashboards key on them. Never renumber; retire +# a rule by setting `retired: true`. +# +# Fields: +# id CI-NN, stable. +# area grouping for the dashboard. +# title short name (metric-safe text, shown in the matrix). +# statement the rule, in one or two sentences. +# severity high | medium | low. +# kind static - read from the workflow files alone (the policy gate can run it) +# settings - needs branch protection / rulesets (auditor only) +# runtime - needs recent Actions runs (auditor only) +# check the auditor function that evaluates it (auditor/src/ci_auditor/checks.py). +# machine how the check decides, in words, so a reader can predict it. +# fix what to change to pass. +# enforce report | block. `block` rules fail the policy gate once a repo +# opts into enforcement (policy.yml `enforce: true`); `report` +# rules only ever show in the dashboard. See README "Rollout". +# +# Source: ~/Working/Active/ci-review-2026-11.md §3 and +# .claude/rules/delivery-process.md "CI". + +version: 1 + +# Capability classes for `runs-on`. A job's runs-on must equal one of a +# class's `forms` exactly (order-insensitive). `self-hosted` alone is never a +# class. `public_only` classes are GitHub-hosted and allowed only in public +# repositories, for light jobs (operator, 2026-11-02). `repos` restricts a +# class to the named repositories. +runner_classes: + ci-small: + forms: [[ci-small]] + use: policy, fmt, lint, type-check, unit tests, changes and gate jobs, docs. No Docker, no tailnet, <= 8 GB. + rust-heavy: + forms: [[self-hosted, rust-heavy]] + use: cargo build, clippy and nextest on large workspaces. + docker: + forms: [[self-hosted, docker]] + use: compose e2e, container smoke, image builds without push. + bigmem: + forms: [[self-hosted, bigmem]] + use: CodeQL on tags, parity recordings, huge links. Nothing else. + publish: + forms: [[self-hosted, publish], [self-hosted, docker, publish]] + use: push to a registry, deploy, signing. Only on push to main, tags or dispatch (CI-06). + tailnet: + forms: [[self-hosted, tailnet]] + use: jobs that must reach estate hosts. + windows: + forms: [[self-hosted, windows]] + use: Windows builds and signing (rustTorrent). + graft-dev: + forms: [[self-hosted, graft-dev]] + repos: [graft] + use: graft attest and dev deploy, graft repo only. + hosted: + forms: [[ubuntu-24.04], [ubuntu-24.04-arm], [windows-2025], [macos-15]] + public_only: true + use: light jobs (lint, unit tests, docs) in public repositories with no secrets and no tailnet. Pinned images, never -latest. + +# Required contexts a repository may make required on its default branch. +# `policy / policy` is the check name when policy.yml is called from its own +# caller job; preferred is to call it from the CI workflow and let `ci` need it. +required_contexts: [ci, policy, policy / policy] + +# Exemptions may not run longer than this from the day they are written. +max_exemption_days: 180 + +rules: + # --- Trigger matrix ------------------------------------------------------ + - id: CI-01 + area: triggers + title: no branch-wide push CI + statement: >- + A workflow that runs on pull_request or merge_group does not also run on + push to any branch other than the default branch (no `push: ['**']`, no + unfiltered `push:`, no long-lived `dev`/`develop` branch). + severity: medium + kind: static + check: trigger_push_scope + machine: >- + For each workflow with a pull_request or merge_group trigger, a `push` + trigger must name `branches` (or only `tags`), and every branch pattern + must equal the default branch. + fix: >- + Delete `push: branches: ['**']`, bare `push:` and `push: [main, dev]` + from CI workflows. Delete the `dev` branch. Batch PRs and the merge queue + already run CI. + enforce: block + + - id: CI-02 + area: triggers + title: no re-test on push to main + statement: >- + Push to the default branch publishes and warms caches; it does not + re-run the tests the merge queue already passed. + severity: medium + kind: static + check: trigger_no_main_retest + machine: >- + A workflow with pull_request or merge_group AND push-to-default-branch + triggers fails unless every job is gated by an `if:` naming the push + event or the main ref (a publish or cache-warm job). + fix: >- + Remove `push: main` from the CI workflow; move publishing and the cache + warm into a push-only workflow (or gate those jobs with + `if: github.event_name == 'push'`). + enforce: block + + - id: CI-03 + area: triggers + title: merge_group iff merge queue + statement: >- + When the default branch has a merge queue, every workflow that produces + a required context runs on merge_group. When it has none, no workflow + carries a dead merge_group trigger. + severity: low + kind: settings + check: trigger_merge_group_matches_queue + machine: >- + Reads the merge_queue rule from /rules/branches/{default}. Queue on: + each required context's producing workflow has merge_group. Queue off: + no workflow has merge_group. + fix: Add `merge_group:` to the gate workflow, or remove the dead trigger. + enforce: report + + - id: CI-04 + area: triggers + title: expensive suites off pull_request + statement: >- + Image builds, compose e2e, Playwright and other heavy suites run in + merge_group (and nightly), never on the batch PR. + severity: medium + kind: static + check: trigger_heavy_off_pr + machine: >- + A job that can run on pull_request (the workflow has the trigger and the + job's `if:` does not exclude it) fails if its class is docker, bigmem or + publish, or a step runs `docker build`, docker/build-push-action, `docker + compose up` or `playwright test`. + fix: >- + Gate the job with `if: github.event_name != 'pull_request'` (it still + runs in merge_group), or move it to a merge_group-only workflow. + enforce: report + + # --- Runners --------------------------------------------------------------- + - id: CI-05 + area: runners + title: runs-on names one class + statement: >- + Every `runs-on` names exactly one capability class from + `runner_classes`. Bare `[self-hosted]` is not a class. GitHub-hosted + labels are allowed only in public repositories. graft-dev only in graft. + severity: high + kind: static + check: runner_class_allowed + machine: >- + Each job's runs-on (string, list, or a `${{ matrix.x }}` resolved + against a literal matrix) must equal a class form. Unresolvable + expressions fail. `public_only` classes fail in private repositories; + `repos`-restricted classes fail elsewhere. + fix: >- + Pick the class: `runs-on: ci-small` for lint/test/gate jobs, + `[self-hosted, rust-heavy]` for big cargo builds, `[self-hosted, + docker]` for e2e/smoke, `[self-hosted, publish]` only to publish. + enforce: block + + - id: CI-06 + area: runners + title: publish only from main/tags + statement: >- + The `publish` class is a credential boundary. A publish job runs only on + push to the default branch, tags, release or workflow_dispatch. + severity: high + kind: static + check: runner_publish_scope + machine: >- + A job of class publish fails if its workflow has a pull_request, + pull_request_target or merge_group trigger and the job's `if:` does not + exclude those events. + fix: >- + Move e2e, smoke and dry-run jobs from `[docker, publish]` to + `[self-hosted, docker]`; keep publish for the pushing job only. + enforce: block + + - id: CI-07 + area: runners + title: runtime labels are classes + statement: >- + The jobs that actually ran in the last 7 days asked for a class label + set (catches workflows on other branches and reusable callers). + severity: medium + kind: runtime + check: runtime_runner_labels + machine: >- + Job records from the latest runs (jobs API `labels`) must equal a class + form; reports each distinct offending label set with a count. + fix: Same as CI-05, in the workflow that produced the job. + enforce: report + + # --- Required-check gate --------------------------------------------------- + - id: CI-08 + area: gate + title: one required gate ci + statement: >- + The default branch requires `ci` (and `policy` while it is a separate + reusable workflow) and nothing else. `ci` is an aggregator job with + `if: always()` and `needs:` that fails on any failed or cancelled need + and accepts skipped. + severity: high + kind: settings + check: gate_shape + machine: >- + Required contexts from branch protection plus /rules/branches must be + non-empty, include `ci`, and be a subset of `required_contexts`. The job + producing `ci` must have `needs` and an `if:` containing `always()`. + fix: >- + Add a gate job (github-policy `templates/ci-gate.yml`), make `ci` + required, drop the per-job required contexts. + enforce: report + + - id: CI-09 + area: gate + title: required contexts have unfiltered producers + statement: >- + Every required context is produced by a workflow that always runs on + the events the branch needs: no `paths:`/`paths-ignore:` filter on its + pull_request or merge_group trigger. + severity: high + kind: settings + check: gate_producers_unfiltered + machine: >- + Maps each required context to a job (by `name:` or key, matrix and + reusable prefixes included). Fails when no producer exists or the + producing workflow's pull_request/merge_group trigger is path-filtered. + fix: >- + Path-scope inside the workflow with a `changes` job and let the gate + accept skipped needs; never path-filter the workflow itself. + enforce: report + + - id: CI-10 + area: gate + title: merge queue rebase, allgreen + statement: >- + A merge queue merges with REBASE and ALLGREEN grouping, so every land + keeps its commit. Public repositories have a merge queue; private ones + on the Team plan merge directly with land-pr. + severity: medium + kind: settings + check: gate_merge_queue + machine: >- + merge_queue rule parameters must have merge_method REBASE and + grouping_strategy ALLGREEN. A public repository with a required context + but no merge queue fails. + fix: Edit the ruleset's merge queue (operator), or add one. + enforce: report + + # --- Jobs and workflows ---------------------------------------------------- + - id: CI-11 + area: jobs + title: timeout-minutes on every job + statement: Every job sets `timeout-minutes`, at most 180. + severity: medium + kind: static + check: job_timeouts + machine: >- + Every job without `uses:` has an integer `timeout-minutes` (or an + expression) <= 180. + fix: Add `timeout-minutes:` at about 2x the job's p95. + enforce: block + + - id: CI-12 + area: jobs + title: concurrency on every workflow + statement: >- + Every workflow triggered by pull_request or push declares + `concurrency`, and never cancels merge-queue runs. + severity: medium + kind: static + check: workflow_concurrency + machine: >- + Top-level `concurrency` (or one on every job) is present. If the + workflow has merge_group, `cancel-in-progress` must not be the literal + `true`. + fix: >- + `concurrency: {group: '${{ github.workflow }}-${{ github.ref }}', + cancel-in-progress: "${{ github.event_name != 'merge_group' }}"}` + enforce: block + + - id: CI-13 + area: jobs + title: no job-level continue-on-error + statement: >- + `continue-on-error` on a whole job is banned; it hides deterministic + failures for weeks. Quarantine flaky tests instead (flaky-quarantine). + severity: high + kind: static + check: job_no_continue_on_error + machine: Any job with a `continue-on-error` key whose value is not the literal false. + fix: >- + Fix the job, or quarantine the failing tests and make the job pass; a + non-required experimental job lives in a separate workflow. + enforce: block + + - id: CI-14 + area: jobs + title: no masked failures + statement: >- + In the workflows that produce required contexts, no job fails while its + run concludes success (continue-on-error at work). + severity: medium + kind: runtime + check: runtime_masked_failures + machine: >- + For recent completed runs of producer workflows: a job with conclusion + failure in a run with conclusion success is a finding. + fix: As CI-13. + enforce: report + + # --- Policy gate ------------------------------------------------------------- + - id: CI-15 + area: policy + title: shared policy gate pinned + statement: >- + The repository calls github-policy `policy.yml` (actionlint, runner + classes, timeouts, identity) pinned to a release tag or a commit SHA. + severity: medium + kind: static + check: policy_gate_adopted + machine: >- + Some job `uses: + TheDancingDeveloper-org/github-policy/.github/workflows/policy.yml@` + where ref matches `v` or a 40-hex SHA (not a branch), and its + `with: ref` input repeats that ref. + fix: Copy `templates/policy-stub.yml` to `.github/workflows/policy.yml`. + enforce: report + + - id: CI-16 + area: policy + title: identity checks author and committer only + statement: >- + Identity policy reads commit author and committer fields and tracked + paths and content, never commit message bodies (graft trailers and + transcripts live there). + severity: medium + kind: static + check: policy_identity_scope + machine: >- + Fails on a local migration-policy copy (`*migration-policy*` in the + tree) or a workflow `run:` that pipes `%B`/`%b` commit bodies into grep. + fix: Delete the local copy; the shared policy.yml carries the identity check. + enforce: block + + - id: CI-17 + area: policy + title: workflows pass actionlint + statement: Every workflow file passes actionlint (shellcheck off). + severity: medium + kind: static + check: workflows_actionlint + machine: >- + The auditor runs `actionlint -shellcheck= -pyflakes=` over the default + branch's workflow files; any error is a finding. + fix: Run `actionlint` locally and fix what it reports. + enforce: block + + - id: CI-18 + area: policy + title: third-party actions pinned by SHA + statement: >- + Third-party actions are pinned to a 40-hex commit SHA. actions/*, + github/* and github-policy (tag) may use a tag. + severity: low + kind: static + check: actions_pinned + machine: >- + Each `uses:` that is not local (`./`), not docker://, and not owned by + actions, github or TheDancingDeveloper-org must end in @<40 hex>. + fix: Pin with the SHA and a `# vX.Y.Z` comment; Dependabot bumps it. + enforce: report + + # --- Caching ------------------------------------------------------------- + - id: CI-19 + area: cache + title: rust-cache saved from main only + statement: >- + Swatinem/rust-cache saves only on the default branch (push or the + cache-warm job); PR and merge-queue runs restore only. + severity: low + kind: static + check: cache_rust_save_main_only + machine: >- + Every Swatinem/rust-cache step has `save-if` naming the main ref or the + push event. An actions/cache (save) step over target/ or ~/.cargo + without such a condition also fails. + fix: >- + `with: {shared-key: , save-if: "${{ github.ref == + 'refs/heads/main' }}"}`; add a cache-warm job on push to main. + enforce: report + + - id: CI-20 + area: cache + title: sccache optional + statement: >- + If sccache is used it is optional: an unreachable cache server slows the + build, it never fails it. + severity: low + kind: static + check: cache_sccache_optional + machine: >- + A workflow that mentions sccache (RUSTC_WRAPPER, sccache-action) must + set SCCACHE_IGNORE_SERVER_IO_ERROR to 1/true in workflow or job env. + fix: >- + Set `SCCACHE_IGNORE_SERVER_IO_ERROR: "1"`, or drop sccache for rust-cache. + enforce: block + + # --- Release --------------------------------------------------------------- + - id: CI-21 + area: release + title: release dry-run exists + statement: >- + A repository that releases on tags has a release dry-run (same build, + publish false) that runs nightly on main and on PRs touching the release + path. A tag is never the first run of the release build. + severity: medium + kind: static + check: release_dry_run_present + machine: >- + If any workflow triggers on push tags or release, some workflow whose + file or name contains "dry-run"/"dry run" has a schedule trigger. + fix: Add a caller of github-policy `release-dry-run.yml` (templates/release-dry-run-stub.yml). + enforce: report + + - id: CI-22 + area: release + title: nightly dry-run green + statement: The latest scheduled release dry-run on main succeeded, and none was red more than 2 days running. + severity: medium + kind: runtime + check: runtime_dry_run_green + machine: >- + Of the dry-run workflow's schedule runs on the default branch in the + last 7 days, the latest is success and no 3 consecutive ones failed. + fix: Fix the release path; a red nightly blocks tagging. + enforce: report + + # --- Dependencies ---------------------------------------------------------- + - id: CI-23 + area: deps + title: no private source at CI time + statement: >- + CI never fetches a private source at build or test time (no clone of a + private repo, no git dependency install from one) and never passes a + secret as a Docker build-arg. Private dependencies come from a registry + with a read-only token, or a pinned artifact. + severity: high + kind: static + check: deps_no_private_fetch + machine: >- + `run:` blocks and `with:` values are scanned for git clone, `cargo + install --git`, `pip install git+`, `npm i github:` and Forgejo + (repo.indexarr.net) URLs that name a private org repository or any + Forgejo repository; `build-args` containing `secrets.` fail. + fix: >- + Publish the dependency to the Forgejo registry and pin it; pass tokens + with BuildKit `secrets:`; take bundled tools from a pinned GHCR artifact. + enforce: block + + - id: CI-24 + area: deps + title: CodeQL on releases only + statement: >- + CodeQL runs only when a release build is cut (tags, release, + workflow_dispatch). Never on pull_request, merge_group, push to a branch + or a schedule, and never a required check. + severity: medium + kind: static + check: codeql_release_only + machine: >- + A workflow using github/codeql-action may have only push-with-tags-only, + release and workflow_dispatch triggers. + fix: >- + Change the CodeQL workflow's `on:` to `push: tags: ['v*']` plus + `workflow_dispatch`. + enforce: block diff --git a/runner-exceptions.json b/runner-exceptions.json index 4a0270e..c3960b8 100644 --- a/runner-exceptions.json +++ b/runner-exceptions.json @@ -30,6 +30,42 @@ ], "reason": "The browser suite and the M1a acceptance run need google-chrome at the path CHROME_EXECUTABLE/EGGS_WEB_CHROME pin, plus npm for playwright-core. Verified absent on all three runner containers 2026-08-17. Move when the runner image ships them.", "expires_on": "2026-11-17" + }, + { + "repo": "github-policy", + "workflow": "audit.yml", + "reason": "Public repository; GitHub-hosted runners are allowed for light jobs in public repos (operator, 2026-11-02). This legacy line-based checker predates that decision; ci-auditor rule CI-05 (ci-standard) judges these jobs instead.", + "expires_on": "2027-04-30" + }, + { + "repo": "github-policy", + "workflow": "ci-auditor.yml", + "reason": "Public repository; GitHub-hosted runners are allowed for light jobs in public repos (operator, 2026-11-02). This legacy line-based checker predates that decision; ci-auditor rule CI-05 (ci-standard) judges these jobs instead.", + "expires_on": "2027-04-30" + }, + { + "repo": "github-policy", + "workflow": "policy.yml", + "reason": "Reusable workflow whose `runs-on` comes from a `runs-on` input (default a ci-standard runner class). This legacy line-based checker reads the input's key as a runner selection; ci-auditor rule CI-05 resolves the default instead.", + "expires_on": "2027-04-30" + }, + { + "repo": "github-policy", + "workflow": "rust-ci.yml", + "reason": "Reusable workflow whose `runs-on` comes from a `runs-on` input (default a ci-standard runner class). This legacy line-based checker reads the input's key as a runner selection; ci-auditor rule CI-05 resolves the default instead.", + "expires_on": "2027-04-30" + }, + { + "repo": "github-policy", + "workflow": "image.yml", + "reason": "Reusable workflow whose `runs-on` comes from a `runs-on` input (default a ci-standard runner class). This legacy line-based checker reads the input's key as a runner selection; ci-auditor rule CI-05 resolves the default instead.", + "expires_on": "2027-04-30" + }, + { + "repo": "github-policy", + "workflow": "release-dry-run.yml", + "reason": "Reusable workflow whose `runs-on` comes from a `runs-on` input (default a ci-standard runner class). This legacy line-based checker reads the input's key as a runner selection; ci-auditor rule CI-05 resolves the default instead.", + "expires_on": "2027-04-30" } ] } diff --git a/templates/ci-gate.yml b/templates/ci-gate.yml new file mode 100644 index 0000000..c88e651 --- /dev/null +++ b/templates/ci-gate.yml @@ -0,0 +1,50 @@ +# Template: the standard CI workflow shape (ci-standard rules CI-01..CI-15). +# Copy to .github/workflows/ci.yml and replace the `checks` job with the +# repository's own jobs (or a call to rust-ci.yml). Make ONLY `ci` required. +name: CI + +on: + pull_request: + merge_group: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'merge_group' }} + +permissions: + contents: read + +jobs: + policy: + uses: TheDancingDeveloper-org/github-policy/.github/workflows/policy.yml@v1.0.0 + with: + ref: v1.0.0 + + rust: + uses: TheDancingDeveloper-org/github-policy/.github/workflows/rust-ci.yml@v1.0.0 + + e2e: + # Expensive suites run in the merge queue only (CI-04). + if: github.event_name == 'merge_group' + runs-on: [self-hosted, docker] + timeout-minutes: 45 + steps: + - uses: actions/checkout@v5 + - run: ./scripts/e2e.sh + + ci: + # The one required context. Fails on any failed or cancelled need, + # accepts skipped (path-scoped or merge_group-only jobs). + name: ci + if: always() + needs: [policy, rust, e2e] + runs-on: ci-small + timeout-minutes: 5 + steps: + - name: Gate + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + if echo "$RESULTS" | grep -qE '"(failure|cancelled)"'; then exit 1; fi diff --git a/templates/policy-stub.yml b/templates/policy-stub.yml new file mode 100644 index 0000000..781deb8 --- /dev/null +++ b/templates/policy-stub.yml @@ -0,0 +1,23 @@ +# Template: the policy gate on its own, for a repository whose CI workflow +# cannot take the `policy` job yet. Prefer adding the job to ci.yml and +# letting `ci` need it (templates/ci-gate.yml). Its check is `policy / policy`. +# Keep `uses:` and `with: ref` on the same tag (rule CI-15). +name: Policy + +on: + pull_request: + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'merge_group' }} + +permissions: + contents: read + +jobs: + policy: + uses: TheDancingDeveloper-org/github-policy/.github/workflows/policy.yml@v1.0.0 + with: + ref: v1.0.0 + # enforce: true # phase 2, once the repository is clean (README "Rollout") diff --git a/templates/release-dry-run-stub.yml b/templates/release-dry-run-stub.yml new file mode 100644 index 0000000..2e83c6b --- /dev/null +++ b/templates/release-dry-run-stub.yml @@ -0,0 +1,31 @@ +# Template: release dry-run (rules CI-21, CI-22). The build job calls the +# SAME reusable build the tag workflow calls, with publish off. +name: Release dry run + +on: + schedule: + - cron: "17 3 * * *" + pull_request: + paths: + - ".github/workflows/release*.yml" + - "packaging/**" + - "Dockerfile*" + - "scripts/release/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + preflight: + uses: TheDancingDeveloper-org/github-policy/.github/workflows/release-dry-run.yml@v1.0.0 + + build: + needs: preflight + uses: ./.github/workflows/release-build.yml + with: + publish: false