diff --git a/.github/workflows/_ci-gate.yml b/.github/workflows/_ci-gate.yml deleted file mode 100644 index 44b5017..0000000 --- a/.github/workflows/_ci-gate.yml +++ /dev/null @@ -1,1111 +0,0 @@ -# Reusable: CI Gate (Merge Gatekeeper) -# -# Centralizes the conditional-required-check pattern that previously lived as -# duplicated `ci-gate.yml` files in every consumer repo. -# -# What this workflow does: -# -# scope -> content-aware classifier (JacobPEvans/.github-ai's -# scope-classify.yml, "Jev"); a SECOND, additional layer on -# top of `changes` below, narrowing further, never replacing -# it - see "Content-aware scope classifier" below -# changes -> dorny/paths-filter on caller-supplied filters -# -> per-check reusable workflows, gated on (input toggle && filter) -# watchdog -> watches every sibling until it completes; cancels the run when -# any job waits for a runner longer than queue_timeout_minutes -# gate -> name MUST stay "Merge Gate" (matches branch protection rulesets); -# aggregates results with the shared result script -# -# Content-aware scope classifier (`scope` job): -# -# Runs unconditionally, in parallel with `changes`. Every conditional check -# job below carries its ORIGINAL filter-based `if:`, ANDed with one more -# clause reading `needs.scope.outputs.ci` (or `.molecule`) - narrowing -# further when the classifier is confident nothing more is needed, never -# widening past what the filter already allows. A lint/format/security-scan -# tier job runs when `ci` is `full` OR `lint-only` (only skips on `none`); a -# full-suite tier job (Nix Build, Python CI) requires `ci == 'full'`. Every -# such clause reads `needs.scope.result != 'success' || ...`: a scope job -# that fails, or has no TYPESAFE_API_KEY plumbed through this file's own -# caller yet, defers ENTIRELY to the pre-existing filter - "the classifier -# is unreachable" behaves exactly like "this feature doesn't exist yet", -# never like "skip everything". `dependency-review`, `zizmor`, `osv-scan`, -# and `renovate-annotations` are deliberately NOT gated on `scope` - each -# has its own documented reason a supply-chain/workflow-security scan must -# never be skippable by ANY signal, deterministic or classified. -# `markdown-lint` and `file-size` depend only on their path filters, never on -# `scope`: the classifier may narrow optional jobs, not these two. -# https://github.com/JacobPEvans/.github-ai/blob/v1/.github/scope-rubric.md -# -# Why the watchdog exists: -# -# GitHub Actions only schedules a `needs:` dependent once every upstream job -# reaches a *terminal* state (success/failure/cancelled/skipped). A job stuck -# waiting for a runner (e.g. a self-hosted runner never picks it up) is NOT -# terminal. If any job waits past the timeout, the watchdog cancels the run so an -# incomplete check set cannot pass the required `Merge Gate`. -# -# Caller requirements (thin consumer shape): -# -# jobs: -# gate: -# permissions: -# contents: read -# pull-requests: read -# actions: write # REQUIRED: the watchdog cancels queued jobs via the -# # Actions API, and a called workflow can never exceed -# # the caller's token grant — omitting this fails the -# # entire run at workflow-graph validation -# # (startup_failure, 0s, no jobs, UI-only error). -# uses: dryvist/.github/.github/workflows/_ci-gate.yml@<40-hex commit SHA> # vX.Y.Z -# -# That is the whole caller for a markdown-only repo: `markdown_lint` and -# `file_size` default on, and `filters` defaults to the `markdown` block -# below. Pass `with:` only for what differs (a `nix`/`python`/`ansible`/ -# `terraform` filter, a toggle, a runner label). -# -# Scope policy (canonical): a check runs in proportion to the change that -# triggered it; the one guaranteed end-to-end sweep is promotion to a -# git-flow repo's production branch. Narrow at the consumer, never in a -# filter several repos share, and never let the job that decides what to -# skip be skippable itself. -# https://docs.jacobpevans.com/d/decisions/ci-scope-follows-the-change/ -# -# Filter name convention (caller defines these in `filters:` input): -# -# nix -> gates `nix_validate` (profile nix enables it); `nix_build` is opt-in -# markdown -> gates `markdown_lint`, `file_size`, and `token_limits` -# (`file_size` also fires on `nix` and `terraform`) -# python -> gates `python_security` -# ansible -> gates `ansible_ci` -# terraform -> gates `tofu_ci` -# extra -> gates nothing here; exported as the `extra` output so a -# caller can gate ONE repo-local job without a second -# `changes` job of its own -# -# `dependency_review` runs on PUBLIC repos only: `actions/dependency-review- -# action` needs GitHub Advanced Security to read the dependency graph, which -# is off on private/internal repos (their dependency-graph API 403s), so the -# job auto-skips there — tolerated via `allowed-skips`, gate stays green. It -# is NOT filter-gated: on the repos where it runs, it runs on every -# `pull_request` event when enabled, regardless of which paths changed (a -# supply-chain scan must not be skippable by touching only "safe" paths). -# -# `zizmor` (workflow-security scan) is likewise NOT filter-gated — it runs on -# every `pull_request` event when enabled, regardless of which paths changed, -# since a security scan must not be evadable by touching only untracked paths. -# -# `gitleaks` and `trufflehog` (secret scan, one `secret_scan` toggle) are -# likewise NOT filter-gated: a secret can land in any file type. Both scan only -# the pull request's own commits (base..head), never full history, and run with -# `contents: read` only. The gitleaks job ignores the scanned repo's own -# `.gitleaks.toml`, `.gitleaksignore` and `gitleaks:allow` comments, so a pull -# request cannot allowlist its own finding. -# -# PERMISSIONS INVARIANT for jobs in this file: never request more than the -# STANDARD caller grant (contents: read, pull-requests: read, actions: write). -# A called workflow's job that requests more than its caller granted kills -# the ENTIRE run at graph validation (startup_failure) — even if the job's -# `if:` is false — taking every consumer's Merge Gate down with it. -# Anything needing an elevated scope must receive it from the caller's own -# job-level grant, never from a `permissions:` block here. The invariant -# applies TRANSITIVELY to every workflow this gate calls: a child (or a -# child's child) declaring an elevated workflow-level `permissions:` block -# poisons the whole graph the same way. -# -# `osv_scan` (default false) runs `_osv-scan.yml` on every pull_request when -# enabled — not filter-gated, same reasoning as `dependency_review`. It fits -# under this gate because `_osv-scan.yml` runs the scanner binary itself with -# `contents: read` only (no code-scanning upload), so it stays inside the -# PERMISSIONS INVARIANT. Scheduled full scans are not part of the gate; a repo -# that wants them keeps its own scheduled caller. -# -# `nix_skip_deps_only` (default false, opt-in): when enabled, `nix-validate` -# and `nix-build` are skipped for dependency-only bot PRs — the authors live -# in `scripts/ci-gate-detect-deps-only.sh` and are pinned by its self-check, -# rather than restated here where a copy silently rotted once already — -# UNLESS `flake.lock` or any `*.nix` file changed, since a lockfile bump -# must still build and a version bump in a `.nix` file can move a -# fixed-output hash. The signals are computed once in `changes` -# (`is-deps-only`, `is-flake-lock-change`, `is-nix-source-change`) and -# reused by both jobs. -# -# Fork-PR runner guard (central invariant): every `runs-on:`/`runner_label:` -# in this file resolves through -# (!github.event.pull_request || -# github.event.pull_request.head.repo.full_name == github.repository) -# && inputs.runner_label || 'ubuntu-24.04' -# The orchestration jobs (`changes`, `watchdog`, `gate`) substitute -# `inputs.orchestration_runner` for `inputs.runner_label` in that expression. -# A fork PR must never execute fork-author-controlled code on a caller's -# self-hosted RunsOn runners; same-repo PRs and non-PR events keep the -# caller's label. Centralized here so consumers (nix-ai, nix-darwin, ...) no -# longer repeat the guard on every job. Pattern reference: -# runs-on.com/configuration/job-labels (Fork PRs). -# -# Callers may include additional filters; this workflow ignores them. To add -# a new conditional check, add: input toggle, conditional job, and entry in -# the gate's `needs:` and `allowed-skips`. - -name: _ci-gate - -on: - workflow_call: - inputs: - filters: - description: >- - YAML body for dorny/paths-filter. Use the convention names listed - in this file's header to enable the matching conditional check. - Empty (default) uses the `profile` block when a profile is set, - otherwise the markdown block. A non-empty value overrides the - profile's block. - type: string - required: false - default: '' - - profile: - description: >- - Preset that supplies a default `filters` block and enables its - matching toggles: ansible, nix, tofu, python or docs. Empty - (default) keeps the explicit-input behaviour. A profile toggle is - OR-ed with its explicit input, so `profile` enables a job but cannot - turn one off. - type: string - required: false - default: '' - - nix_validate: - description: Enable `Nix Validate` (gated on `nix` filter) - type: boolean - default: false - all_systems: - description: >- - Pass --all-systems to `nix flake check`. Default true catches - darwin-only `meta.broken` packages from a linux runner. Set false - only when consumer flakes declare platform-specific check - derivations that cannot be made platform-aware (rare — prefer - fixing the flake to use `runCommandLocal` or to scope source-only - checks to the CI system). - type: boolean - default: true - nix_build: - description: >- - Enable `Nix Build` (gated on `nix` filter). Builds the flake on - macos-latest via `_nix-build.yml` (hardcoded macOS runner — that - workflow has no `runner_label` input, so the fork-PR guard below - does not apply to it). On pull_request events the build runs only - when the PR targets `main` — see the job's own comment. - type: boolean - default: false - nix_build_command: - description: >- - Build command for `Nix Build`, passed to `_nix-build.yml` as - `build-command`. Empty (default) runs the home-manager - `homeConfigurations.$(whoami).activationPackage` build. - type: string - required: false - default: '' - nix_skip_deps_only: - description: >- - Skip `nix-validate`/`nix-build` on dependency-only bot PRs (see - header). Default false — a repo opts in once its dependency-bump - PRs are stable enough to trust without a full build. - type: boolean - default: false - markdown_lint: - description: Enable `Markdown Lint` (gated on `markdown` filter) - type: boolean - default: true - file_size: - description: Enable `File Size` (gated on `nix`, `markdown`, or `terraform` filter — the org byte limits scan .md, .nix and .tf) - type: boolean - default: true - token_limits: - description: >- - Enable `Token Limits` (gated on `markdown` filter). Budgets AI-read - docs via tiktoken per .token-limits.yaml; pairs with `file_size`, - which skips token-gated files. No secret required. - type: boolean - default: false - python_security: - description: Enable `Python Security` (gated on `python` filter) - type: boolean - default: false - python_security_dirs: - description: Space-separated dirs passed to _python-security.yml - type: string - default: '.' - python_security_ignore_vulns: - description: Space-separated vulnerability IDs passed to _python-security.yml `ignore-vulns` - type: string - default: '' - python_ci: - description: Enable `Python CI` lint/format/type/test gate (gated on `python` filter) - type: boolean - default: false - python_ci_versions: - description: >- - JSON array of Python versions for the Python CI pytest matrix. Default - covers ALL currently non-EOL Python releases; a consumer narrows it - (e.g. to min + latest) by passing its own array. - type: string - default: '["3.10", "3.11", "3.12", "3.13", "3.14"]' - ansible_ci: - description: >- - Enable `Ansible CI` (ansible-lint + molecule via `_ansible-ci.yml`, - gated on `ansible` filter). Callers must define an `ansible:` key - in their `filters` input, same convention as `python`. Called with - `emit_gate: false` — this gate is the sole "Merge Gate" aggregator, - so `_ansible-ci.yml`'s own nested gate job is suppressed. - type: boolean - default: false - renovate_annotations: - description: >- - Enable `Renovate Annotations` (fails on an unannotated version pin - under a path the annotated-pin Renovate customManagers cover, via - `_renovate-annotations.yml`). NOT filter-gated — unlike most - toggles here it runs on every PR once enabled, because a caller - whose `filters` input lacks an `ansible:` key would otherwise skip - it forever with no error (see the job's own comment). The check - itself is a fast file scan, so running it unconditionally is cheap. - Ansible repos should prefer enabling it via `_ansible-ci.yml`'s own - `renovate_annotations` input instead — none of them call this - `_ci-gate.yml` directly. - type: boolean - default: false - tofu_ci: - description: >- - Enable `OpenTofu CI` (fmt/init/validate/tflint via `_tofu-ci.yml`, - gated on `terraform` filter). Callers define a `terraform:` key in - `filters` (e.g. `**.tf`, `**.tofu`, `.tflint.hcl`, `**/*.tfvars`). - type: boolean - default: false - tofu_working_directories: - description: JSON array of directories `_tofu-ci.yml` validates - type: string - default: '["."]' - osv_scan: - description: >- - Enable `OSV Scanner` on every pull_request (differential scan; not - filter-gated). Replaces a standalone osv-scan.yml caller. - type: boolean - default: false - osv_config_file: - description: Path to an osv-scanner.toml passed to _osv-scan.yml `config_file` - type: string - default: '' - dependency_review: - description: >- - Enable `Dependency Review`. Runs on PUBLIC repos only — - actions/dependency-review-action needs GitHub Advanced Security to - read the dependency graph, which is off on private/internal repos, - so the job auto-skips there (tolerated via `allowed-skips`). On - repos where it runs, it fires on every `pull_request` event, - unconditionally — NOT gated on any `filters` output, since a - supply-chain scan must not be skippable by touching only untracked - paths. - type: boolean - default: true - - agent_pr_body: - description: Enable Agent PR Body (advisory pilot, never in gate.needs) - type: boolean - default: false - agent_pr_body_block: - description: Fail Agent PR Body instead of only warning - type: boolean - default: false - - zizmor: - description: >- - Enable `Workflow Security (zizmor)`. Runs on every `pull_request` - event, unconditionally — NOT gated on any `filters` output, since a - security scan must not be skippable by touching only untracked - paths. - type: boolean - default: true - - secret_scan: - description: >- - Enable `Secret Scan (gitleaks)` and `Secret Scan (trufflehog)`. Each - runs on every `pull_request` event, unconditionally - NOT gated on - any `filters` output, since a secret can be committed in any file - type. Both scan only the commits the pull request adds. - type: boolean - default: true - - runner_label: - description: >- - GitHub Actions runner label for the check jobs in this workflow. - Also passed through to every child reusable workflow this gate calls - (`Nix Validate`, `Markdown Lint`, `File Size`, `Python Security`), - so a single input migrates the whole gate. The orchestration jobs - (`Detect Changes`, `Queue Watchdog`, `Merge Gate`) use - `orchestration_runner` instead. - Defaults to ubuntu-24.04. Pass a RunsOn label - (see runs-on.com/configuration/job-labels) to opt in. - type: string - required: false - default: ubuntu-24.04 - - orchestration_runner: - description: >- - GitHub Actions runner label for the orchestration jobs (`Detect - Changes`, `Queue Watchdog`, `Merge Gate`). Defaults to a - GitHub-hosted image, so an unavailable self-hosted pool cannot leave - the gate queued. Subject to the same fork-PR guard as `runner_label`. - type: string - required: false - default: ubuntu-24.04 - - nix_validate_runner_label: - description: >- - Runner label for `Nix Validate` only. `nix flake check` on a large - flake needs more CPU and disk than the doc checks, and a consumer - should not have to move its whole gate onto a big runner (or keep a - repo-local copy of the job) to give one job a bigger machine. Empty - (default) means "same as runner_label". Subject to the same fork-PR - guard. - type: string - required: false - default: '' - - nix_validate_timeout_minutes: - description: >- - Opt-in running-job timeout for `Nix Validate` only, forwarded as - `timeout_minutes` to `_nix-validate.yml`. 0 (default) keeps the org - variable GH_ACTION_TIMEOUT_NIX (fallback 15). Use only for a repo - whose cold `nix flake check` closure is known to exceed the default. - type: number - required: false - default: 0 - - ansible_lint_args: - description: >- - Extra arguments for ansible-lint, forwarded to `_ansible-ci.yml`'s - `lint_args`, for example `tests/canary/ansible` to lint one directory. - Empty (default) keeps the lint scope unchanged. - type: string - required: false - default: '' - - workflow_lint: - description: >- - Enable `Workflow Lint`: actionlint over the caller's `.github/workflows/`, - so a broken reusable-workflow contract (an unknown input, a missing - file) fails the gate instead of making the run a missing check. Not - filter-gated. Off by default during rollout. - type: boolean - default: false - - test_command: - description: >- - Shell command that runs this repo's own unit tests as the `Repo Tests` - check, for example `bun test`. Empty (default) turns the check off. - Node 22 is always set up. When `bun.lock` or `bun.lockb` exists, bun - is installed with `bun install --frozen-lockfile` first. - type: string - required: false - default: '' - - queue_timeout_minutes: - description: >- - Minutes the watchdog waits before cancelling sibling jobs still in - `queued` state. Default 10. Cap your slowest gate-path job at well - below this value or it will be killed before it starts. - type: number - default: 10 - - # Export the change detection this workflow already performs. Without - # these, a caller that needs to gate a job of its own has no way to reach - # the answer and has to re-run dorny/paths-filter in a duplicate `changes` - # job — which is what tofu-proxmox, nix-darwin and mlx-benchmarks each do - # today, with three copies of the filter body free to drift apart. - # `_ansible-ci.yml` already exports its filters; this matches it. - outputs: - nix: - description: Whether the change touched Nix paths - value: ${{ jobs.changes.outputs.nix }} - markdown: - description: Whether the change touched Markdown paths - value: ${{ jobs.changes.outputs.markdown }} - python: - description: Whether the change touched Python paths - value: ${{ jobs.changes.outputs.python }} - ansible: - description: Whether the change touched Ansible paths - value: ${{ jobs.changes.outputs.ansible }} - extra: - description: >- - Whether the change touched the caller's own `extra` filter key, for - gating one repo-local job this gate cannot host. Empty when the - caller defines no such key. - value: ${{ jobs.changes.outputs.extra }} - is-deps-only: - description: Whether the pull request is a dependency-only update - value: ${{ jobs.changes.outputs.is-deps-only }} - is-flake-lock-change: - description: Whether the change touched flake.lock - value: ${{ jobs.changes.outputs.is-flake-lock-change }} - scope_ci: - description: >- - Content-aware scope classifier's ci output (full | lint-only | - none), for a caller that wants to gate a job of its own the same - way this file's own jobs are gated. Empty when `scope` failed - - treat empty the same as "full" (defer to your own filters), never - as "none". - value: ${{ jobs.scope.outputs.ci }} - scope_molecule: - description: Scope classifier's molecule output (full | changed-roles | none) - value: ${{ jobs.scope.outputs.molecule }} - scope_source: - description: jev | fallback - whether the scope outputs came from the classifier or a fail-safe default - value: ${{ jobs.scope.outputs.source }} - - secrets: - TYPESAFE_API_KEY: - description: >- - Bearer token for the scope classifier's typesafe.ai API call. - Not required: a caller that hasn't plumbed this secret through - yet gets an empty key, which the classifier's own fail-safe - already turns into source=fallback, full/yes - never a startup - error. - required: false - GH_APP_PRIVATE_KEY: - description: >- - Optional. Lets the flake freshness check resolve private flake - inputs; public inputs resolve without it. - required: false - -permissions: - contents: read - pull-requests: read - -jobs: - # ============================================================================ - # CONTENT-AWARE SCOPE CLASSIFIER - # Always runs (no `if:`), independent of `changes` below. Its outputs are - # a SECOND, additional layer on top of the deterministic path filters - # `changes` already computes - narrowing further, never replacing them - # (each conditional job below still carries its original filter-based - # `if:`). `allowed-failures: scope` in the gate aggregate means a - # classifier infra failure is visible as a red job but never blocks the - # Merge Gate; every job below only narrows when `needs.scope.result == - # 'success'` AND the output says so, so a scope failure or an empty - # secret defers entirely to the pre-existing filter logic (the classifier - # is unreachable = everything the filters already decided still runs). - # https://github.com/JacobPEvans/.github-ai/blob/v1/.github/scope-rubric.md - scope: - name: Classify CI scope (Jev) - uses: JacobPEvans/.github-ai/.github/workflows/scope-classify.yml@v1 - secrets: - TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }} - - # ============================================================================ - # CHANGE DETECTION - # ============================================================================ - changes: - name: Detect Changes - runs-on: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.orchestration_runner || 'ubuntu-24.04' }} - timeout-minutes: 5 - outputs: - nix: ${{ steps.filter.outputs.nix == 'true' || (inputs.profile == 'nix' && steps.unmapped.outputs.full == 'true') }} - markdown: ${{ steps.filter.outputs.markdown == 'true' || (inputs.profile == 'docs' && steps.unmapped.outputs.full == 'true') }} - python: ${{ steps.filter.outputs.python == 'true' || (inputs.profile == 'python' && steps.unmapped.outputs.full == 'true') }} - ansible: ${{ steps.filter.outputs.ansible == 'true' || (inputs.profile == 'ansible' && steps.unmapped.outputs.full == 'true') }} - terraform: ${{ steps.filter.outputs.terraform == 'true' || (inputs.profile == 'tofu' && steps.unmapped.outputs.full == 'true') }} - extra: ${{ steps.filter.outputs.extra }} - profile-unmapped: ${{ steps.unmapped.outputs.full == 'true' }} - is-deps-only: ${{ steps.detect-deps.outputs.is-deps-only }} - is-flake-lock-change: ${{ steps.lockfilter.outputs.flake-lock }} - is-nix-source-change: ${{ steps.lockfilter.outputs.nix-source }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - # One filter body: an explicit `filters` input wins, else the profile's - # block, else the markdown default (the behaviour with no profile set). - - name: Resolve filter body - id: resolve - env: - PROFILE: ${{ inputs.profile }} - CALLER_FILTERS: ${{ inputs.filters }} - run: | - set -euo pipefail - markdown='markdown: - - "**.md" - - ".markdownlint*"' - case "$PROFILE" in - "") block="" ;; - ansible) block='ansible: - - "ansible/**" - - "roles/**" - - "playbooks/**" - - "inventory/**" - - "**/molecule/**" - - "**/group_vars/**" - - "**/host_vars/**" - - "requirements.yml" - - ".ansible-lint*"' ;; - nix) block='nix: - - "**.nix" - - "flake.lock"' ;; - tofu) block='terraform: - - "**.tf" - - "**.tofu" - - ".tflint.hcl" - - "**/*.tfvars"' ;; - python) block='python: - - "**.py" - - "pyproject.toml" - - "uv.lock" - - "**/requirements*.txt" - - "setup.cfg" - - "setup.py"' ;; - docs) block="" ;; - *) - echo "::error::unknown profile '$PROFILE' (expected ansible, nix, tofu, python or docs)" - exit 1 - ;; - esac - if [[ -n "$CALLER_FILTERS" ]]; then - body="$CALLER_FILTERS" - elif [[ -n "$block" ]]; then - body="$markdown"$'\n'"$block" - else - body="$markdown" - fi - if [[ -n "$PROFILE" ]]; then - body="$body"$'\n'"changed-all:"$'\n'" - \"**\"" - fi - { - echo "filters<> "$GITHUB_OUTPUT" - - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 - id: filter - with: - # On a push, comparing to the same long-lived branch makes the - # action use the commit immediately before the push as its base. - # PR events continue to compare against their PR base branch. - base: ${{ github.event_name == 'push' && github.ref || '' }} - filters: ${{ steps.resolve.outputs.filters }} - # Lists each filter's matched files; `changed-all` (appended by the - # resolve step under a profile) lists every changed file. - list-files: ${{ inputs.profile != '' && 'json' || 'none' }} - - # Static (not caller-supplied) so `is-flake-lock-change` and - # `is-nix-source-change` are always available regardless of how a - # caller's `filters` input defines its own `nix` key. - - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 - id: lockfilter - with: - # Keep static lock/source classification aligned with the caller's - # push range; PR events continue to use their PR base branch. - base: ${{ github.event_name == 'push' && github.ref || '' }} - filters: | - flake-lock: - - 'flake.lock' - nix-source: - - '**.nix' - - - name: Sparse-checkout shared scripts from this repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - repository: dryvist/.github - # The commit this reusable workflow was loaded from, NOT main. - # Hardcoding `main` made a workflow running from a branch pull its - # scripts from a different commit than the one under test: the - # self-check below ran main's copy, which has no --self-check flag, - # so it ignored the argument, did its normal work and exited 0 — - # green, having verified nothing. The same mismatch pairs a changed - # workflow with an incompatible script in ordinary use. Must be - # `job.workflow_sha`, not `github.job_workflow_sha`: the latter is - # empty whenever this workflow runs nested two levels deep (a - # caller invokes this workflow, which invokes another), and an - # empty `ref` silently checks out the default branch instead. - ref: ${{ job.workflow_sha }} - path: .gh-shared - sparse-checkout: | - scripts/ci-gate-detect-deps-only.sh - scripts/ci-gate-unmapped.sh - sparse-checkout-cone-mode: false - - # A profile must never skip a path it does not map: a changed file (docs - # excepted) that no filter covers runs the full profile with a warning. - - name: Flag paths no profile filter covers - id: unmapped - if: ${{ inputs.profile != '' }} - env: - ALL: ${{ steps.filter.outputs.changed-all_files }} - NIX: ${{ steps.filter.outputs.nix_files }} - MARKDOWN: ${{ steps.filter.outputs.markdown_files }} - PYTHON: ${{ steps.filter.outputs.python_files }} - ANSIBLE: ${{ steps.filter.outputs.ansible_files }} - TERRAFORM: ${{ steps.filter.outputs.terraform_files }} - EXTRA: ${{ steps.filter.outputs.extra_files }} - run: bash .gh-shared/scripts/ci-gate-unmapped.sh - - # Milliseconds, and it is the only thing standing between a stale author - # literal and a detector that answers "false" forever without saying so. - - name: Verify the deps-only detector still classifies - run: bash .gh-shared/scripts/ci-gate-detect-deps-only.sh --self-check - - - name: Detect Dependency-Only Commits - id: detect-deps - env: - AUTHOR: ${{ github.event.pull_request.user.login }} - COMMIT_MSG: ${{ github.event.pull_request.title }} - run: bash .gh-shared/scripts/ci-gate-detect-deps-only.sh - - # ============================================================================ - # CONDITIONAL CHECKS - # Each is gated on `inputs.` AND `needs.changes.outputs.`. - # Disabled or untouched paths skip; the gate aggregate accepts configured skips. - # ============================================================================ - nix-validate: - name: Nix Validate - needs: [changes, scope] - # Second layer on top of the filter-based condition: only narrows - # further when `scope` definitively succeeded and said "none" - a - # scope failure (or a caller with no TYPESAFE_API_KEY plumbed through) - # defers entirely to the filter, exactly as before this job existed. - if: ${{ !cancelled() && (inputs.nix_validate || inputs.profile == 'nix') && needs.changes.outputs.nix == 'true' && (inputs.nix_skip_deps_only == false || needs.changes.outputs.is-deps-only != 'true' || needs.changes.outputs.is-flake-lock-change == 'true' || needs.changes.outputs.is-nix-source-change == 'true') && (needs.changes.outputs.profile-unmapped == 'true' || needs.scope.result != 'success' || needs.scope.outputs.ci != 'none') }} - # Use the canonical v1 validator while validating changes to this reusable - # workflow repository. - uses: JacobPEvans/.github/.github/workflows/_nix-validate.yml@v1 - with: - all_systems: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && inputs.all_systems }} - focused_checks: ${{ github.event_name != 'push' || github.ref != 'refs/heads/main' }} - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (inputs.nix_validate_runner_label || inputs.runner_label) || 'ubuntu-24.04' }} - timeout_minutes: ${{ inputs.nix_validate_timeout_minutes }} - - # macos-latest is hardcoded in _nix-build.yml (no runner_label input), so - # the fork-PR guard doesn't apply here. - # - # PR-base gate: on pull_request events the full build runs only when the PR - # targets `main`. This is the scope policy's end-to-end sweep placed at the - # one point it is guaranteed: a trunk repo's every PR targets main - # (unaffected), while a git-flow repo's develop PRs take the fast path - # (`nix-validate`) and the develop->main promotion PR runs the full build. - # The base-ref check lives in this `if:` rather than an input default - # because workflow_call input defaults must be static — they cannot read - # `github.base_ref`. Non-PR events (push, schedule, dispatch) keep building. - nix-build: - name: Nix Build - needs: [changes, scope] - # Full-suite tier: only narrows when `scope` succeeded AND said - # something other than "full" (lint-only or none skip it); a scope - # failure defers to the filter/base-ref condition alone, same as - # before this job existed. - if: ${{ !cancelled() && inputs.nix_build && (github.event_name != 'pull_request' || github.base_ref == 'main') && needs.changes.outputs.nix == 'true' && (inputs.nix_skip_deps_only == false || needs.changes.outputs.is-deps-only != 'true' || needs.changes.outputs.is-flake-lock-change == 'true' || needs.changes.outputs.is-nix-source-change == 'true') && (needs.changes.outputs.profile-unmapped == 'true' || needs.scope.result != 'success' || (needs.scope.outputs.ci != 'lint-only' && needs.scope.outputs.ci != 'none')) }} - uses: JacobPEvans/.github/.github/workflows/_nix-build.yml@v1 - with: - build-command: ${{ inputs.nix_build_command }} - - markdown-lint: - name: Markdown Lint - needs: changes - if: ${{ !cancelled() && inputs.markdown_lint && needs.changes.outputs.markdown == 'true' }} - uses: JacobPEvans/.github/.github/workflows/_markdown-lint.yml@v1 - with: - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - file-size: - name: File Size - needs: changes - if: ${{ !cancelled() && inputs.file_size && (needs.changes.outputs.nix == 'true' || needs.changes.outputs.markdown == 'true' || needs.changes.outputs.terraform == 'true') }} - uses: JacobPEvans/.github/.github/workflows/_file-size.yml@v1 - with: - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - token-limits: - name: Token Limits - needs: [changes, scope] - if: ${{ !cancelled() && inputs.token_limits && needs.changes.outputs.markdown == 'true' && (needs.scope.result != 'success' || needs.scope.outputs.ci != 'none') }} - uses: JacobPEvans/.github/.github/workflows/_token-limits.yml@v1 - with: - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - python-security: - name: Python Security - needs: [changes, scope] - if: ${{ !cancelled() && (inputs.python_security || inputs.profile == 'python') && needs.changes.outputs.python == 'true' && (needs.changes.outputs.profile-unmapped == 'true' || needs.scope.result != 'success' || needs.scope.outputs.ci != 'none') }} - uses: JacobPEvans/.github/.github/workflows/_python-security.yml@v1 - with: - python-dirs: ${{ inputs.python_security_dirs }} - ignore-vulns: ${{ inputs.python_security_ignore_vulns }} - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - # No `secrets: inherit` — _python-security.yml declares and consumes no - # secrets, and unconditional inheritance trips zizmor's secrets-inherit - # audit. (Divergence from the JacobPEvans-personal original, on purpose.) - - python-ci: - name: Python CI - needs: [changes, scope] - # Full-tier: this runs the full lint/format/type/test matrix, not just - # a fast validate - only narrows when scope succeeded and said - # something other than full. - if: ${{ !cancelled() && (inputs.python_ci || inputs.profile == 'python') && needs.changes.outputs.python == 'true' && (needs.changes.outputs.profile-unmapped == 'true' || needs.scope.result != 'success' || (needs.scope.outputs.ci != 'lint-only' && needs.scope.outputs.ci != 'none')) }} - uses: JacobPEvans/.github/.github/workflows/_python-ci.yml@v1 - with: - python_versions: ${{ inputs.python_ci_versions }} - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - # dependency-review is intentionally NOT `needs: changes` / filter-gated — - # a supply-chain scan must run on every pull_request regardless of which - # paths a diff touches. It IS gated to public repos: dependency-review- - # action needs GHAS to read the dependency graph, which is off on - # private/internal repos (403s there) — those skip, which the gate - # tolerates via allowed-skips. - dependency-review: - name: Dependency Review - if: ${{ inputs.dependency_review && github.event_name == 'pull_request' && github.event.repository.private == false }} - runs-on: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - timeout-minutes: 10 - # contents:read ONLY — a called workflow's job may never request more than - # the caller granted, or the whole run dies at graph validation - # (startup_failure) before any job starts. `pull-requests: write` (for - # comment-summary-in-pr) exceeded the standard caller grant and broke - # every consumer's gate; failure detail lives in the check-run output - # instead of a PR comment. - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 - with: - fail-on-severity: moderate - - # zizmor is intentionally NOT `needs: changes` / filter-gated — a workflow- - # security scan must run on every pull_request regardless of which paths a - # diff touches. - zizmor: - name: Workflow Security (zizmor) - if: ${{ inputs.zizmor && github.event_name == 'pull_request' }} - uses: JacobPEvans/.github/.github/workflows/_zizmor.yml@v1 - with: - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - # Deliberately NOT in `gate.needs` — advisory pilot, warns only (see - # _agent-pr-body.yml); not gated on `changes` (no path filter applies). - agent-pr-body: - name: Agent PR Body - if: ${{ inputs.agent_pr_body && github.event_name == 'pull_request' }} - uses: JacobPEvans/.github/.github/workflows/_agent-pr-body.yml@v1 - with: - block: ${{ inputs.agent_pr_body_block }} - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - # No caller currently sets `ansible_ci: true` here (ansible repos call - # `_ansible-ci.yml` directly, with their own local molecule handling) - - # this wiring is inert today, kept correct for whenever one does. - ansible-ci: - name: Ansible CI - needs: [changes, scope] - if: ${{ !cancelled() && (inputs.ansible_ci || inputs.profile == 'ansible') && needs.changes.outputs.ansible == 'true' && (needs.changes.outputs.profile-unmapped == 'true' || needs.scope.result != 'success' || needs.scope.outputs.ci != 'none') }} - uses: JacobPEvans/.github/.github/workflows/_ansible-ci.yml@v1 - with: - emit_gate: false - lint_args: ${{ inputs.ansible_lint_args }} - # scope's `molecule` output is a second layer on `_ansible-ci.yml`'s - # own molecule toggle - only turns it off when scope definitively - # said "none"; any other answer, or a scope failure, leaves it on. - molecule: ${{ needs.scope.result != 'success' || needs.scope.outputs.molecule != 'none' }} - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - # NOT filter-gated (same convention as osv-scan/dependency-review below): a - # caller's `filters` input may not define an `ansible:` key at all, in - # which case `needs.changes.outputs.ansible` is an empty string forever — - # neither 'true' nor 'false' — and gating this job on it would silently - # SKIP a toggled-on check on every such caller, going green having never - # run. A toggled-on job must never skip for that reason, so this runs - # unconditionally once `inputs.renovate_annotations` is true. - renovate-annotations: - name: Renovate Annotations - needs: changes - if: ${{ inputs.renovate_annotations }} - uses: JacobPEvans/.github/.github/workflows/_renovate-annotations.yml@v1 - with: - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - tofu-ci: - name: OpenTofu CI - needs: [changes, scope] - if: ${{ !cancelled() && (inputs.tofu_ci || inputs.profile == 'tofu') && needs.changes.outputs.terraform == 'true' && (needs.changes.outputs.profile-unmapped == 'true' || needs.scope.result != 'success' || needs.scope.outputs.ci != 'none') }} - uses: JacobPEvans/.github/.github/workflows/_tofu-ci.yml@v1 - with: - working_directories: ${{ inputs.tofu_working_directories }} - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - # osv-scan is NOT filter-gated — a vulnerability scan must run on every - # pull_request regardless of which paths a diff touches. - osv-scan: - name: OSV Scanner - if: ${{ inputs.osv_scan && github.event_name == 'pull_request' }} - uses: JacobPEvans/.github/.github/workflows/_osv-scan.yml@v1 - with: - config_file: ${{ inputs.osv_config_file }} - runner_label: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - - # Secret scanners are NOT `needs: changes` / filter-gated - a secret can be - # committed in any file type. Both scan only the PR's own commits - # (base..head), never full history, so existing history cannot fail a PR. - # - # gitleaks runs the pinned release binary: the gitleaks GitHub Action needs a - # licence key for organisations. The generated config extends the default - # ruleset and is passed explicitly, and `.gitleaksignore` and `gitleaks:allow` - # comments are disabled, so the PR under scan cannot allowlist its own finding. - # Both pinned binaries carry a Renovate annotation on the version; the sha256 beside - # each is not Renovate-tracked, so a version bump fails closed until it is updated too. - secret-scan-gitleaks: - name: Secret Scan (gitleaks) - if: ${{ inputs.secret_scan && github.event_name == 'pull_request' }} - runs-on: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - timeout-minutes: 5 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - persist-credentials: false - - name: Install the pinned gitleaks release - env: - GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb - run: | - set -euo pipefail - # renovate: datasource=github-releases depName=gitleaks/gitleaks - GITLEAKS_VERSION="v8.30.1" - archive="${RUNNER_TEMP}/gitleaks.tar.gz" - curl -fsSL --retry 3 -o "$archive" \ - "https://github.com/gitleaks/gitleaks/releases/download/${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION#v}_linux_x64.tar.gz" - echo "${GITLEAKS_SHA256} ${archive}" | sha256sum --check --strict - tar -xzf "$archive" -C "$RUNNER_TEMP" gitleaks - - name: Scan the commits this pull request adds - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - printf '[extend]\nuseDefault = true\n' > "${RUNNER_TEMP}/gitleaks.toml" - mkdir "${RUNNER_TEMP}/gitleaks-no-ignore" - "${RUNNER_TEMP}/gitleaks" git \ - --config "${RUNNER_TEMP}/gitleaks.toml" \ - --gitleaks-ignore-path "${RUNNER_TEMP}/gitleaks-no-ignore" \ - --ignore-gitleaks-allow \ - --log-opts "${BASE_SHA}..${HEAD_SHA}" \ - --redact --no-banner --exit-code 1 . - - # trufflehog runs the pinned release binary too, so it needs no Docker on the - # runner. `--since-commit` + `--branch ` scans exactly base..head, and - # `--fail-on-scan-errors` keeps a scan that could not read the range from - # passing silently. `verified,unknown` fails on live credentials and on - # candidates the verifier could not check; a known-dead candidate is not - # reported, so gitleaks above is the check that catches those. `--no-ignore-tag` - # makes inline `trufflehog:ignore` comments count as findings. - secret-scan-trufflehog: - name: Secret Scan (trufflehog) - if: ${{ inputs.secret_scan && github.event_name == 'pull_request' }} - runs-on: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - timeout-minutes: 5 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - persist-credentials: false - - name: Install the pinned trufflehog release - env: - TRUFFLEHOG_SHA256: 3c7b0d1dc4278294269bfdd7594bca8bb28c78da0a4a06ad202ded37b48deca4 - run: | - set -euo pipefail - # renovate: datasource=github-releases depName=trufflesecurity/trufflehog - TRUFFLEHOG_VERSION="v3.99.2" - archive="${RUNNER_TEMP}/trufflehog.tar.gz" - curl -fsSL --retry 3 -o "$archive" \ - "https://github.com/trufflesecurity/trufflehog/releases/download/${TRUFFLEHOG_VERSION}/trufflehog_${TRUFFLEHOG_VERSION#v}_linux_amd64.tar.gz" - echo "${TRUFFLEHOG_SHA256} ${archive}" | sha256sum --check --strict - tar -xzf "$archive" -C "$RUNNER_TEMP" trufflehog - - name: Scan the commits this pull request adds - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - "${RUNNER_TEMP}/trufflehog" git "file://${GITHUB_WORKSPACE}" \ - --since-commit "$BASE_SHA" --branch "$HEAD_SHA" \ - --results=verified,unknown --fail --fail-on-scan-errors \ - --no-ignore-tag --no-update - - # ============================================================================ - # WATCHDOG - # Cancels the run if queued sibling jobs remain after queue_timeout_minutes. - # This leaves the required gate non-green rather than accepting partial CI. - # ============================================================================ - watchdog: - name: Queue Watchdog - needs: changes - runs-on: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.orchestration_runner || 'ubuntu-24.04' }} - permissions: - actions: write # required to cancel this run on a queue timeout - contents: read # required for actions/checkout sparse-checkout - # Hardcoded ceiling — GHA expressions don't support arithmetic so this - # can't be derived from `queue_timeout_minutes`. The watchdog lives as long - # as the run and stops itself at WATCH_MAX_MINUTES (55), inside this ceiling. - timeout-minutes: 60 - steps: - - name: Sparse-checkout watchdog script from this repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - repository: dryvist/.github - ref: ${{ job.workflow_sha }} - path: .gh-shared - sparse-checkout: scripts/ci-gate-watchdog.sh - sparse-checkout-cone-mode: false - - name: Poll queue, then cancel the run if jobs remain queued - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - QUEUE_TIMEOUT_MINUTES: ${{ inputs.queue_timeout_minutes }} - REPO: ${{ github.repository }} - RUN_ID: ${{ github.run_id }} - run: bash .gh-shared/scripts/ci-gate-watchdog.sh - - # Owned root flake inputs: on a pull request, no pinned revs and no move to a - # rev that does not descend from the base lock's; on the default branch, - # stale inputs dispatch the targeted relock. Passes when the repo has no flake.lock. - flake-lock-fresh: - name: Flake Lock Fresh - if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'schedule' || (github.event_name == 'push' && github.ref_name == github.event.repository.default_branch)) }} - uses: ./.github/workflows/_flake-lock-fresh.yml - secrets: - GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} - - # Every reusable-workflow `uses:` in the caller must resolve: a missing local - # file, or a dryvist ref absent at its ref, fails the gate. Not filter-gated - # and never skipped, so a broken reference cannot merge green. - uses-resolve: - name: Uses Resolve - runs-on: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.orchestration_runner || 'ubuntu-24.04' }} - timeout-minutes: 5 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Sparse-checkout the uses checker from this repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - repository: dryvist/.github - ref: ${{ job.workflow_sha }} - path: .gh-shared - sparse-checkout: scripts/ci-gate-uses-resolve.sh - sparse-checkout-cone-mode: false - - name: Check every reusable workflow reference resolves - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bash .gh-shared/scripts/ci-gate-uses-resolve.sh - - # The caller's own unit tests, run as one gate check so they can block a - # merge. Set `test_command` to the exact command the repo runs locally. Not - # filter-gated: when set, it runs on every event, like renovate-annotations. - repo-tests: - name: Repo Tests - if: ${{ inputs.test_command != '' }} - runs-on: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - timeout-minutes: 15 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: '22' - - name: Set up bun - if: ${{ hashFiles('bun.lock', 'bun.lockb') != '' }} - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - - name: Install bun dependencies - if: ${{ hashFiles('bun.lock', 'bun.lockb') != '' }} - run: bun install --frozen-lockfile - - name: Run repo tests - env: - TEST_COMMAND: ${{ inputs.test_command }} - run: bash -c "$TEST_COMMAND" - - # actionlint over the caller's workflows, so a broken reusable-workflow - # contract fails here instead of making the run a missing check. Not - # filter-gated. The binary is a pinned release, checksum-verified by the script. - workflow-lint: - name: Workflow Lint - if: ${{ inputs.workflow_lint }} - runs-on: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.runner_label || 'ubuntu-24.04' }} - timeout-minutes: 5 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Sparse-checkout the actionlint runner from this repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - repository: dryvist/.github - ref: ${{ job.workflow_sha }} - path: .gh-shared - sparse-checkout: scripts/ci-gate-workflow-lint.sh - sparse-checkout-cone-mode: false - - name: Lint caller workflows with the pinned actionlint - run: bash .gh-shared/scripts/ci-gate-workflow-lint.sh - - # ============================================================================ - # MERGE GATE — the only check that branch protection requires. - # `name:` MUST stay "Merge Gate" (matches existing required_status_checks). - # ============================================================================ - gate: - name: Merge Gate - needs: [changes, scope, watchdog, nix-validate, nix-build, markdown-lint, file-size, token-limits, python-security, python-ci, ansible-ci, renovate-annotations, tofu-ci, osv-scan, secret-scan-gitleaks, secret-scan-trufflehog, dependency-review, zizmor, flake-lock-fresh, uses-resolve, repo-tests, workflow-lint] - # Run after workflow cancellation too, so cancelled sibling checks aggregate - # to a failing required status instead of a skipped (successful) job. - if: ${{ always() }} - runs-on: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && inputs.orchestration_runner || 'ubuntu-24.04' }} - timeout-minutes: 5 - permissions: - # `actions: read` lets the run-report step read this run's jobs (durations - # + queue delays) via the Actions API; `contents: read` is for the sparse - # checkout of the report script. Both stay within the STANDARD caller grant - # (contents: read, pull-requests: read, actions: write), so this never - # exceeds what the caller granted — see the PERMISSIONS INVARIANT header. - actions: read - contents: read - steps: - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: '22' - # The run report is non-blocking; the aggregator is required to produce - # the Merge Gate result. - - name: Sparse-checkout gate scripts from this repo - if: ${{ always() }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - repository: dryvist/.github - ref: ${{ job.workflow_sha }} - path: .gh-shared - sparse-checkout: | - scripts/ci-gate-aggregate.mts - scripts/ci-gate-run-report.sh - sparse-checkout-cone-mode: false - - name: Aggregate results - env: - CI_GATE_JOBS: ${{ toJSON(needs) }} - CI_GATE_ALLOWED_SKIPS: >- - nix-validate, nix-build, markdown-lint, file-size, token-limits, - python-security, python-ci, ansible-ci, renovate-annotations, - tofu-ci, osv-scan, secret-scan-gitleaks, secret-scan-trufflehog, - dependency-review, zizmor, watchdog, - flake-lock-fresh, repo-tests, workflow-lint - CI_GATE_ALLOWED_FAILURES: scope - run: node .gh-shared/scripts/ci-gate-aggregate.mts - # Run after every sibling is terminal, when timestamps are final. - - name: Job durations + queue-delay summary - if: ${{ always() }} - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - RUN_ID: ${{ github.run_id }} - run: bash .gh-shared/scripts/ci-gate-run-report.sh diff --git a/.github/workflows/_disclosure-gate.yml b/.github/workflows/_disclosure-gate.yml deleted file mode 100644 index 2b3c6e9..0000000 --- a/.github/workflows/_disclosure-gate.yml +++ /dev/null @@ -1,117 +0,0 @@ -# Reusable: Disclosure Gate -# -# Two independent checks that keep sensitive values out of a public repo: -# -# - "gitleaks (redacted)" — same pattern as dryvist/docs' secret-scan.yml: -# gitleaks-action with the org GITLEAKS_PRIVATE_CONFIG materialized to a -# runner-local file at run time. PR comments, the SARIF artifact and the -# job summary are all disabled, and the action runs gitleaks with -# --redact — a catch never echoes the matched secret. -# - "disclosure-denylist" — a fixed-string scan against the org -# DISCLOSURE_DENYLIST secret (one term per line), run by -# scripts/disclosure-denylist-scan.sh over the PR diff plus the PR title -# and body. A hit prints only `path:line`, never the matched term or -# line content — the denylist itself is sensitive, so a verbose catch -# would re-leak exactly what it caught. -# -# Kept separate from _ci-gate.yml so the two secrets these jobs need stay -# scoped to this workflow, not the whole gate surface. -name: _disclosure-gate - -on: - workflow_call: - inputs: - runner_label: - description: >- - GitHub Actions runner label. Defaults to ubuntu-24.04; pass a - self-hosted label to opt the calling repo into on-prem runners. - type: string - required: false - default: ubuntu-24.04 - secrets: - GITLEAKS_PRIVATE_CONFIG: - description: Org gitleaks ruleset (dryvist org secret). Required. - required: true - GITLEAKS_LICENSE_KEY: - description: Org gitleaks-action license key. Required. - required: true - DISCLOSURE_DENYLIST: - description: >- - Org denylist, one fixed string per line (dryvist org secret). - Declared optional here (not `required: true`) so a caller that - inherits it before it exists gets a clear, diagnosable job - failure from the scan step below instead of an opaque - workflow-graph startup failure with no logs. - required: false - -permissions: - contents: read - pull-requests: read - -jobs: - gitleaks: - name: gitleaks (redacted) - runs-on: ${{ inputs.runner_label }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Materialize the org ruleset into a config file - env: - GITLEAKS_PRIVATE_CONFIG: ${{ secrets.GITLEAKS_PRIVATE_CONFIG }} - run: printf '%s' "$GITLEAKS_PRIVATE_CONFIG" > "$RUNNER_TEMP/gitleaks.toml" - - - uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE_KEY }} - GITLEAKS_CONFIG: ${{ runner.temp }}/gitleaks.toml - GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "false" - GITLEAKS_ENABLE_COMMENTS: "false" - GITLEAKS_ENABLE_SUMMARY: "false" - - disclosure-denylist: - name: disclosure-denylist - runs-on: ${{ inputs.runner_label }} - timeout-minutes: 10 - steps: - - name: Checkout caller repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Sparse-checkout the shared scanner from this repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - repository: dryvist/.github - ref: ${{ job.workflow_sha }} - path: .gh-shared - sparse-checkout: | - scripts/disclosure-denylist-scan.sh - sparse-checkout-cone-mode: false - persist-credentials: false - - - name: Materialize the org denylist into a file - # An empty/unset secret writes an empty file; the scanner below - # (scripts/disclosure-denylist-scan.sh) already treats an - # empty/missing denylist file as an instrument error (exit 2 with - # a clear ::error:: message), so no extra guard is needed here. - env: - DISCLOSURE_DENYLIST: ${{ secrets.DISCLOSURE_DENYLIST }} - run: printf '%s' "$DISCLOSURE_DENYLIST" > "$RUNNER_TEMP/denylist.txt" - - - name: Scan diff, title and body - env: - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - set -euo pipefail - base="${BASE_SHA:-HEAD~1}" - bash .gh-shared/scripts/disclosure-denylist-scan.sh \ - "$RUNNER_TEMP/denylist.txt" "$base" diff --git a/.github/workflows/_flake-lock-fresh.yml b/.github/workflows/_flake-lock-fresh.yml deleted file mode 100644 index 81c30d1..0000000 --- a/.github/workflows/_flake-lock-fresh.yml +++ /dev/null @@ -1,130 +0,0 @@ -# Reusable: check owned flake inputs and optionally age-check root GitHub inputs. -# -# Checks the caller's root flake.lock with scripts/flake-lock-fresh.sh. The existing -# owned-input policy remains enabled for every caller. Set max_age_days to additionally -# check third-party root GitHub inputs for updates older than the specified age. -# Pull request: compared with the base branch's lock (--pr); fails on a pinned rev or -# an owned input moved to a rev that does not descend from the base one. -# Other events: compared with live ref heads; stale inputs are reported and an -# update-flake-input repository_dispatch starts the caller's targeted relock. A -# dryvist input must name a floating major tag (vN); its locked rev is fresh while -# it is in that tag's history. A dryvist input naming no ref or a branch fails. -# A repo without a flake.lock passes. Pass GH_APP_PRIVATE_KEY when the lock has -# private inputs; without it, a private input cannot be resolved and fails the check. -name: _flake-lock-fresh - -on: - workflow_call: - inputs: - runner_label: - description: >- - GitHub Actions runner label. Defaults to the GitHub-hosted ubuntu-24.04 - image. Pass a self-hosted label, or a RunsOn label in the pattern - _update-flake-lock.yml uses (`runs-on=/runner=2cpu-linux-x64`, - with the caller's run id), to run the check elsewhere. - type: string - required: false - default: ubuntu-24.04 - max_age_days: - description: >- - Maximum age in days for a root GitHub input that has newer commits on its - locked ref. Defaults to 0, which disables the age check. - type: number - required: false - default: 0 - secrets: - GH_APP_PRIVATE_KEY: - required: false - -permissions: {} - -jobs: - fresh: - name: Flake inputs at branch head - runs-on: ${{ inputs.runner_label }} - timeout-minutes: 5 - permissions: - contents: read - env: - HAS_APP_KEY: ${{ secrets.GH_APP_PRIVATE_KEY != '' }} - MAX_AGE_DAYS: ${{ inputs.max_age_days }} - steps: - - name: Checkout flake.lock - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - sparse-checkout: flake.lock - sparse-checkout-cone-mode: false - - - name: Checkout the base branch's flake.lock - if: github.event_name == 'pull_request' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .base - persist-credentials: false - sparse-checkout: flake.lock - sparse-checkout-cone-mode: false - - # The token is scoped to the input repositories this lock names under the - # caller's owner, never the whole installation. - - name: List same-owner input repositories - id: input-repos - if: env.HAS_APP_KEY == 'true' && hashFiles('flake.lock') != '' - env: - OWNER: ${{ github.repository_owner }} - run: | - repos=$(jq -r --arg o "$OWNER" '[.nodes[] | .original? | select(.type? == "github" - and (.owner | ascii_downcase) == ($o | ascii_downcase)) | .repo] | unique | join(",")' flake.lock) - echo "repos=$repos" >> "$GITHUB_OUTPUT" - - - name: Mint read token for those repositories - id: app-token - if: steps.input-repos.outputs.repos != '' - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3 - with: - client-id: ${{ vars.GH_APP_CLIENT_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - repositories: ${{ steps.input-repos.outputs.repos }} - permission-contents: read - - - name: Checkout the check script - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - repository: dryvist/.github - ref: ${{ job.workflow_sha }} - path: .gh-shared - persist-credentials: false - sparse-checkout: scripts/flake-lock-fresh.sh - sparse-checkout-cone-mode: false - - - name: Check flake input freshness - id: check - env: - OWNERS: ${{ vars.FLAKE_INPUT_OWNERS }} - GIT_TOKEN: ${{ steps.app-token.outputs.token || github.token }} - EVENT: ${{ github.event_name }} - run: | - if [ "$EVENT" = pull_request ]; then - exec bash .gh-shared/scripts/flake-lock-fresh.sh --pr .base/flake.lock flake.lock - fi - STALE_NAMES_FILE=stale.txt bash .gh-shared/scripts/flake-lock-fresh.sh flake.lock || true - echo "stale=$(paste -sd' ' stale.txt 2>/dev/null)" >> "$GITHUB_OUTPUT" - - - name: Mint dispatch token for this repository - id: dispatch-token - if: steps.check.outputs.stale != '' && env.HAS_APP_KEY == 'true' - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3 - with: - client-id: ${{ vars.GH_APP_CLIENT_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - repositories: ${{ github.event.repository.name }} - permission-contents: write - - # The caller's _update-flake-lock.yml (targeted) relocks just the stale inputs. - - name: Dispatch the targeted relock - if: steps.dispatch-token.outputs.token != '' - env: - GH_TOKEN: ${{ steps.dispatch-token.outputs.token }} - REPO: ${{ github.repository }} - run: gh api "repos/$REPO/dispatches" -f event_type=update-flake-input diff --git a/.github/workflows/_release-please.yml b/.github/workflows/_release-please.yml deleted file mode 100644 index 1d94084..0000000 --- a/.github/workflows/_release-please.yml +++ /dev/null @@ -1,191 +0,0 @@ -# Reusable: Release Please (org-native). -# -# Canonical release-please workflow for the org, hosted once here so every repo -# runs identical logic. A calling repo adds a thin -# .github/workflows/release-please.yml and passes the org-level release App key. -# -# Auth: the org-level release App — app-id from the -# GH_ACTION_RELEASE_PLEASE_APP_ID org variable, private key from the -# GH_ACTION_RELEASE_PLEASE_PRIVATE_KEY org secret. The App must be installed -# with Contents, Pull requests and Workflows write. Its token lets the release -# PR trigger pull_request CI gates. -# -# Behavior: -# - Trunk repos (default branch main): open or update a release PR against -# main and create the tag and GitHub Release in the same run. -# - Git-flow repos (default branch develop): a develop push opens or updates -# the release PR against develop without tagging. A main push only creates -# the tag and GitHub Release after every merged release PR commit is present -# on main. main is never merged back into develop (there is no back-merge). -# - Every release PR is set to auto-merge as soon as it is opened or updated; -# it merges when its required checks pass. The bot PR sweep merges any -# release PR this step missed. -# -# Config modes (selected by inputs): -# - Manifest mode (default): the caller's repo holds release-please-config.json -# + .release-please-manifest.json; the action reads them via the GitHub API. -# - Non-manifest mode: the caller passes config-file: '' and manifest-file: '' -# with a release-type (e.g. simple). Version state is read from existing tags. -# -# Asset-carrying repos (opt-in per repo, default off): a GitHub release is -# immutable once published, so assets cannot be attached to it afterwards. Such a -# repo sets, in its release-please-config.json package entry: -# "draft": true, "force-tag-creation": true -# so release-please creates the release as a draft and the tag immediately -# (without force-tag-creation the tag is created lazily at publish, and the next -# release run cannot find the previous release). The caller then adds an asset -# job that uploads to the draft (gh release upload ) and a publish job -# that runs gh release edit --draft=false after it. The action has no -# draft input, so this stays config-driven. Repos with no assets leave draft -# unset and are unaffected. Recovery after a failed asset job: Re-run failed -# jobs (not Re-run all), which keeps the release outputs from the first run. -# -# Caller shape (manifest mode): -# on: -# push: -# branches: [main, develop] -# jobs: -# release-please: -# permissions: -# contents: write -# pull-requests: write -# uses: dryvist/.github/.github/workflows/_release-please.yml@<40-hex commit SHA> # vX.Y.Z -# secrets: -# GH_ACTION_RELEASE_PLEASE_PRIVATE_KEY: secrets.GH_ACTION_RELEASE_PLEASE_PRIVATE_KEY -name: _release-please - -on: - workflow_call: - inputs: - runner_label: - description: >- - GitHub Actions runner label. Defaults to the GitHub-hosted ubuntu-24.04 - image. Pass a self-hosted label to opt the calling repo into on-prem runners. - type: string - required: false - default: ubuntu-24.04 - auto-merge: - description: >- - Deprecated and ignored. Every release PR is set to auto-merge. The input - stays declared so callers that still pass it keep running. - type: boolean - default: false - release-type: - description: >- - release-please release type (e.g. simple). Set this AND blank out - config-file/manifest-file to run config-free (non-manifest) mode. - type: string - default: '' - config-file: - description: >- - Path to release-please-config.json. Blank ('') to run non-manifest mode. - type: string - default: release-please-config.json - manifest-file: - description: >- - Path to .release-please-manifest.json. Blank ('') to run non-manifest mode. - type: string - default: .release-please-manifest.json - include-component-in-tag: - description: Whether to prefix tags/branches with the component name. - type: boolean - default: false - secrets: - GH_ACTION_RELEASE_PLEASE_PRIVATE_KEY: - required: true - outputs: - release_created: - description: >- - true when this run cut a release (tag + GitHub Release), false otherwise. - Lets a caller gate a follow-on job on an actual release. - value: ${{ jobs.release-please.outputs.release_created }} - tag_name: - description: >- - Tag of the release this run cut; empty when no release was cut. An - asset-carrying caller builds on it to upload to the draft release and - then publish it (see the header). - value: ${{ jobs.release-please.outputs.tag_name }} - -permissions: {} - -jobs: - release-please: - runs-on: ${{ inputs.runner_label }} - timeout-minutes: 10 - outputs: - release_created: ${{ steps.release.outputs.releases_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - permissions: - contents: write - pull-requests: write - steps: - - name: Detect repository flow - id: flow - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - if [[ "$DEFAULT_BRANCH" == "develop" ]]; then - echo "flow=gitflow" >> "$GITHUB_OUTPUT" - echo "target_branch=develop" >> "$GITHUB_OUTPUT" - else - echo "flow=trunk" >> "$GITHUB_OUTPUT" - echo "target_branch=main" >> "$GITHUB_OUTPUT" - fi - - - name: Generate GitHub App token - id: app-token - if: ${{ steps.flow.outputs.flow == 'trunk' || github.ref_name == 'main' || github.ref_name == 'develop' }} - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3 - with: - app-id: ${{ vars.GH_ACTION_RELEASE_PLEASE_APP_ID }} - private-key: ${{ secrets.GH_ACTION_RELEASE_PLEASE_PRIVATE_KEY }} - permission-contents: write - permission-pull-requests: write - permission-workflows: write - - - name: Sparse-checkout release tag guard - if: ${{ steps.flow.outputs.flow == 'gitflow' && github.ref_name == 'main' }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - repository: dryvist/.github - ref: ${{ job.workflow_sha }} - path: .gh-shared - sparse-checkout: scripts/release-tag-guard.sh - sparse-checkout-cone-mode: false - - - name: Guard release tags on main - id: tag-guard - if: ${{ steps.flow.outputs.flow == 'gitflow' && github.ref_name == 'main' }} - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - GH_REPO: ${{ github.repository }} - run: | - set -euo pipefail - bash .gh-shared/scripts/release-tag-guard.sh | tee "$RUNNER_TEMP/release-tag-guard.log" - tail -n 1 "$RUNNER_TEMP/release-tag-guard.log" >> "$GITHUB_OUTPUT" - - - name: Run release-please - id: release - if: ${{ steps.flow.outputs.flow == 'trunk' || github.ref_name == 'main' || github.ref_name == 'develop' }} - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 - with: - token: ${{ steps.app-token.outputs.token }} - target-branch: ${{ steps.flow.outputs.target_branch }} - release-type: ${{ inputs.release-type }} - config-file: ${{ inputs.config-file }} - manifest-file: ${{ inputs.manifest-file }} - include-component-in-tag: ${{ inputs.include-component-in-tag }} - skip-github-release: ${{ steps.flow.outputs.flow == 'gitflow' && (github.ref_name == 'develop' || (github.ref_name == 'main' && steps.tag-guard.outputs.safe != 'true')) }} - skip-github-pull-request: ${{ steps.flow.outputs.flow == 'gitflow' && github.ref_name == 'main' }} - - - name: Enable auto-merge on the release PR - if: ${{ steps.release.outputs.prs_created == 'true' }} - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - GH_REPO: ${{ github.repository }} - RELEASE_PRS: ${{ steps.release.outputs.prs }} - run: | - set -euo pipefail - jq -r '.[].number' <<<"$RELEASE_PRS" | while read -r number; do - gh pr merge "$number" --auto --squash || gh pr merge "$number" --auto --merge - done diff --git a/.github/workflows/bot-pr-automerge-sweep.yml b/.github/workflows/bot-pr-automerge-sweep.yml deleted file mode 100644 index 66d3264..0000000 --- a/.github/workflows/bot-pr-automerge-sweep.yml +++ /dev/null @@ -1,443 +0,0 @@ -# Deterministic backstop that merges eligible bot dependency PRs org-wide. -# -# WHY THIS EXISTS. Renovate is configured to merge its own PRs -# (renovate-presets.json: automerge:true, platformAutomerge:false), but in -# practice it usually does not. Sampling merged Renovate PRs across five -# dryvist repos in 2026-08, `app/renovate` was the merging actor on 2 of ~14; -# in mlx-benchmarks it was the merging actor on none of ~30. Every other one -# was merged by a person or an ad-hoc agent sweep. Two causes were fixed in -# renovate-presets.json alongside this workflow (digest updates matched no -# auto-merge rule at all; the lock-file automerge windows were four hours wide -# and a hosted Renovate run had to land inside one). Neither fix is a -# guarantee: both still depend on a hosted Renovate run happening at the right -# moment. This workflow is the guarantee. It re-derives eligibility from the -# GitHub API on its own schedule, so a missed Renovate run costs nothing. -# -# IT IS A BACKSTOP, NOT THE PRIMARY PATH. When Renovate merges first, this -# workflow finds nothing to do. Do not move policy here: the freshness/trust -# model stays in renovate-presets.json; this only re-checks the parts of it -# that are observable from the API. -# -# NO MODEL IS IN THE LOOP. Eligibility is computed in shell from API fields, so -# the outcome is reproducible and auditable. An agent must never hand-merge a -# bot PR to "help" — a bot PR this sweep will not merge is a policy defect to -# fix here, not to route around. -# -# TOKENS. Every READ (search, PR fields, check-runs, commit statuses) uses the -# job's own GITHUB_TOKEN against public data. Only the merge uses a GitHub App -# token, and that token is minted with `repositories:` set to exactly the repos -# discovered to have an open bot PR, carrying exactly contents:write and -# pull-requests:write. Reads deliberately do NOT use the App token: the -# check-runs and commit-status APIs need `checks: read` and `statuses: read`, -# which are implied by neither of those two permissions, so an App-token read -# would 403 and every PR would be skipped as unreadable while CI stayed green. -# -# BOUNDARY: discovery is by code search, which sees public repos only. No -# private dryvist repo currently has an open Renovate PR (verified 2026-08-22). -# If one ever adopts Renovate, it will not be swept and will need to be added -# to discovery explicitly — it will not fail loudly on its own. -name: Bot PR Automerge Sweep - -on: - schedule: - # Hourly, off the :00 mark so the org's runs do not pile onto the hour. - - cron: "23 * * * *" - workflow_dispatch: - inputs: - dry_run: - description: >- - Report what would be merged without merging. Defaults to true so a - manual run is always safe; scheduled runs are always live. - type: boolean - required: false - default: true - -permissions: - contents: read - -concurrency: - group: bot-pr-automerge-sweep - cancel-in-progress: false - -jobs: - sweep: - name: Sweep - runs-on: ubuntu-26.04 - timeout-minutes: 20 - environment: automation - env: - # Space-separated. Every author here produces PRs - # whose eligibility is fully decidable from API fields — never an author - # whose PRs carry human intent. app/dryvist-github-actions is the - # flake.lock updater: it normally merges its own PRs within the same - # minute, but when that self-merge fails nothing else was watching, so - # those PRs sat open indefinitely while this sweep could not see them. - # app/jacobpevans-release-please opens release PRs on every repo flow - # (main on trunk repos, develop on git-flow repos); each merges like any - # other bot PR once its checks are green, and skips the triage-label gate - # on "manual" repos. app/openbao-service-broker opens documentation - # projection PRs and is gated by scripts/bot-sweep-policy.sh. - BOT_AUTHORS: "app/renovate app/dryvist-github-actions app/jacobpevans-release-please app/openbao-service-broker" - SMALL_DIFF_MAX_FILES: "5" - SMALL_DIFF_MAX_LINES: "100" - SMALL_DIFF_PROJECTION_AUTHOR: "app/openbao-service-broker" - SMALL_DIFF_PROJECTION_REPO: "dryvist/docs" - # Cap on the discovery search. Hitting it is reported, never silently - # truncated. - SEARCH_LIMIT: "200" - steps: - - name: Check out the sweep policy script - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - sparse-checkout: scripts/bot-sweep-policy.sh - sparse-checkout-cone-mode: false - persist-credentials: false - - - name: Discover open bot PRs - id: discover - env: - GH_TOKEN: ${{ github.token }} - ORG: ${{ github.repository_owner }} - run: | - set -euo pipefail - - # No `|| true` anywhere here: a search that ERRORS must fail the job. - # An empty result and a broken query look identical in the output but - # not in the exit code, and a sweep that silently examines nothing is - # indistinguishable from a sweep that found nothing to do. The loop - # runs under `set -e`, so one failed author search fails the run - # rather than silently narrowing the sweep to the authors that did - # answer. - : > parts.json - for author in $BOT_AUTHORS; do - gh search prs --owner "$ORG" --author "$author" --state open \ - --limit "$SEARCH_LIMIT" --json repository,number > part.json - # The cap is PER SEARCH, so it has to be checked per author: a - # combined total would hide one author truncating while another - # returned few. - n=$(jq 'length' part.json) - if [ "$n" -ge "$SEARCH_LIMIT" ]; then - echo "::warning::discovery for ${author} hit the ${SEARCH_LIMIT}-result cap; some bot PRs were not examined" - fi - cat part.json >> parts.json - done - # -s collects each author's array, `add` concatenates them. A PR has - # exactly one author, so this cannot duplicate an entry. - jq -s 'add' parts.json > found.json - - total=$(jq 'length' found.json) - jq -r '[.[].repository.name] | unique | .[]' found.json > repos.txt - - echo "Discovered ${total} open bot PRs (${BOT_AUTHORS}) across $(wc -l < repos.txt) repos" - # Written here, not in the merge step, so a run that discovers - # nothing still produces a summary with a number in it. A silent - # run and a run that examined nothing are otherwise identical. - { - echo "## Bot PR automerge sweep" - echo - echo "Discovered ${total} open bot PRs across $(wc -l < repos.txt) repos (authors: \`${BOT_AUTHORS}\`)." - } >> "$GITHUB_STEP_SUMMARY" - { - echo "count=${total}" - echo "repos<> "$GITHUB_OUTPUT" - - # Skipped entirely when nothing was found: create-github-app-token treats - # an empty `repositories:` as "every repo in the installation", which is - # the exact over-scoping this split exists to avoid. - - name: Mint merge token, scoped to the repos with work - id: token - if: steps.discover.outputs.count != '0' - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3 - with: - # Reuses the existing org release App rather than provisioning a - # second one; it is already installed org-wide with Contents + - # Pull requests write. Swap these two lines if a dedicated automation - # App is ever created. - app-id: ${{ vars.GH_ACTION_RELEASE_PLEASE_APP_ID }} - private-key: ${{ secrets.GH_ACTION_RELEASE_PLEASE_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ steps.discover.outputs.repos }} - # Exactly what a squash merge needs: contents to write the merge - # commit, pull-requests to close the PR. Nothing else. - permission-contents: write - permission-pull-requests: write - - - name: Merge eligible bot PRs - if: steps.discover.outputs.count != '0' - env: - GH_TOKEN: ${{ github.token }} - MERGE_TOKEN: ${{ steps.token.outputs.token }} - ORG: ${{ github.repository_owner }} - EVENT_NAME: ${{ github.event_name }} - DISPATCH_DRY_RUN: ${{ inputs.dry_run }} - # Kill switch and arming gate in one. A scheduled run is DRY until - # this repo variable is set to the literal "true", so merging this - # workflow cannot start merging PRs before a dispatched dry run has - # been read. Unsetting it disarms the sweep without reverting code. - SWEEP_LIVE: ${{ vars.BOT_SWEEP_LIVE }} - # A bot PR still open past this is a policy defect, not a backlog - # item. The job fails so the failure notification surfaces it. - STALL_DAYS: "7" - # On "manual" repos, the local triage agent's verdict: the label it - # adds to a low-risk bot PR, and the ID of the GitHub App it acts as. - # Unset means no manual-repo bot PR merges. - TRIAGE_LABEL: "auto-merge-deps" - TRIAGE_APP_ID: ${{ vars.BOT_TRIAGE_APP_ID }} - run: | - set -euo pipefail - - now=$(date -u +%s) - stall_age=$(( STALL_DAYS * 86400 )) - - # A manual run honours its input. A scheduled run is dry unless armed. - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - DRY_RUN="$DISPATCH_DRY_RUN" - elif [ "$SWEEP_LIVE" = "true" ]; then - DRY_RUN=false - else - DRY_RUN=true - fi - - # Reuse the discovery output rather than searching again. A second - # search could return a different set than the one the merge token - # was scoped to — a PR appearing between the two would be attempted - # with a token that cannot reach its repo and reported as a merge - # failure, and a PR merged between them would empty the list and - # fail a non-empty assertion for no reason. - jq -r '.[] | "\(.repository.name) \(.number)"' found.json | sort -u > work.txt - - merged=0; skipped=0; stalled=0; failed=0; examined=0 - rows=""; stall_rows="" - : > reasons.txt - - while read -r repo num; do - examined=$(( examined + 1 )) - - # Re-read each PR individually rather than trusting search output. - # GitHub computes mergeability ASYNCHRONOUSLY: a cold query returns - # mergeable/mergeStateStatus of UNKNOWN and only kicks off the - # computation, so a single read would skip an eligible PR as - # "not-mergeable" on every run and quietly defeat this whole - # workflow. Observed live on a PR that read MERGEABLE moments - # earlier. Retry until GitHub answers. - pr='{}' - for attempt in 1 2 3 4; do - pr=$(gh pr view "$num" --repo "${ORG}/${repo}" \ - --json number,title,author,labels,mergeable,mergeStateStatus,headRefName,headRefOid,baseRefName,changedFiles,additions,deletions,files \ - 2>/dev/null || echo '{}') - [ "$(jq -r '.mergeable // "UNKNOWN"' <<<"$pr")" != "UNKNOWN" ] && break - sleep $(( attempt * 3 )) - done - - sha=$(jq -r '.headRefOid // ""' <<<"$pr") - state=$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$pr") - mergeable=$(jq -r '.mergeable // "UNKNOWN"' <<<"$pr") - branch=$(jq -r '.headRefName // "?"' <<<"$pr") - title=$(jq -r '.title // ""' <<<"$pr") - author=$(jq -r '.author.login // ""' <<<"$pr") - # A first-party bump: a Renovate PR whose title updates a dryvist/ - # package or a github.com/dryvist/ git source, or the - # "first-party workflows" group (renovate-grouping.json), which - # matches only dryvist packages. A title is attacker-controlled, - # so it counts only on a PR that Renovate itself opened. - first_party=false - if [ "$author" = "app/renovate" ] \ - && [[ "$title" =~ ^[a-z]+(\([a-z0-9-]+\))?:\ update\ ((dependency\ )?(dryvist/|https://github\.com/dryvist/)|first-party\ workflows(\ |$)) ]]; then - first_party=true - fi - # renovate-presets.json no longer labels first-party minors or - # majors, so a hold label on one is left over and is not read. - held=$(jq -r --argjson fp "$first_party" 'any(.labels[]?; - (($fp | not) and (.name == "update:major" or .name == "bump:minor" or .name == "bump:major")) - or .name == "needs-review")' <<<"$pr") - - if [ -z "$sha" ]; then - skipped=$(( skipped + 1 )) - rows="${rows}| \`${repo}#${num}\` | skipped | pr-unreadable |"$'\n' - continue - fi - - # A repo whose `automerge` custom property is "manual" merges a bot - # PR only when the local triage agent judged it low risk: the - # TRIAGE_LABEL was added by the TRIAGE_APP_ID App, after the head commit. - # The label alone proves nothing, since anyone with triage access - # can add it; the event history names who added it and when. - manual=$(gh api "repos/${ORG}/${repo}/properties/values" \ - --jq 'any(.[]; .property_name == "automerge" and .value == "manual")' 2>/dev/null || echo false) - if [ "$manual" = "true" ] && [ "$author" != "app/jacobpevans-release-please" ]; then - head_date=$(gh api "repos/${ORG}/${repo}/commits/${sha}" \ - --jq '.commit.committer.date' 2>/dev/null || echo "") - triaged="" - if [[ "$TRIAGE_APP_ID" =~ ^[0-9]+$ ]]; then - triaged=$(gh api "repos/${ORG}/${repo}/issues/${num}/events" --paginate \ - --jq ".[] | select(.event == \"labeled\" and .label.name == \"${TRIAGE_LABEL}\" - and .performed_via_github_app.id == ${TRIAGE_APP_ID}) | .created_at" 2>/dev/null \ - | sort | tail -1) - fi - has_label=$(jq -r --arg l "$TRIAGE_LABEL" 'any(.labels[]?; .name == $l)' <<<"$pr") - if [ "$has_label" != "true" ] || [ -z "$triaged" ] || [ -z "$head_date" ] \ - || [ "$(date -u -d "$triaged" +%s)" -lt "$(date -u -d "$head_date" +%s)" ]; then - skipped=$(( skipped + 1 )) - rows="${rows}| \`${repo}#${num}\` | skipped | automerge-manual (no triage label) |"$'\n' - continue - fi - fi - - policy=$(REPO="${ORG}/${repo}" bash scripts/bot-sweep-policy.sh <<<"$pr") - - # Age is read from the HEAD COMMIT, never the PR's createdAt. Renovate - # updates a PR in place with a new commit, and createdAt stays put. - # The age feeds only the stalled-PR report below. - committed=$(gh api "repos/${ORG}/${repo}/commits/${sha}" \ - --jq '.commit.committer.date' 2>/dev/null || echo "") - if [ -z "$committed" ]; then - skipped=$(( skipped + 1 )) - rows="${rows}| \`${repo}#${num}\` | skipped | head-commit-unreadable |"$'\n' - continue - fi - - age=$(( now - $(date -u -d "$committed" +%s) )) - reason="" - [[ "$policy" == skip:* ]] && reason=${policy#skip:} - - # Majors and minors never auto-merge, and neither does a flake bump that - # moved nixpkgs or landed off a release. The labels are read, never parsed - # for versions: update:major comes from renovate-presets.json; bump:minor, - # bump:major and needs-review come from _update-flake-lock.yml. - [ "$held" = "true" ] && reason="held-label" - [ -z "$reason" ] && [ "$mergeable" != "MERGEABLE" ] && reason="not-mergeable" - # BLOCKED covers unresolved review threads and missing required - # checks; DIRTY is a conflict; BEHIND/DRAFT are Renovate's to fix. - case "$state" in - CLEAN|UNSTABLE) ;; - *) [ -z "$reason" ] && reason="state:${state}" ;; - esac - # UNSTABLE is accepted only when the sole non-green signal is - # Renovate's own renovate/stability-days status. That status - # advances only on a Renovate run, so gating on it would - # reintroduce the very dependency this workflow exists to remove. - # Renovate opens a PR only after its release-age window, so the sweep - # does not wait on this status either. Any other failing or pending - # signal still blocks. - if [ -z "$reason" ]; then - # Each blocking check is reported as name(verdict), never folded - # into a bare "failed". `cancelled` and `timed_out` mean the check - # NEVER CONCLUDED — a runner eviction, a superseded run, a job - # that hit its timeout — which is a different thing from a check - # that ran and said no. Both block here, deliberately: an - # unexamined check is not a passing one. But a chronic - # infrastructure timeout and a genuine test failure need - # different fixes, so the summary has to tell them apart. - # `neutral` and `skipped` count as satisfied, matching how GitHub - # itself treats them for required checks. - bad=$(gh api "repos/${ORG}/${repo}/commits/${sha}/check-runs" --paginate \ - --jq '[.check_runs[] - | select(.status != "completed" - or (.conclusion | IN("success","neutral","skipped") | not)) - | "\(.name)(\(if .status != "completed" then .status else .conclusion end))"] - | join(",")' 2>/dev/null || echo "API_ERROR") - [ "$bad" = "API_ERROR" ] && reason="checks-unreadable" - [ -z "$reason" ] && [ -n "$bad" ] && reason="checks:${bad}" - fi - if [ -z "$reason" ]; then - bad=$(gh api "repos/${ORG}/${repo}/commits/${sha}/status" \ - --jq '[.statuses[] - | select(.context != "renovate/stability-days") - | select(.state != "success") - | .context] | join(",")' 2>/dev/null || echo "API_ERROR") - [ "$bad" = "API_ERROR" ] && reason="status-unreadable" - [ -z "$reason" ] && [ -n "$bad" ] && reason="status:${bad}" - fi - - if [ -n "$reason" ]; then - skipped=$(( skipped + 1 )) - echo "skipped ${repo}#${num} ${reason}" - # Tallied so a reason holding several PRs can be surfaced once, - # loudly, at the end of the run. - echo "$reason" >> reasons.txt - rows="${rows}| \`${repo}#${num}\` | skipped | ${reason} |"$'\n' - # A PR this sweep will not merge, and that has outlived the - # policy, is a defect in the config above — not a backlog item. - # Counted only here: a PR merged on this run was never stuck. A label hold - # waits on a human by design, so only an unlabelled hold can be a defect. - if [ "$age" -gt "$stall_age" ] && [ "$reason" != "held-label" ]; then - stalled=$(( stalled + 1 )) - stall_rows="${stall_rows}| \`${repo}#${num}\` | ${reason} | $(( age / 86400 ))d |"$'\n' - fi - continue - fi - - if [ "$DRY_RUN" = "true" ]; then - echo "would-merge ${repo}#${num} ${branch}" - rows="${rows}| \`${repo}#${num}\` | would-merge | ${branch} |"$'\n' - continue - fi - - # The App token is used HERE and nowhere else. --squash matches - # renovate-presets.json automergeStrategy; a base that allows only - # merge commits (a git-flow `main`) rejects it, so --merge follows. - # No --base flag: gh merges into the PR's own base. No - # --delete-branch either: delete_branch_on_merge is already set on - # these repos, and losing the race with it would be reported here - # as a merge failure that did not happen. - if GH_TOKEN="$MERGE_TOKEN" gh pr merge "$num" --repo "${ORG}/${repo}" --squash \ - || GH_TOKEN="$MERGE_TOKEN" gh pr merge "$num" --repo "${ORG}/${repo}" --merge; then - merged=$(( merged + 1 )) - echo "merged ${repo}#${num} ${branch}" - rows="${rows}| \`${repo}#${num}\` | merged | ${branch} |"$'\n' - else - failed=$(( failed + 1 )) - echo "::error::merge rejected for ${repo}#${num} (${branch})" - rows="${rows}| \`${repo}#${num}\` | MERGE-FAILED | ${branch} |"$'\n' - fi - done < work.txt - - [ "$DRY_RUN" = "true" ] && mode="dry-run" || mode="live" - - # A gate that holds the same PRs for the same reason on every run, - # and says nothing louder each time, is how a correct gate gets - # loosened by someone who cannot see why it was tight. A check that - # is broken on the BASE branch blocks every PR in that repo forever - # — the sweep is then not merely cautious there, it has zero - # throughput — and the hold reason is identical each time, so - # nothing distinguishes it from ordinary caution. Any reason holding - # more than one PR is called out once, as a warning annotation. - if [ -s reasons.txt ]; then - while read -r n why; do - [ "$n" -gt 1 ] && echo "::warning::${n} PRs are held on the same reason: ${why} — check whether this is broken on the base branch rather than in the PRs" - done < <(sort reasons.txt | uniq -c | sort -rn) - fi - - # Repeated on stdout as well as in the summary. A step summary is - # readable in the web UI and essentially not readable from the API, - # so a run whose every decision lived only there could not be - # inspected by the next agent or by a script. - echo "mode=${mode} examined=${examined} merged=${merged} skipped=${skipped} failed=${failed} stalled=${stalled}" - - # "examined" is printed unconditionally so a quiet run carries a - # number: a sweep that found nothing eligible and a sweep that - # examined nothing at all read identically otherwise. - { - echo - echo "Mode: ${mode} | PRs examined: ${examined} | merged: ${merged} | skipped: ${skipped} | merge failures: ${failed}" - echo - echo "| PR | Outcome | Detail |" - echo "| --- | --- | --- |" - printf '%s' "$rows" - if [ -n "$stall_rows" ]; then - echo - echo "### Stalled past ${STALL_DAYS}d — policy defect, fix the config" - echo - echo "| PR | Held by | Age |" - echo "| --- | --- | --- |" - printf '%s' "$stall_rows" - fi - } >> "$GITHUB_STEP_SUMMARY" - - # Fail on a merge that was attempted and rejected, or on a PR that - # has outlived the policy. Both mean the config no longer matches - # reality, and a green scheduled run would hide that. - [ "$failed" -eq 0 ] && [ "$stalled" -eq 0 ] diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml deleted file mode 100644 index faf48fe..0000000 --- a/.github/workflows/canary.yml +++ /dev/null @@ -1,322 +0,0 @@ ---- -# Caller-contract canary for the central reusable workflows this repo calls. -# -# Stack jobs call generic reusables at JacobPEvans/.github@v1 and call this -# repository's gate locally. The canary checks the central workflow contract -# against the gate and fixtures from the PR head (pull_request) or pushed -# commit (push to main). On push, only a release commit runs the stacks: its -# Canary check gates the major-tag move, and every other commit was already -# canaried as a pull request. Release PRs are squash-merged with their -# release-please title so the push matches. The release commit is never -# re-canaried. Fixtures under tests/canary// are reached only through -# the directory inputs a reusable exposes; the rest run against the repo root. -# -# The `promote` job at the end runs on push to main after this run's summary -# succeeds. It moves the floating major tag when a release tag already names -# this commit. When the release comes later, promote-major-tag.yml does it. -# Both paths call scripts/promote-major-tag.sh. -# -# The job named `Canary` is the required check (ruleset, admin gate). It fails -# when any caller job fails or is cancelled, and when a stack's change detection -# did not match its fixture (a silent skip would otherwise read as a pass). -# `ansible-direct` is the one exception: the central _ansible-ci.yml hard-codes -# its own filter, so its lint runs only when the change touches Ansible paths. -# Its coverage is printed to the job summary, not asserted. -# -# `template-callers` is static: it reads each template's ci-gate.yml caller from -# the template's default branch and checks it against this commit's gate -# (inputs, required inputs, permissions). It has no fixture, so it is required -# like the other caller jobs. - -name: Canary - -on: - pull_request: - push: - branches: [main] - -concurrency: - # Push runs group per commit, so a newer push to main never drops a queued - # release commit run before its promote job moves the major tag. - group: canary-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -permissions: {} - -jobs: - # On a pull request the stacks run unless every changed file is Markdown - # outside .github/ and tests/canary/ (agentic-workflow imports and canary - # fixtures are consumed as code). - # A release commit push always runs them. - scope: - name: Scope - if: >- - github.event_name == 'pull_request' || - startsWith(github.event.head_commit.message, 'chore(main): release ') - runs-on: ubuntu-26.04 - timeout-minutes: 5 - permissions: - contents: read - pull-requests: read - outputs: - run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.code == 'true' || steps.filter.outputs.github_md == 'true' || steps.filter.outputs.fixtures == 'true' }} - steps: - - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 - id: filter - if: github.event_name == 'pull_request' - with: - predicate-quantifier: every - filters: | - code: - - '**' - - '!**/*.md' - - '!**/*.mdx' - github_md: - - '.github/**/*.md' - fixtures: - - 'tests/canary/**' - - # Reusable-workflow caller jobs take no `timeout-minutes`; each inner job - # carries its own. The `actions: write` grant is what _ci-gate.yml's queue - # watchdog needs. dependency_review and zizmor are off: the repo's own gate - # already runs them once per PR, and the canary would repeat them per stack. - ansible: - needs: [scope] - if: needs.scope.outputs.run == 'true' - permissions: - contents: read - pull-requests: read - actions: write - uses: ./.github/workflows/_ci-gate.yml - with: - filters: | - ansible: - - '**' - ansible_ci: true - # Scopes lint to the fixture. - ansible_lint_args: tests/canary/ansible - dependency_review: false - zizmor: false - - nix: - needs: [scope] - if: needs.scope.outputs.run == 'true' - permissions: - contents: read - pull-requests: read - actions: write - uses: ./.github/workflows/_ci-gate.yml - with: - filters: | - nix: - - '**' - nix_validate: true - all_systems: false - dependency_review: false - zizmor: false - - tofu: - needs: [scope] - if: needs.scope.outputs.run == 'true' - permissions: - contents: read - pull-requests: read - actions: write - uses: ./.github/workflows/_ci-gate.yml - with: - # The gate exports no `terraform` output, so `extra` mirrors the same - # pattern and gives the summary a change-detection value to read. - filters: | - terraform: - - '**' - extra: - - '**' - tofu_ci: true - tofu_working_directories: '["tests/canary/tofu"]' - dependency_review: false - zizmor: false - - # python_ci is not canaried: the central _python-ci.yml runs `uv run` at the - # repo root and needs a root pyproject.toml this repo does not have. The - # central _python-security.yml takes a directory, so it exercises the fixture. - python: - needs: [scope] - if: needs.scope.outputs.run == 'true' - permissions: - contents: read - pull-requests: read - actions: write - uses: ./.github/workflows/_ci-gate.yml - with: - filters: | - python: - - '**' - python_security: true - python_security_dirs: tests/canary/python - dependency_review: false - zizmor: false - - docs: - needs: [scope] - if: needs.scope.outputs.run == 'true' - permissions: - contents: read - pull-requests: read - actions: write - uses: ./.github/workflows/_ci-gate.yml - with: - filters: | - markdown: - - '**' - markdown_lint: true - dependency_review: false - zizmor: false - - # The central _ansible-ci.yml runs with Molecule on. The contract_only map - # covers the Molecule-relevant paths on a PR, so a PR touching only workflows - # skips Molecule. A push to main runs the full scenario set. - ansible-direct: - needs: [scope] - if: needs.scope.outputs.run == 'true' - permissions: - contents: read - pull-requests: read - uses: JacobPEvans/.github/.github/workflows/_ansible-ci.yml@v1 - with: - emit_gate: false - molecule: true - lint_args: tests/canary/ansible - molecule_contract_filters: | - contract_only: - - 'roles/**' - - 'playbooks/**' - - 'inventory/**' - - 'group_vars/**' - - 'host_vars/**' - - 'molecule/**' - - '.config/molecule/**' - - 'requirements.yml' - - 'requirements-ci.txt' - - 'ansible.cfg' - - '.github/workflows/**' - - '.github/scripts/**' - - 'tests/**' - - template-callers: - needs: [scope] - if: needs.scope.outputs.run == 'true' - name: Template Callers - runs-on: ubuntu-26.04 - timeout-minutes: 5 - permissions: - contents: read - steps: - - name: Check out the candidate commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - - # Unauthenticated: the templates are public. Any failed fetch fails the job. - - name: Fetch the template caller workflows from their default branch - run: | - mkdir -p "$RUNNER_TEMP/template-callers" - for template in python-template cc-edge-pack-template tofu-aws-templates; do - curl -fsSL "https://raw.githubusercontent.com/dryvist/${template}/main/.github/workflows/ci-gate.yml" \ - -o "$RUNNER_TEMP/template-callers/${template}.yml" - done - - - name: Check the callers against the candidate gate - run: | - yq --version - scripts/check-template-callers.sh "$RUNNER_TEMP"/template-callers/*.yml - - canary-summary: - name: Canary - needs: [scope, ansible, nix, tofu, python, docs, ansible-direct, template-callers] - if: >- - always() && (github.event_name == 'pull_request' || - startsWith(github.event.head_commit.message, 'chore(main): release ')) - runs-on: ubuntu-26.04 - timeout-minutes: 5 - permissions: {} - steps: - - name: Require every stack to pass and to have matched its fixture - env: - R_SCOPE: ${{ needs.scope.result }} - RUN: ${{ needs.scope.outputs.run }} - R_ANSIBLE: ${{ needs.ansible.result }} - R_NIX: ${{ needs.nix.result }} - R_TOFU: ${{ needs.tofu.result }} - R_PYTHON: ${{ needs.python.result }} - R_DOCS: ${{ needs.docs.result }} - R_ANSIBLE_DIRECT: ${{ needs.ansible-direct.result }} - R_TEMPLATE_CALLERS: ${{ needs.template-callers.result }} - C_ANSIBLE: ${{ needs.ansible.outputs.ansible }} - C_NIX: ${{ needs.nix.outputs.nix }} - C_TOFU: ${{ needs.tofu.outputs.extra }} - C_PYTHON: ${{ needs.python.outputs.python }} - C_DOCS: ${{ needs.docs.outputs.markdown }} - C_ANSIBLE_DIRECT: ${{ needs.ansible-direct.outputs.ansible }} - run: | - if [[ "$R_SCOPE" != success ]]; then - echo "::error::scope result is '$R_SCOPE'" - exit 1 - fi - if [[ "$RUN" != true ]]; then - echo "::notice::Markdown-only change: caller-contract stacks not run" - exit 0 - fi - fail=0 - result() { - if [[ "$2" == success ]]; then - echo "$1: result success" - else - echo "::error::$1 result is '$2'" - fail=1 - fi - } - matched() { - if [[ "$2" == true ]]; then - echo "$1: change detection matched the fixture" - else - echo "::error::$1 change detection did not match the fixture ('$2')" - fail=1 - fi - } - result ansible "$R_ANSIBLE"; matched ansible "$C_ANSIBLE" - result nix "$R_NIX"; matched nix "$C_NIX" - result tofu "$R_TOFU"; matched tofu "$C_TOFU" - result python "$R_PYTHON"; matched python "$C_PYTHON" - result docs "$R_DOCS"; matched docs "$C_DOCS" - result ansible-direct "$R_ANSIBLE_DIRECT" - echo "ansible-direct: lint ran=${C_ANSIBLE_DIRECT:-false} (not asserted; see header)" - result template-callers "$R_TEMPLATE_CALLERS" - exit "$fail" - - # Second arrival for a release: this push-to-main run finished after the - # release tag named this commit. `needs` with `success()` means the summary - # above already concluded success, so the script skips the check-run lookup. - promote: - name: Move floating major tag - needs: [canary-summary] - if: ${{ success() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} - runs-on: ubuntu-26.04 - timeout-minutes: 10 - permissions: - contents: write - checks: read - concurrency: - group: promote-major-tag - cancel-in-progress: false - steps: - - name: Check out tags - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Promote if a release tag names this commit - env: - GH_TOKEN: ${{ github.token }} - SHA: ${{ github.sha }} - run: scripts/promote-major-tag.sh "$SHA" --gate-verified diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml index 94b1b69..734d851 100644 --- a/.github/workflows/ci-gate.yml +++ b/.github/workflows/ci-gate.yml @@ -33,7 +33,6 @@ jobs: with: nix_validate: true workflow_lint: true - test_command: "node --test 'tests/**/*.test.js' tests/ci-gate-aggregate.test.mts" filters: | markdown: - '**.md' diff --git a/.github/workflows/markdownlint.yml b/.github/workflows/markdownlint.yml index fd45da2..a2d3430 100644 --- a/.github/workflows/markdownlint.yml +++ b/.github/workflows/markdownlint.yml @@ -3,12 +3,8 @@ # Referenced by dryvist/terraform-github's github_organization_ruleset, which # injects this workflow into the default-branch PRs of EVERY repo in the org. # -# Unlike the reusable _markdown-lint.yml (which defers to each repo's own -# markdownlint config and only falls back to the org default), this FORCES the -# single canonical config from dryvist/.github. That is the point: identical -# rules org-wide, zero per-repo .markdownlint* files to drift. Once this is -# active, the reusable workflow + per-repo configs + per-repo `uses:` wiring -# can be retired. +# This FORCES the single canonical config from dryvist/.github. That keeps +# rules identical org-wide and avoids per-repo markdownlint config drift. name: Markdown Lint on: diff --git a/.github/workflows/renovate-config-validate.yml b/.github/workflows/renovate-config-validate.yml deleted file mode 100644 index ede3ca0..0000000 --- a/.github/workflows/renovate-config-validate.yml +++ /dev/null @@ -1,63 +0,0 @@ -# Validates this repo's Renovate configs on every PR that touches them. They are -# pass-throughs to JacobPEvans/.github, the single source of truth for Renovate -# policy across every dryvist/JacobPEvans repo, so a broken pass-through here -# silently breaks dependency automation for dryvist consumers. `--strict` also fails on -# deprecation/migration warnings, so config drift (renamed options, removed -# keys) is caught and fixed at PR time — not discovered months later. Scoped by -# `paths`, so it only runs when a renovate*.json file actually changes. -name: Renovate Config Validate - -on: - pull_request: - paths: - - "default.json" - - "renovate.json" - - "renovate-nix.json" - - "renovate-presets.json" - - "renovate-grouping.json" - workflow_call: - inputs: - config_file: - description: Repository Renovate config file to validate through RENOVATE_CONFIG_FILE. - type: string - required: false - default: "" - -permissions: - contents: read - -jobs: - validate: - name: Validate - runs-on: ubuntu-26.04 - timeout-minutes: 10 - env: - RENOVATE_VERSION: "44.165.3" - RENOVATE_CONFIG_FILE: ${{ inputs.config_file }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Set up Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: "24.22.1" - - - name: Cache Renovate CLI - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: ~/.npm - key: renovate-cli-${{ runner.os }}-${{ env.RENOVATE_VERSION }} - restore-keys: renovate-cli-${{ runner.os }}- - - - name: Validate Renovate config (strict) - run: | - if [[ -n "$RENOVATE_CONFIG_FILE" ]]; then - npx --yes --package "renovate@${RENOVATE_VERSION}" -- \ - renovate-config-validator --strict - else - npx --yes --package "renovate@${RENOVATE_VERSION}" -- \ - renovate-config-validator --strict \ - default.json renovate.json renovate-nix.json \ - renovate-presets.json renovate-grouping.json - fi diff --git a/.github/workflows/review-thread-resolver.yml b/.github/workflows/review-thread-resolver.yml deleted file mode 100644 index e4eb23d..0000000 --- a/.github/workflows/review-thread-resolver.yml +++ /dev/null @@ -1,29 +0,0 @@ -# Review Thread Resolver caller. -# -# Instantly resolves stale/failed bot review threads (gemini, Copilot) on PR -# events, so the org `required_review_thread_resolution` rule stops blocking -# merges. Only touches bot-only threads that are outdated or failure notices; -# substantive human/bot feedback is never resolved. -# -# Permissions are job-scoped and the App key is passed explicitly (zizmor: -# excessive-permissions, secrets-inherit). contents: write is required by the -# resolveReviewThread GraphQL mutation. No concurrency block on purpose: a -# caller sharing the reusable workflow's exact concurrency group causes an -# opaque 0-job startup_failure. - -name: Review Thread Resolver - -on: - pull_request: - types: [synchronize] - pull_request_review: - types: [submitted] - -jobs: - resolve: - permissions: - contents: write - pull-requests: write - uses: JacobPEvans/.github-ai/.github/workflows/review-thread-resolver.yml@v1 - secrets: - GH_APP_CLAUDE_BOT_PRIVATE_KEY: ${{ secrets.GH_APP_CLAUDE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index df476bf..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Runs the unit tests for the scripts this repo ships. Repo-local; the -# ubuntu-24.04 image's Node (22) has `node --test` built in; the glob form is -# required there — a bare directory argument only works on Node 24+. -name: Tests - -on: - pull_request: - paths: - - '.github/scripts/**' - - 'scripts/ci-gate-aggregate.mts' - - 'scripts/flake-lock-fresh.sh' - - 'tests/**' - - '.github/workflows/_ci-gate.yml' - - '.github/workflows/renovate-config-validate.yml' - - '.github/workflows/tests.yml' - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: {} - -jobs: - node: - name: node --test - runs-on: ubuntu-26.04 - timeout-minutes: 5 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: '22' - - run: node --test 'tests/**/*.test.js' tests/ci-gate-aggregate.test.mts - - python: - name: python -m unittest - runs-on: ubuntu-26.04 - timeout-minutes: 5 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 - with: - python-version: '3.x' - - run: pip install --quiet tiktoken pyyaml - - run: python3 -m unittest discover -s tests -v diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index 6b6bae0..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "1.19.3" -} diff --git a/AGENTS.md b/AGENTS.md index b6fd6f9..6c12fd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Python is not used for new dryvist work. | Code lint/format | Biome | `biome.jsonc` in this repo; `lineWidth: 120` for JS/TS/JSON/CSS | | Markdown lint | markdownlint-cli2 | `.markdownlint-cli2.yaml` in this repo; `MD013 line_length: 160` | | Type check | `tsc --noEmit` | TypeScript strict mode in `tsconfig.json` | -| Release automation | release-please | Org-native — `.github/workflows/_release-please.yml` | +| Release automation | release-please | `JacobPEvans/.github/.github/workflows/_release.yml@v1` | | Dependency updates | Renovate | Policy mastered in `JacobPEvans/.github`; this repo's presets pass through to it | The canonical `biome.jsonc` and `.markdownlint-cli2.yaml` live in this repo. @@ -56,9 +56,8 @@ defaults. ## Release-please (org-native) -Release-please is **org-native** to this repo: every dryvist repo calls the -reusable `.github/workflows/_release-please.yml` here, pinned to a commit SHA -(`@<40-hex commit SHA> # vX.Y.Z`), not a cross-account JacobPEvans workflow. It +Every dryvist repo runs release-please through the shared +`JacobPEvans/.github/.github/workflows/_release.yml@v1` workflow. It opens the release PR for every bump type, which merges like any bot PR (below). A dryvist repo's `release-please.yml` caller forwards a single secret: @@ -102,7 +101,7 @@ Work through this in order when creating a dryvist repo. branching model you actually intend; the `org-gitflow-*` rulesets bind on that property, not on the default branch. 2. **Add release automation.** A thin `.github/workflows/release-please.yml` - calling `dryvist/.github/.github/workflows/_release-please.yml@<40-hex commit SHA> # vX.Y.Z` and + calling `JacobPEvans/.github/.github/workflows/_release.yml@v1` and forwarding `GH_ACTION_RELEASE_PLEASE_PRIVATE_KEY`. Copy an existing caller verbatim rather than writing one. Manifest mode is the default and also needs `release-please-config.json` (copy `configs/release-please-config.json` @@ -207,7 +206,7 @@ first, with no wiring**. A reference added in the same PR as the file fails workflow **graph validation** before any job runs ("workflow file issue", zero jobs, no `gate / Merge Gate` context at all). Merge the new file first, then reference the released `v1` tag in a second PR. This repo's own gate calls -`./.github/workflows/_ci-gate.yml` by local path. +`JacobPEvans/.github/.github/workflows/_ci-gate.yml@v1`. ## When in doubt @@ -216,5 +215,5 @@ reference the released `v1` tag in a second PR. This repo's own gate calls - Read this repo's `biome.jsonc` for current lint/format rules. - Read [`dryvist/cc-edge-pack-template`](https://github.com/dryvist/cc-edge-pack-template) for Cribl-specific test/build scaffolding. -- For release-please specifics, the org-native workflow's docstring at - `.github/workflows/_release-please.yml` in this repo is authoritative. +- For release-please specifics, the `_release.yml` docstring in + `JacobPEvans/.github` is authoritative. diff --git a/README.md b/README.md index 45abf4b..d95d4dd 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,14 @@ on: jobs: gate: permissions: { contents: read, pull-requests: read, actions: write } - uses: dryvist/.github/.github/workflows/_ci-gate.yml@<40-hex commit SHA> # vX.Y.Z + uses: JacobPEvans/.github/.github/workflows/_ci-gate.yml@v1 ``` `markdown_lint` and `file_size` are on by default and `filters` defaults to the markdown block; pass `with:` only for a `nix`/`python`/`ansible`/ `terraform` filter, a toggle (`nix_validate`, `python_ci`, `tofu_ci`, -`osv_scan`, …), or a `runner_label`. The header of `_ci-gate.yml` lists -every input. +`osv_scan`, …), or a `runner_label`. The header of `_ci-gate.yml` in +[`JacobPEvans/.github`](https://github.com/JacobPEvans/.github) lists every input. `secret_scan` is on by default: `Secret Scan (gitleaks)` and `Secret Scan (trufflehog)` run on every pull request, are not filter-gated, scan only the @@ -98,7 +98,7 @@ instead — the template already includes the canonical configs. ### Wire up release-please in a dryvist repo Each repo needs `.release-please-manifest.json` + `release-please-config.json` -plus a thin caller workflow that delegates to the org-native reusable workflow: +plus a thin caller workflow that delegates to the shared reusable workflow: ```yaml # .github/workflows/release-please.yml @@ -112,7 +112,7 @@ jobs: permissions: contents: write pull-requests: write - uses: dryvist/.github/.github/workflows/_release-please.yml@<40-hex commit SHA> # vX.Y.Z + uses: JacobPEvans/.github/.github/workflows/_release-please.yml@v1 secrets: GH_ACTION_RELEASE_PLEASE_PRIVATE_KEY: ${{ secrets.GH_ACTION_RELEASE_PLEASE_PRIVATE_KEY }} ``` @@ -148,13 +148,12 @@ Org-level prereqs (one-time, owner-handled) for the dryvist release App: ## Calling the shared workflows -Callers pin each reusable workflow to a commit SHA, with the release tag as a trailing comment: +The shared reusable workflows live in `JacobPEvans/.github`. Callers use its floating major tag: ```yaml -uses: dryvist/.github/.github/workflows/_ci-gate.yml@<40-hex commit SHA> # vX.Y.Z +uses: JacobPEvans/.github/.github/workflows/_ci-gate.yml@v1 ``` -- Renovate updates the SHA and the version comment together, in one PR per consumer. - Patch updates merge automatically after CI passes. Minor and major updates are merged by a person. Release PRs and small documentation-projection PRs merge themselves. - Nix, Ansible and OpenTofu git references use the floating major tag (`?ref=vN`). The lock file records the exact revision. @@ -177,11 +176,9 @@ This repo exposes the following inheritance surfaces: | `precommit/` | Shared pre-commit layer (canonical lint configs + static YAML templates); see [`precommit/README.md`](precommit/README.md) | | `zizmor.yml` | Org-wide zizmor workflow-security policy (referenced by the pre-commit `zizmor` hook) | | Generic reusable workflows | `JacobPEvans/.github`, consumed via `uses: JacobPEvans/.github/.github/workflows/@v1` | -| Org-specific reusable workflows | `_ci-gate.yml`, `_disclosure-gate.yml`, `_flake-lock-fresh.yml`, `_release-please.yml`, `renovate-config-validate.yml` | | `.github/workflows/{markdownlint,conventions-check,ai-review}.yml` | Org-wide Required Workflows; `tofu-github` rulesets inject them into every repo's PRs | | `configs/` | Org-specific configs retained here, including `gitignore` and `release-please-config.json` | | `configs/gitignore` | Org-default `.gitignore` baseline (secrets, credentials, TF state, AI-assistant local state); appended per repo at scaffold | -| `scripts/` | Helpers used by workflows retained here, including `ci-gate-watchdog.sh` and `flake-lock-fresh.sh` | | repo-local `osv-scanner.toml` | Optional OSV config passed to `JacobPEvans/.github`'s `_osv-scan.yml` `config_file` input | | `SECURITY.md` | Org-wide vulnerability reporting policy (auto-applied to every dryvist repo's Security tab) | | `profile/README.md` | Org profile page at | diff --git a/VERSION b/VERSION deleted file mode 100644 index 1b92e58..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.19.3 diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml deleted file mode 100644 index bd93ce1..0000000 --- a/molecule/default/converge.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -- name: Converge the canary marker - hosts: all - gather_facts: false - tasks: - - name: Write the canary marker file - ansible.builtin.copy: - dest: /tmp/dryvist-canary-marker - content: canary - mode: "0644" diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml deleted file mode 100644 index e1fe324..0000000 --- a/molecule/default/molecule.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- -# Delegated driver: no instances are created. The converge and verify run on -# the runner itself. Exercises the molecule path without Docker or a VM. -driver: - name: default - options: - managed: false -provisioner: - name: ansible - inventory: - hosts: - all: - hosts: - localhost: - ansible_connection: local -verifier: - name: ansible -scenario: - test_sequence: - - converge - - idempotence - - verify diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml deleted file mode 100644 index 7726c1a..0000000 --- a/molecule/default/verify.yml +++ /dev/null @@ -1,15 +0,0 @@ ---- -- name: Verify the canary marker - hosts: all - gather_facts: false - tasks: - - name: Stat the canary marker file - ansible.builtin.stat: - path: /tmp/dryvist-canary-marker - register: canary_marker - - - name: Assert the marker exists with the expected content - ansible.builtin.assert: - that: - - canary_marker.stat.exists - - canary_marker.stat.size == 6 diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index b1bb10a..0000000 --- a/release-please-config.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "packages": { - ".": { - "release-type": "simple", - "version-file": "VERSION", - "include-v-in-tag": true, - "changelog-sections": [ - {"type": "feat", "section": "Features"}, - {"type": "fix", "section": "Bug Fixes"}, - {"type": "perf", "section": "Performance"}, - {"type": "docs", "section": "Documentation", "hidden": true}, - {"type": "chore", "section": "Miscellaneous", "hidden": true}, - {"type": "refactor", "section": "Refactoring", "hidden": true}, - {"type": "test", "section": "Tests", "hidden": true}, - {"type": "ci", "section": "CI", "hidden": true} - ] - } - } -} diff --git a/requirements-ci.txt b/requirements-ci.txt deleted file mode 100644 index 185a0c9..0000000 --- a/requirements-ci.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Molecule runner for molecule/default (the canary's delegated-driver scenario). -molecule==26.10.0 -ansible-core==2.21.5 -ansible-lint==26.10.1 diff --git a/scripts/bot-sweep-policy.sh b/scripts/bot-sweep-policy.sh deleted file mode 100755 index 0e3e083..0000000 --- a/scripts/bot-sweep-policy.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# Per-PR policy for the bot PR automerge sweep. Reads one `gh pr view --json` object on -# stdin plus REPO (owner/name) and prints `proceed` or `skip:`. -# -# A PR from SMALL_DIFF_PROJECTION_AUTHOR proceeds only in SMALL_DIFF_PROJECTION_REPO, with -# at most SMALL_DIFF_MAX_FILES changed files and SMALL_DIFF_MAX_LINES added plus deleted -# lines, a complete changed-file list, and no path under .github/workflows/ or matching a -# secrets, credentials, auth, permissions, .env, .pem or .key rule. Every other author -# proceeds to the sweep's remaining gates. -set -euo pipefail - -if ! pr=$(jq -c -s ' - if length == 1 and (.[0] | type) == "object" then .[0] - else error("expected one PR object") - end -' 2>/dev/null); then - echo "expected exactly one PR JSON object on stdin" >&2 - exit 2 -fi - -author=$(jq -r '.author.login // ""' <<<"$pr") -projection_author=${SMALL_DIFF_PROJECTION_AUTHOR:-app/openbao-service-broker} -projection_repo=${SMALL_DIFF_PROJECTION_REPO:-dryvist/docs} -repo=${REPO:-} -result=proceed - -if [[ "$author" == "$projection_author" ]]; then - if [[ "$repo" != "$projection_repo" ]]; then - result=skip:author-not-allowed-here - else - max_files=${SMALL_DIFF_MAX_FILES:-5} - max_lines=${SMALL_DIFF_MAX_LINES:-100} - if [[ ! "$max_files" =~ ^(0|[1-9][0-9]*)$ || ! "$max_lines" =~ ^(0|[1-9][0-9]*)$ ]]; then - result=skip:size-unverifiable - else - shape=$(jq -r ' - if (.changedFiles | type) != "number" - or (.additions | type) != "number" - or (.deletions | type) != "number" - or (.files | type) != "array" - or any(.files[]; (.path | type) != "string") - then "invalid" - else "valid" - end - ' <<<"$pr") - if [[ "$shape" != valid ]]; then - result=skip:size-unverifiable - else - size_policy=$(jq -r --argjson max_files "$max_files" --argjson max_lines "$max_lines" ' - if .changedFiles != (.files | length) then "size-unverifiable" - elif .changedFiles > $max_files or (.additions + .deletions) > $max_lines then "too-large" - elif any(.files[].path; - startswith(".github/workflows/") - or test("(^|/)(secrets?|credentials?|auth|permissions?|\\.env)([./_-]|$)"; "i") - or test("\\.(pem|key)$"; "i") - ) then "sensitive-path" - else "proceed" - end - ' <<<"$pr") - [[ "$size_policy" == proceed ]] || result="skip:$size_policy" - fi - fi - fi -fi - -printf '%s\n' "$result" diff --git a/scripts/cancel-on-close.sh b/scripts/cancel-on-close.sh deleted file mode 100755 index 2b83d04..0000000 --- a/scripts/cancel-on-close.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Cancel the queued and in-progress workflow runs on a closed PR's head branch. -# -# Called by the reusable _cancel-on-close.yml from a `pull_request: closed` run. -# -# Usage: cancel-on-close.sh -# Env: REPO owner/name of the repository (required) -# GH_TOKEN token with actions:write (read by gh) -# RUN_ID the run executing this script; it is never cancelled (optional) -# -# A run that is already gone (HTTP 404, or gh's "could not find any workflow run") -# or already completed counts as cancelled. -# Any other error fails the script. -set -euo pipefail - -branch=${1:?usage: cancel-on-close.sh } -repo=${REPO:?REPO is required} -keep=${RUN_ID:-} - -runs=$(gh run list --repo "$repo" --branch "$branch" --limit 1000 --json databaseId,status \ - --jq '.[] | select(.status == "queued" or .status == "in_progress") | .databaseId') - -while read -r id; do - [ -n "$id" ] || continue - [ "$id" = "$keep" ] && continue - if err=$(gh run cancel "$id" --repo "$repo" 2>&1); then - echo "cancelled run $id" - elif grep -qiE 'HTTP 404|could not find any workflow run|already completed|that is completed' <<<"$err"; then - echo "::notice::run $id is already gone or completed; treated as cancelled" - else - echo "$err" >&2 - exit 1 - fi -done <<<"$runs" diff --git a/scripts/check-template-callers.sh b/scripts/check-template-callers.sh deleted file mode 100755 index e5dbd99..0000000 --- a/scripts/check-template-callers.sh +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env bash -# Static caller-contract check. Each caller is a workflow whose job calls -# _ci-gate.yml; every caller is checked against this checkout's -# .github/workflows/_ci-gate.yml: -# 1. each `with:` key of the calling job is an on.workflow_call.inputs key; -# 2. each input marked `required: true` is passed; -# 3. the calling job's permissions grant reaches the highest level each scope -# needs across the gate's jobs (a job without a block inherits the workflow -# default) and across the ./ reusables those jobs call. -# -# A caller is named in messages by its file name without .yml. Needs mikefarah -# yq v4 (preinstalled on ubuntu-24.04). Shorthand permissions (read-all) are not -# supported: yq fails on them, and a failed query fails the check. -# -# Usage: scripts/check-template-callers.sh ... -# Exit codes: -# 0 — every caller matches the gate -# 1 — a mismatch, or a caller with no job that calls _ci-gate.yml - -set -euo pipefail - -if [[ $# -eq 0 ]]; then - echo "usage: $0 ..." >&2 - exit 2 -fi -if ! yq --version 2>&1 | grep -q mikefarah; then - echo "::error::needs mikefarah yq v4 on PATH" >&2 - exit 1 -fi - -repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -gate="${repo}/.github/workflows/_ci-gate.yml" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -fail=0 - -err() { - echo "::error::$*" - fail=1 -} - -# Rank of a permission level: none 0, read 1, write 2. -rank() { - case "$1" in - write) echo 2 ;; - read) echo 1 ;; - *) echo 0 ;; - esac -} - -# "scope level" for every permission a workflow's jobs request, then the same -# for each ./ reusable they call. Other repositories are not in this checkout. -requested() { - local file=$1 depth=$2 uses path - if ((depth > 5)); then - echo "::error::reusable nesting deeper than 5 at ${file#"$repo"/}" >&2 - exit 1 - fi - # A job without a permissions block inherits the workflow default: parent(2) - # is the document root from a job node (job -> jobs map -> root). - yq -r '.jobs[] | (.permissions // parent(2).permissions // {}) | to_entries[] | "\(.key) \(.value)"' "$file" - uses=$(yq -r '.jobs[] | select((.uses // "") | test("^\\./")) | .uses' "$file") - while read -r path; do - if [[ -n $path ]]; then - requested "${repo}/${path#./}" $((depth + 1)) - fi - done <<< "$uses" -} - -# Highest level per scope, from "scope level" lines on stdin. -highest() { - awk 'function rank(l) { return l == "write" ? 2 : (l == "read" ? 1 : 0) } - NF == 2 && rank($2) > rank(top[$1]) { top[$1] = $2 } - END { for (s in top) print s, top[s] }' -} - -check_caller() { - local file=$1 name jobs job passed granted scope need_level have - name=$(basename "$file" .yml) - jobs=$(yq -r '.jobs | to_entries[] | select((.value.uses // "") | contains("_ci-gate.yml")) | .key' "$file") - if [[ -z $jobs ]]; then - err "${name}: no job calls _ci-gate.yml" - return 0 - fi - while read -r job; do - [[ -n $job ]] || continue - passed=$(JOB="$job" yq -r '(.jobs[env(JOB)].with // {}) | keys | .[]' "$file") - while read -r key; do - [[ -n $key ]] || continue - grep -qxF "$key" "$work/inputs" || - err "${name}: job '${job}' passes '${key}', which is not an input of _ci-gate.yml" - done <<< "$passed" - while read -r key; do - [[ -n $key ]] || continue - grep -qxF "$key" <<< "$passed" || - err "${name}: job '${job}' omits required input '${key}'" - done < "$work/required" - granted=$(JOB="$job" yq -r '(.jobs[env(JOB)].permissions // .permissions // {}) | to_entries[] | "\(.key) \(.value)"' "$file") - while read -r scope need_level; do - [[ -n $scope ]] || continue - have=$(awk -v s="$scope" '$1 == s { print $2 }' <<< "$granted") - if (($(rank "${have:-none}") < $(rank "$need_level"))); then - err "${name}: job '${job}' grants ${scope}: ${have:-none}; _ci-gate.yml needs ${need_level}" - fi - done < "$work/need" - done <<< "$jobs" -} - -yq -r '(.on.workflow_call.inputs // {}) | keys | .[]' "$gate" > "$work/inputs" -yq -r '(.on.workflow_call.inputs // {}) | to_entries[] | select(.value.required == true) | .key' "$gate" > "$work/required" -requested "$gate" 0 > "$work/need.raw" -highest < "$work/need.raw" > "$work/need" - -for caller in "$@"; do - check_caller "$caller" -done - -if ((fail)); then - exit 1 -fi -echo "every caller matches $(basename "$gate")" diff --git a/scripts/ci-gate-aggregate.mts b/scripts/ci-gate-aggregate.mts deleted file mode 100644 index 9ed4361..0000000 --- a/scripts/ci-gate-aggregate.mts +++ /dev/null @@ -1,68 +0,0 @@ -import { pathToFileURL } from 'node:url'; - -type JobResult = { - result?: unknown; -}; - -type Needs = Record; - -const knownResults = new Set(['success', 'failure', 'cancelled', 'skipped']); - -function gateFailures( - needs: Needs, - allowedSkips: ReadonlySet, - allowedFailures: ReadonlySet, -): string[] { - return Object.entries(needs).flatMap(([name, job]) => { - const result = job.result; - if (result === 'success' || (result === 'skipped' && allowedSkips.has(name))) return []; - if (result === 'failure' && allowedFailures.has(name)) return []; - if (typeof result !== 'string' || !knownResults.has(result)) { - return [`job "${name}" returned unknown result ${JSON.stringify(result) ?? String(result)}`]; - } - return [`job "${name}" returned ${result}`]; - }); -} - -function parseSet(value: string | undefined): ReadonlySet { - return new Set((value ?? '').split(',').map((entry) => entry.trim()).filter(Boolean)); -} - -function isNeeds(value: unknown): value is Needs { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.values(value).every((job) => typeof job === 'object' && job !== null && !Array.isArray(job)) - ); -} - -function main(): void { - let parsed: unknown; - try { - parsed = JSON.parse(process.env.CI_GATE_JOBS ?? ''); - } catch { - console.error('Merge Gate failed: CI_GATE_JOBS is not valid JSON.'); - process.exitCode = 1; - return; - } - if (!isNeeds(parsed)) { - console.error('Merge Gate failed: CI_GATE_JOBS must be an object of job results.'); - process.exitCode = 1; - return; - } - - const failures = gateFailures( - parsed, - parseSet(process.env.CI_GATE_ALLOWED_SKIPS), - parseSet(process.env.CI_GATE_ALLOWED_FAILURES), - ); - if (failures.length > 0) { - console.error(`Merge Gate failed: ${failures.join('; ')}.`); - process.exitCode = 1; - return; - } - console.log(`Merge Gate passed: ${Object.keys(parsed).length} jobs checked.`); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/scripts/ci-gate-detect-deps-only.sh b/scripts/ci-gate-detect-deps-only.sh deleted file mode 100755 index e70b03d..0000000 --- a/scripts/ci-gate-detect-deps-only.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# ci-gate-detect-deps-only.sh — invoked by the `changes` job in _ci-gate.yml. -# -# Feeds the opt-in `nix_skip_deps_only` toggle: writes `is-deps-only=true` to -# $GITHUB_OUTPUT when the PR looks like a pure dependency bump, so callers can -# skip `nix-validate`/`nix-build` on it (still overridden by a flake.lock -# change — see `is-flake-lock-change` in the same job). -# -# - Renovate: author + chore(deps) title (dual check since Renovate can also -# open non-dependency PRs). -# - Dependabot: author check alone (it exclusively opens dependency PRs). -# -# The Renovate author was `jacobpevans-github-actions*`, which no pull request -# has ever carried: every dependency PR in this org is opened by the Renovate -# GitHub App, whose `user.login` is `renovate[bot]`. So this always answered -# `false` and the toggle it feeds had never skipped anything. Verified against -# 21 `chore(deps` PRs across three repos — all `app/renovate`, none matching. -# A detector that cannot fire looks identical to one that decided not to. -# -# AUTHOR/COMMIT_MSG are empty on non-pull_request events, which safely -# evaluates to is-deps-only=false (never skip). -# -# Required env: -# AUTHOR — github.event.pull_request.user.login -# COMMIT_MSG — github.event.pull_request.title -# GITHUB_OUTPUT — GitHub Actions output file (set by the runner) - -set -euo pipefail - -# --self-check: exercise both answers without a runner. Guards the author -# literals, which is the part that rotted — a wrong one still exits 0 and -# reports "not a dependency PR" forever. -if [[ ${1:-} == --self-check ]]; then - fail=0 - for case in "renovate[bot]|chore(deps): bump x|true" \ - "renovate[bot]|feat: a real change|false" \ - "dependabot[bot]|chore(deps): bump y|true" \ - "someone|chore(deps): bump z|false" \ - "||false"; do - IFS='|' read -r a m want <<<"$case" - out=$(mktemp) - AUTHOR=$a COMMIT_MSG=$m GITHUB_OUTPUT=$out bash "$0" - got=$(sed 's/^is-deps-only=//' "$out") - rm -f "$out" - [[ $got == "$want" ]] || { echo "FAIL: author='$a' title='$m' -> $got, want $want" >&2; fail=1; } - done - [[ $fail == 0 ]] && echo "self-check OK: 5 cases" - exit $fail -fi - -: "${GITHUB_OUTPUT:?required}" - -if [[ ("${AUTHOR:-}" == "renovate[bot]" && "${COMMIT_MSG:-}" == chore\(deps\)*) || \ - "${AUTHOR:-}" == "dependabot[bot]" ]]; then - echo "is-deps-only=true" >>"$GITHUB_OUTPUT" -else - echo "is-deps-only=false" >>"$GITHUB_OUTPUT" -fi diff --git a/scripts/ci-gate-run-report.sh b/scripts/ci-gate-run-report.sh deleted file mode 100755 index a5d7c3e..0000000 --- a/scripts/ci-gate-run-report.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# ci-gate-run-report.sh — invoked by the `gate` job in _ci-gate.yml. -# -# Writes a per-job run report to $GITHUB_STEP_SUMMARY: each job's result, -# execution duration (completed_at - started_at), and queue delay -# (started_at - created_at). The `gate` job runs last with every sibling in a -# terminal state, so job timestamps are final here — the only point in the run -# where a queue-delay scan reads true values. -# -# Also appends a loud runner-capacity warning naming any job that sat longer -# than 5 minutes in `queued` before a runner picked it up. Warning only: this -# script never fails the gate (the workflow step is `continue-on-error`). -# -# Required env: -# GH_TOKEN — GitHub token with actions:read on the run -# REPO — owner/repo of the current workflow run -# RUN_ID — workflow run id -# GITHUB_STEP_SUMMARY — summary file path (auto-set by the runner) - -set -euo pipefail - -: "${GH_TOKEN:?required}" -: "${REPO:?required}" -: "${RUN_ID:?required}" -: "${GITHUB_STEP_SUMMARY:?required}" - -# Queue-delay threshold (seconds) above which a job is flagged as a -# runner-capacity signal. 5 minutes. -queue_warn_seconds=300 - -jobs_json="$(gh api --paginate "repos/${REPO}/actions/runs/${RUN_ID}/jobs?per_page=100")" - -# fromdateiso8601 parses the `...Z` timestamps GitHub returns. Durations are -# integer seconds, so `dur` can use modulo safely. A running job (the gate -# itself) has no completed_at, so its duration renders as "—". -{ - echo "### CI job durations" - echo "" - echo "| Job | Result | Duration | Queue delay |" - echo "| --- | --- | --- | --- |" - jq -r ' - def dur: if . >= 60 then "\((./60|floor))m \((.%60))s" else "\(.)s" end; - .jobs[] - | (if .started_at and .completed_at - then ((.completed_at|fromdateiso8601) - (.started_at|fromdateiso8601)) | dur - else "—" end) as $d - | (if .started_at and .created_at - then ((.started_at|fromdateiso8601) - (.created_at|fromdateiso8601)) - else null end) as $q - | "| \(.name) | \(.conclusion // .status) | \($d) | \(if $q == null then "—" else ($q|dur) end) |" - ' <<<"$jobs_json" -} >>"$GITHUB_STEP_SUMMARY" - -slow="$(jq -r --argjson threshold "$queue_warn_seconds" ' - def dur: if . >= 60 then "\((./60|floor))m \((.%60))s" else "\(.)s" end; - .jobs[] - | select(.started_at and .created_at) - | { name, q: ((.started_at|fromdateiso8601) - (.created_at|fromdateiso8601)) } - | select(.q > $threshold) - | "\(.name) waited \(.q|dur) in queue" -' <<<"$jobs_json")" - -if [ -n "$slow" ]; then - { - echo "" - echo "> [!WARNING]" - echo "> Runner-capacity signal — jobs queued longer than 5 minutes:" - while IFS= read -r line; do - echo "> - ${line}" - done <<<"$slow" - } >>"$GITHUB_STEP_SUMMARY" -fi diff --git a/scripts/ci-gate-unmapped.sh b/scripts/ci-gate-unmapped.sh deleted file mode 100644 index d668183..0000000 --- a/scripts/ci-gate-unmapped.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Flags changed files that no profile filter covers (docs and release-please -# metadata excepted). -# -# Reads dorny/paths-filter `list-files: json` output from the environment: -# ALL every changed file (the resolve step's `changed-all` key) -# NIX, MARKDOWN, PYTHON, ANSIBLE, TERRAFORM, EXTRA -# the files each convention filter matched -# Each variable is a JSON array; an empty or unset one counts as []. -# -# For every unmapped path it prints a GitHub warning, then writes -# full=true|false to $GITHUB_OUTPUT. It never exits non-zero for an unmapped -# path: an unmapped path runs the full profile, it never fails the gate. -set -euo pipefail - -unmapped=$(jq -r -n \ - --argjson all "${ALL:-[]}" \ - --argjson nix "${NIX:-[]}" \ - --argjson markdown "${MARKDOWN:-[]}" \ - --argjson python "${PYTHON:-[]}" \ - --argjson ansible "${ANSIBLE:-[]}" \ - --argjson terraform "${TERRAFORM:-[]}" \ - --argjson extra "${EXTRA:-[]}" \ - '($nix + $markdown + $python + $ansible + $terraform + $extra) as $mapped - | $all[] | select(endswith(".md") | not) - | select(IN("VERSION", ".release-please-manifest.json", "release-please-config.json") | not) - | . as $f - | select(any($mapped[]; . == $f) | not)') - -full=false -while IFS= read -r path; do - if [[ -z "$path" ]]; then - continue - fi - echo "::warning::unmapped path ${path}, running full profile" - full=true -done <<< "$unmapped" - -echo "full=${full}" >> "${GITHUB_OUTPUT:-/dev/stdout}" diff --git a/scripts/ci-gate-uses-resolve.sh b/scripts/ci-gate-uses-resolve.sh deleted file mode 100644 index e652cd8..0000000 --- a/scripts/ci-gate-uses-resolve.sh +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env bash -# Fails when a caller workflow's `uses:` names a reusable workflow or local -# path that does not exist. -# ./.github/workflows/ must exist in the checked-out repo -# JacobPEvans/.github or JacobPEvans/.github-ai, .github/workflows/@ -# must exist at , read via the API -# Other `uses:` values are not checked: other dryvist repos may be private, -# and this run's token cannot read them, so a valid ref would false-fail. -# Other dryvist refs print a notice instead, and actions are ignored. -# actionlint does not resolve either form, so this check is the enforcement. -# -# Pin policy, checked on every dryvist reference in the checked-out repo -# (`uses:` refs are checked by pr-policy.yml): -# flake.nix github:dryvist/... input carries ?ref=v -# .envrc no `use flake` of a remote dryvist flake -# requirements.yml dryvist git source version is not main, develop or HEAD -# warn mode: each finding is a ::warning annotation and the script exits 0. -# fail mode: each finding is an ::error annotation and the script exits 1. -# -# Environment: -# GH_TOKEN token for `gh api` (dryvist remote refs only) -# PIN_POLICY_MODE fail (default) or warn -set -euo pipefail - -mode=${PIN_POLICY_MODE:-fail} -case "$mode" in - warn | fail) ;; - *) - echo "::error::PIN_POLICY_MODE must be warn or fail, not '${mode}'" - exit 2 - ;; -esac - -errors=0 -findings=0 - -fail() { - echo "::error file=${1}::${1} calls ${2}, which does not resolve (${3})" - errors=$((errors + 1)) -} - -# One pin-policy finding: a warning in warn mode, an error in fail mode. -policy() { - local level=warning - findings=$((findings + 1)) - if [ "$mode" = fail ]; then level=error; fi - echo "::${level} file=${1},line=${2}::${3}" -} - -# Files with the given name, skipping VCS, worktree and dependency trees. -find_named() { - find . \( -name .git -o -name .worktrees -o -name node_modules \) -prune -o -name "$1" -print -} - -shopt -s nullglob -uses_files=(.github/workflows/*.yml .github/workflows/*.yaml) -while IFS= read -r action; do - uses_files+=("${action#./}") -done < <(find_named action.yml; find_named action.yaml) -# A `uses` key as a block entry, a flow-style entry (`{uses: ...}`, `, uses: ...`) or a quoted key. -uses_key='(^|[[:space:]{,"'\''])uses["'\'']?[[:space:]]*:' -for wf in ${uses_files[@]+"${uses_files[@]}"}; do - while IFS= read -r hit; do - lineno=${hit%%:*} - line=${hit#*:} - line=$(printf '%s' "$line" | tr -d "\"'{}") - value=${line#*uses:} - value=${value%%#*} - value=${value%%,*} - value=$(printf '%s' "$value" | tr -d " \t") - # The org name is matched without case; the original text is kept for messages. - lower=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]') - - case "$lower" in - ./.github/workflows/*) - if [[ ! -f "${value#./}" ]]; then - fail "$wf" "$value" "local file missing" - fi - ;; - dryvist/*/.github/workflows/*@* | jacobpevans/*/.github/workflows/*@*) - owner=${value%%/*} - rest=${value#*/} - repo=${rest%%/*} - if [[ "$repo" != ".github" && "$repo" != ".github-ai" && "$repo" != "ai-workflows" ]]; then - echo "::notice::not checked (${value})" - continue - fi - rest=${rest#*/} - file=${rest%@*} - ref=${value##*@} - file=${file#.github/workflows/} - if ! gh api --method HEAD \ - "repos/${owner}/${repo}/contents/.github/workflows/${file}?ref=${ref}" \ - >/dev/null 2>&1; then - fail "$wf" "$value" "not found at ref, or not readable with this token" - fi - ;; - esac - done < <({ grep -nE "$uses_key" "$wf" || true; } | { grep -vE '^[0-9]+:[[:space:]]*#' || true; }) -done - -# A flake input is pinned when its ref is a floating major tag (v) or an exact -# release tag (v..), which a producer that cannot carry a floating -# tag (an archived repository) uses. A branch, or no ref at all, is not a pin. -flake_ref='ref=v[0-9]+(\.[0-9]+\.[0-9]+)?([^.0-9A-Za-z_-]|$)' -while IFS= read -r file; do - file=${file#./} - while IFS= read -r hit; do - lineno=${hit%%:*} - text=${hit#*:} - stripped=${text#"${text%%[![:space:]]*}"} - case "$stripped" in - '#'*) continue ;; - esac - if [[ ! $text =~ $flake_ref ]]; then - policy "$file" "$lineno" "dryvist flake input has no ?ref=v (or an exact release tag)" - fi - done < <(grep -nE 'github:(dryvist|JacobPEvans)/|git[+][a-z]+://(git@)?github[.]com/(dryvist|JacobPEvans)/' "$file" || true) -done < <(find_named flake.nix) - -# `use flake` of a remote flake whose address names the dryvist org, or the legacy -# JacobPEvans owner that redirects to it. -envrc_remote='^[[:space:]]*use[[:space:]]+flake[[:space:]]+["'\'']?[a-zA-Z+]+[:@][^[:space:]]*(dryvist|JacobPEvans)/' -while IFS= read -r file; do - file=${file#./} - while IFS= read -r hit; do - policy "$file" "${hit%%:*}" "use flake loads a remote dryvist flake" - done < <(grep -nE "$envrc_remote" "$file" || true) -done < <(find_named .envrc) - -# A dryvist git source (a remote URL naming dryvist/) whose version is a branch. -# The version is the key on the source's own list entry, or the comma suffix of -# a bare `url,version` entry. -while IFS= read -r file; do - file=${file#./} - while IFS=$'\t' read -r lineno version; do - policy "$file" "$lineno" "dryvist git source pinned to branch ${version}; pin a release version" - done < <(awk ' - function flush() { - if (src && (ver == "main" || ver == "develop" || ver == "head")) { - printf "%d\t%s\n", vline, ver - } - } - /^[[:space:]]*-/ { flush(); src = 0; ver = ""; vline = NR } - index($0, "dryvist/") && $0 ~ /git[+]|:\/\/|git@|github[.]com/ { - src = 1 - if ($0 ~ /,[[:space:]]*[A-Za-z]+[[:space:]]*$/) { - v = $0 - sub(/.*,[[:space:]]*/, "", v) - sub(/[[:space:]]+$/, "", v) - ver = tolower(v) - vline = NR - } - } - /^[[:space:]]*(-[[:space:]]+)?version:/ { - v = $0 - sub(/^[[:space:]]*(-[[:space:]]+)?version:[[:space:]]*/, "", v) - gsub(/"/, "", v) - gsub(sprintf("%c", 39), "", v) - sub(/[[:space:]]*#.*$/, "", v) - sub(/[[:space:]]+$/, "", v) - ver = tolower(v) - vline = NR - } - END { flush() } - ' "$file") -done < <(find_named requirements.yml) - -if [[ "$errors" -gt 0 ]]; then - echo "uses resolve check: ${errors} unresolved reference(s)" - exit 1 -fi -if [[ "$findings" -gt 0 ]]; then - echo "pin policy ${mode}: ${findings} finding(s)" - if [[ "$mode" = fail ]]; then - exit 1 - fi -fi -echo "uses resolve check: all reusable workflow references resolve" diff --git a/scripts/ci-gate-watchdog.sh b/scripts/ci-gate-watchdog.sh deleted file mode 100755 index 30ba53c..0000000 --- a/scripts/ci-gate-watchdog.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# ci-gate-watchdog.sh — invoked by the `watchdog` job in _ci-gate.yml. -# -# Watch the run's sibling jobs for the whole run. Cancel the run if any job still -# waiting for a runner (`queued`, `pending` or `requested`) is older than -# QUEUE_TIMEOUT_MINUTES, measured per job from its created_at. Exit 0 once every -# sibling is `completed`. The Actions API does not expose job cancellation; -# cancelling the run keeps an incomplete check set from passing. -# -# The watchdog no longer exits at the first moment nothing is queued: jobs created -# later in the run (matrix legs, called workflows) and jobs reported as `pending` -# would go unwatched. It therefore lives as long as the run. On GitHub-hosted -# runners siblings finish in minutes, so it still exits as soon as the last one -# completes. `waiting` (environment approval) is not a runner queue and is never -# cancelled, but it keeps the watchdog polling until the job's timeout-minutes -# ceiling. "Queue Watchdog" (this job) and "Merge Gate" (awaiting this job) are -# exempt; their state here is intentional. Inside a called workflow GitHub -# prefixes job names ("nix / Merge Gate"), so the exemption matches the last -# " / " segment, which also exempts sibling watchdogs from other gate calls. -# -# Required env: -# GH_TOKEN — GitHub token with actions:write on the run -# QUEUE_TIMEOUT_MINUTES — minutes a job may wait for a runner before the run is cancelled -# REPO — owner/repo of the current workflow run -# RUN_ID — workflow run id -# Optional env: -# WATCH_MAX_MINUTES — minutes after which the watchdog stops watching and exits 0 -# (default 55; keep it below the job's timeout-minutes) - -set -euo pipefail - -: "${GH_TOKEN:?required}" -: "${QUEUE_TIMEOUT_MINUTES:?required}" -: "${REPO:?required}" -: "${RUN_ID:?required}" - -# One line per non-exempt sibling: id, status, whole seconds since created_at, name. -# gh evaluates --jq itself (gojq, which has now and fromdateiso8601), so the runner -# needs no jq binary. -sibling_jobs() { - gh api --paginate "repos/${REPO}/actions/runs/${RUN_ID}/jobs?per_page=100" \ - --jq '.jobs[] - | select(.name | test("(^| / )(Queue Watchdog|Merge Gate)$") | not) - | "\(.id)\t\(.status)\t\(now - (.created_at | fromdateiso8601) | floor)\t\(.name)"' -} - -# awk parses QUEUE_TIMEOUT_MINUTES so a float (e.g. 0.5) truncates to an integer -# instead of crashing bash arithmetic, which only handles integers. -limit_seconds=$(awk "BEGIN{printf \"%d\", $QUEUE_TIMEOUT_MINUTES * 60}") -poll_interval=30 -# Stop watching before the job's own timeout-minutes, so a long healthy run never -# turns the watchdog red. $SECONDS is bash's elapsed-time builtin. -watch_seconds=$(awk "BEGIN{printf \"%d\", ${WATCH_MAX_MINUTES:-55} * 60}") -WATCH_MAX_MINUTES=${WATCH_MAX_MINUTES:-55} - -while :; do - jobs=$(sibling_jobs) - stuck=$(awk -F'\t' -v max="$limit_seconds" \ - '$2 ~ /^(queued|pending|requested)$/ && $3 > max' <<<"$jobs") - if [ -n "$stuck" ]; then - echo "Timeout (${QUEUE_TIMEOUT_MINUTES}m) reached; cancelling jobs still waiting for a runner:" - break - fi - unfinished=$(awk -F'\t' 'NF && $2 != "completed" { n++ } END { print n + 0 }' <<<"$jobs") - if [ "$unfinished" -eq 0 ]; then - echo "All sibling jobs completed. Nothing to cancel." - exit 0 - fi - if [ "$SECONDS" -ge "$watch_seconds" ]; then - echo "::notice::Queue Watchdog stopped watching after ${WATCH_MAX_MINUTES}m; no job had waited for a runner longer than ${QUEUE_TIMEOUT_MINUTES}m." - exit 0 - fi - sleep "$poll_interval" -done - -while IFS=$'\t' read -r _ status age job_name; do - echo "Stuck job: ${job_name} (${status}, ${age}s since created)" -done <<<"$stuck" - -echo "Cancelling workflow run ${RUN_ID}; jobs waiting for a runner prevent a complete check set." -gh api -X POST "repos/${REPO}/actions/runs/${RUN_ID}/cancel" diff --git a/scripts/ci-gate-workflow-lint.sh b/scripts/ci-gate-workflow-lint.sh deleted file mode 100644 index cc22b23..0000000 --- a/scripts/ci-gate-workflow-lint.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# Lints the working directory's .github/workflows with a pinned actionlint. -# -# Installs actionlint ACTIONLINT_VERSION from its GitHub release, verifies the -# archive against ACTIONLINT_SHA256, then runs it. The one filtered message is a -# known false positive: actionlint 1.7.12 does not define job.workflow_sha, which -# the reusable workflows use for their sparse checkouts (upstream -# rhysd/actionlint#707). Every other finding fails the job. -# -# Environment: -# ACTIONLINT_BIN use this binary instead of installing the pinned release -# RUNNER_TEMP scratch directory for the download (default: a mktemp dir) -set -euo pipefail - -ACTIONLINT_VERSION=1.7.12 -ACTIONLINT_SHA256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 -IGNORE='property "workflow_sha" is not defined' - -# Fails on a uses: ref that looks like a commit SHA (39+ hex characters) but is not exactly 40. -if grep -rnE 'uses:[[:space:]]*[^[:space:]]+/[^[:space:]@]+@([0-9a-fA-F]{39}|[0-9a-fA-F]{41,})([^0-9a-fA-F]|$)' .github/workflows; then - echo "::error::uses: ref looks like a commit SHA but is not 40 hex characters (matches above)" >&2 - exit 1 -fi - -if [[ -z "${ACTIONLINT_BIN:-}" ]]; then - scratch="${RUNNER_TEMP:-$(mktemp -d)}" - archive="${scratch}/actionlint.tar.gz" - curl -fsSL -o "$archive" \ - "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --strict - tar -xzf "$archive" -C "$scratch" actionlint - ACTIONLINT_BIN="${scratch}/actionlint" -fi - -"$ACTIONLINT_BIN" -ignore "$IGNORE" diff --git a/scripts/disclosure-denylist-scan.sh b/scripts/disclosure-denylist-scan.sh deleted file mode 100755 index b349953..0000000 --- a/scripts/disclosure-denylist-scan.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env bash -# Disclosure denylist scan — fails a PR if any line it adds, the PR title, or -# the PR body contains a fixed string from the org denylist -# (DISCLOSURE_DENYLIST, materialized to a file by the caller workflow). -# -# The denylist itself is sensitive (the terms it lists are exactly what must -# never appear publicly), so a hit is reported as `path:line` ONLY — never -# the matched term, never the line content. Printing either would re-leak -# the very thing the scan exists to catch. -# -# Usage: disclosure-denylist-scan.sh -# — one fixed string per line (blank lines / #-comments skipped) -# — git ref to diff against; only lines added since it are scanned -# -# Reads PR_TITLE / PR_BODY / PR_AUTHOR from the environment (may be unset outside -# a PR). For a renovate[bot] PR, the body's `### Release Notes` section (up to -# `### Configuration`) is skipped: it is upstream changelog text copied verbatim. -# -# Exit codes: -# 0 — no hits -# 1 — one or more hits (see stdout for path:line) -# 2 — instrument error (missing/empty denylist file, bad base ref) - -set -euo pipefail - -DENYLIST_FILE="${1:?usage: disclosure-denylist-scan.sh }" -BASE_REF="${2:?usage: disclosure-denylist-scan.sh }" - -if [[ ! -s "$DENYLIST_FILE" ]]; then - echo "::error::denylist file missing or empty: $DENYLIST_FILE" >&2 - exit 2 -fi - -# Strip blank lines and #-comments so an operator can annotate the source list. -PATTERNS_FILE="$(mktemp)" -trap 'rm -f "$PATTERNS_FILE"' EXIT -grep -vE '^[[:space:]]*(#|$)' "$DENYLIST_FILE" > "$PATTERNS_FILE" - -if [[ ! -s "$PATTERNS_FILE" ]]; then - echo "::error::denylist file has no active patterns after stripping comments/blanks" >&2 - exit 2 -fi - -# Patterns are matched with `grep -w`, which bounds each one to a whole token so -# a denylisted identifier no longer fires inside an unrelated word. `grep -w` -# counts underscore as a word character, so underscores are normalized to spaces -# on BOTH sides — patterns here, haystack at each call site — making `_` bound an -# identifier like every other separator. This mirrors gh-guard's hits_denylist(), -# whose measured result on 102 real PR bodies was 0% false positives with full -# recall on every hard identifier leak in that corpus. -BOUNDED_PATTERNS="$(mktemp)" -trap 'rm -f "$PATTERNS_FILE" "$BOUNDED_PATTERNS"' EXIT -tr '_' ' ' < "$PATTERNS_FILE" > "$BOUNDED_PATTERNS" - -hits=0 - -scan_file() { - local label="$1" path="$2" - [[ -f "$path" ]] || return 0 - # -n gives "line:content"; cut keeps only the number, so no matched term or - # line content ever reaches stdout. Underscores are normalized to spaces - # first: grep -w treats `_` as a word character, so without this an - # identifier glued to a word by `_` would escape the boundary. - while read -r lineno; do - [[ -n "$lineno" ]] || continue - echo "${label}:${lineno}" - hits=$((hits + 1)) - done < <(tr '_' ' ' < "$path" \ - | grep -niwFf "$BOUNDED_PATTERNS" \ - | cut -d: -f1 || true) -} - -# Lines the PR adds (base ref...HEAD). Unchanged lines in a touched file were -# already scanned when they were added, so a regenerated file (CHANGELOG) does -# not re-flag its history. Each hit reports the line's number in the new file. -scan_added() { - local f="$1" content numbers n - content="$(mktemp)"; numbers="$(mktemp)" - git diff -U0 --no-color --no-ext-diff "$BASE_REF"...HEAD -- "$f" | awk -v c="$content" -v l="$numbers" ' - /^@@/ { inhunk = 1; split($3, a, ","); line = substr(a[1], 2) + 0; next } - !inhunk { next } - /^\+/ { print substr($0, 2) > c; print line > l; line++ } - ' - while IFS=: read -r n _; do - [[ -n "$n" ]] || continue - echo "${f}:$(sed -n "${n}p" "$numbers")" - hits=$((hits + 1)) - done < <(tr '_' ' ' < "$content" \ - | grep -niwFf "$BOUNDED_PATTERNS" \ - | cut -d: -f1 || true) - rm -f "$content" "$numbers" -} - -if git rev-parse -q --verify "$BASE_REF" >/dev/null; then - while IFS= read -r f; do - [[ -n "$f" ]] || continue - scan_added "$f" - done < <(git diff --name-only --diff-filter=d "$BASE_REF"...HEAD -- . 2>/dev/null || true) -else - echo "::error::base ref not found: $BASE_REF" >&2 - exit 2 -fi - -# PR title / body, scanned the same way via pseudo-files so no title/body -# text is ever echoed back — only "PR title:1" / "PR body:" on a hit. -if [[ -n "${PR_TITLE:-}" ]]; then - title_file="$(mktemp)" - printf '%s\n' "$PR_TITLE" > "$title_file" - scan_file "PR title" "$title_file" - rm -f "$title_file" -fi -if [[ -n "${PR_BODY:-}" ]]; then - body_file="$(mktemp)" - if [[ "${PR_AUTHOR:-}" == "renovate[bot]" ]]; then - # Blank the skipped lines rather than drop them, so hits keep their body line numbers. - printf '%s\n' "$PR_BODY" \ - | awk '/^### Release Notes/ { skip = 1 } /^### Configuration/ { skip = 0 } { print (skip ? "" : $0) }' \ - > "$body_file" - else - printf '%s\n' "$PR_BODY" > "$body_file" - fi - scan_file "PR body" "$body_file" - rm -f "$body_file" -fi - -if [[ "$hits" -gt 0 ]]; then - echo "::error::disclosure denylist: ${hits} hit(s) — see path:line above" >&2 - exit 1 -fi - -echo "Disclosure denylist: 0 hits." -exit 0 diff --git a/scripts/flake-lock-fresh.sh b/scripts/flake-lock-fresh.sh deleted file mode 100644 index 03ac86a..0000000 --- a/scripts/flake-lock-fresh.sh +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env bash -# Check the direct flake inputs owned by one of OWNERS. -# -# Usage: flake-lock-fresh.sh [--pr BASE_LOCK] [flake.lock] -# default (freshness): fail when an owned input is not locked at its ref's commit. -# --pr BASE_LOCK: compare against the base branch's lock, with no live lookup of -# refs. Fail when an owned input moved to a rev that does -# not descend from the base lock's rev. An input whose ref changed -# is a deliberate re-pin and is not compared (the ref change shows in -# flake.nix). A missing BASE_LOCK means the lock is new and only the -# pinned-rev rule applies. -# Env: OWNERS space-separated input owners to check (case-insensitive); -# inputs from any other owner are ignored. Defaults to OWNED below. -# GIT_TOKEN optional token for private inputs -# STALE_NAMES_FILE optional; the stale input names are written there, one per line -# FLAKE_REF_POLICY fail (default) | warn; fail makes a dryvist input that names no -# floating major tag or exact release tag an error, warn reports -# it and keeps the branch check -# FLAKE_LOCK_REMOTE_BASE optional; URL prefix that /.git is read from -# (default https://github.com) -# Only root inputs of type github are checked. The ref is original.ref, resolved as a -# tag first and as a branch second; an input naming none resolves the repository's HEAD. -# An owned input pinned to a revision (original.rev) fails: ours always track a ref. -# A dryvist input must name a floating major tag (vN). Its locked rev is fresh when it is -# the tag's commit or one of that commit's ancestors, so the tag may move forward. -set -euo pipefail - -base= -if [ "${1:-}" = --pr ]; then base=${2:?--pr needs a base lock path}; shift 2; fi -lock=${1:-flake.lock} -[ -f "$lock" ] || { echo "no $lock, nothing to check"; exit 0; } -# The one list of owners whose flake inputs are ours. JacobPEvans redirects to dryvist. -OWNED="dryvist JacobPEvans JacobPEvans-personal" -OWNERS=${OWNERS:-$OWNED} -remote_base=${FLAKE_LOCK_REMOTE_BASE:-https://github.com} -[ -n "${STALE_NAMES_FILE:-}" ] && : >"$STALE_NAMES_FILE" -max_age_days=${MAX_AGE_DAYS:-0} -if ! [[ $max_age_days =~ ^[0-9]+$ ]]; then - echo "::error::MAX_AGE_DAYS must be a non-negative integer" - exit 2 -fi - -git_remote() { - local url=$1 - shift - if [ -n "${GIT_TOKEN:-}" ] && [[ $url == https://github.com/* ]]; then - GH_TOKEN=$GIT_TOKEN git -c credential.helper= -c 'credential.helper=!gh auth git-credential' "$@" - else - git "$@" - fi -} - -# remote_head : the commit names on the remote. ls-remote lists an annotated -# tag's own object under and its commit under ^{}; the peeled line wins. -remote_head() { - git_remote "$1" ls-remote "$1" "$2" "$2^{}" | awk -v w="$2" '$2 == (w "^{}") { p = $1 } $2 == w { t = $1 } END { print (p ? p : t) }' -} - -# reachable : succeeds when is the tag's commit or one of its ancestors. -# The tag's history is fetched without blobs into a scratch repository. -reachable() { - local tmp status=1 - tmp=$(mktemp -d) - if git init -q --bare "$tmp" && - git_remote "$1" -C "$tmp" fetch -q --no-tags --filter=blob:none "$1" "+refs/tags/$2:refs/tags/$2" && - git -C "$tmp" merge-base --is-ancestor "$3" "refs/tags/$2" 2>/dev/null; then - status=0 - fi - rm -rf "$tmp" - return "$status" -} - -github_compare() { - local url=$1 - if [ -n "${GIT_TOKEN:-}" ]; then - curl -fsSL -H @<(printf 'Authorization: Bearer %s\n' "$GIT_TOKEN") "$url" - else - curl -fsSL "$url" - fi -} - -# name owner/repo ref (PINNED when pinned to a rev, HEAD when none) locked rev -# (no field is ever empty) -inputs=$(jq -r --arg owners "$OWNERS" ' - ($owners | ascii_downcase | split(" ") | map(select(. != ""))) as $ours - | . as $l | $l.nodes[$l.root].inputs // {} | to_entries[] - | .key as $name | ($l.nodes[.value | if type == "string" then . else last end]) as $n - | select($n.original.type? == "github" and (($n.original.owner | ascii_downcase) as $o | $ours | index($o))) - | [$name, "\($n.original.owner)/\($n.original.repo)", - (if $n.original.rev then "PINNED" elif $n.original.ref then $n.original.ref else "HEAD" end), - $n.locked.rev] | @tsv' "$lock") - -stale=0 -while IFS=$'\t' read -r name repo ref rev; do - [ -n "$name" ] || continue - if [ "$ref" = PINNED ]; then - echo "::error::owned input $name ($repo) is pinned to a rev; track a ref instead" - stale=1 - continue - fi - owner=$(printf '%s' "${repo%%/*}" | tr '[:upper:]' '[:lower:]') - if [ "$owner" = dryvist ] && ! [[ $ref =~ ^v[0-9]+(\.[0-9]+\.[0-9]+)?$ ]]; then - case $ref in HEAD) named="no ref" ;; *) named="ref $ref" ;; esac - if [ "${FLAKE_REF_POLICY:-fail}" = fail ]; then - echo "::error::dryvist input $name ($repo) names $named; name a floating major tag (vN) or an exact release tag instead" - stale=1 - continue - fi - echo "::warning::dryvist input $name ($repo) names $named; name a floating major tag (vN) or an exact release tag instead" - fi - if [ -n "$base" ]; then - old=$([ ! -f "$base" ] || jq -r --arg n "$name" '. as $l | ($l.nodes[$l.root].inputs // {})[$n] // empty - | $l.nodes[if type == "string" then . else last end].locked.rev // empty' "$base") - if [ -z "$old" ] || [ "$old" = "$rev" ]; then - echo "$name: unchanged from base" - continue - fi - oldref=$(jq -r --arg n "$name" '. as $l | ($l.nodes[$l.root].inputs // {})[$n] // empty - | $l.nodes[if type == "string" then . else last end].original - | if .rev then "PINNED" elif .ref then .ref else "HEAD" end' "$base") - if [ "$oldref" != "$ref" ]; then - echo "$name: ref changed from $oldref to $ref; the new rev is not compared with the base rev" - continue - fi - # status is "ahead" when the new rev descends from the base rev. - status=$(github_compare "https://api.github.com/repos/$repo/compare/$old...$rev" | jq -r .status) || status=unknown - if [ "$status" = ahead ]; then - echo "$name: $old -> $rev moves forward" - else - echo "::error::$name ($repo) moves from $old to $rev, which does not descend from it ($status)" - stale=1 - fi - continue - fi - if [ "$max_age_days" -gt 0 ]; then - echo "$name: age check enabled; checked with root inputs below" - continue - fi - url="$remote_base/$repo.git" - if [ "$ref" = HEAD ]; then - head=$(remote_head "$url" HEAD) - else - head=$(remote_head "$url" "refs/tags/$ref") - [ -n "$head" ] || head=$(remote_head "$url" "refs/heads/$ref") - fi - if [ -z "$head" ]; then - echo "::error::$name: cannot resolve $ref of $repo" - stale=1 - elif [ "$head" = "$rev" ]; then - echo "$name: at head $head" - elif [[ $ref =~ ^v[0-9]+$ ]]; then - if reachable "$url" "$ref" "$rev"; then - echo "$name: $rev is in the history of $ref ($head)" - else - echo "::error::$name ($repo $ref) locked at $rev, which is not in the history of $ref at $head. Run: nix flake update $name" - [ -n "${STALE_NAMES_FILE:-}" ] && echo "$name" >>"$STALE_NAMES_FILE" - stale=1 - fi - else - echo "::error::$name ($repo $ref) locked at $rev, branch head is $head. Run: nix flake update $name" - [ -n "${STALE_NAMES_FILE:-}" ] && echo "$name" >>"$STALE_NAMES_FILE" - stale=1 - fi -done <<<"$inputs" - -if [ "$max_age_days" -gt 0 ]; then - now=$(date -u +%s) - cutoff=$((now - max_age_days * 86400)) - age_inputs=$(jq -r ' - . as $l | ($l.nodes[$l.root].inputs // {}) | to_entries[] - | .key as $name - | ($l.nodes[.value | if type == "string" then . else last end]) as $n - | select($n.locked.type? == "github") - | [$name, - "\($n.locked.owner // $n.original.owner // "")/\($n.locked.repo // $n.original.repo // "")", - ($n.original.ref // $n.locked.ref // "HEAD"), - ($n.locked.lastModified // ""), - ($n.locked.rev // "")] - | @tsv' "$lock") - - while IFS=$'\t' read -r name repo ref last_modified rev; do - [ -n "$name" ] || continue - if ! [[ $last_modified =~ ^[0-9]+$ ]] || [ -z "$rev" ] || [[ $repo != */* ]]; then - echo "::error::$name: cannot age-check its locked GitHub revision" - stale=1 - continue - fi - if [ "$last_modified" -ge "$cutoff" ]; then - echo "$name: locked revision is within $max_age_days days" - continue - fi - - url="$remote_base/$repo.git" - if [ "$ref" = HEAD ]; then - head=$(remote_head "$url" HEAD) - else - head=$(remote_head "$url" "refs/tags/$ref") - [ -n "$head" ] || head=$(remote_head "$url" "refs/heads/$ref") - fi - if [ -z "$head" ]; then - echo "::error::$name: cannot resolve $ref of $repo" - stale=1 - continue - fi - if [ "$head" = "$rev" ]; then - echo "$name: no newer commits on $ref" - continue - fi - - comparison=$(github_compare "https://api.github.com/repos/$repo/compare/$rev...$head") || comparison= - if [ -z "$comparison" ]; then - echo "::error::$name: cannot compare $rev with upstream $head" - stale=1 - continue - fi - compare_status=$(jq -r '.status // "unknown"' <<<"$comparison") - ahead_by=$(jq -r '.ahead_by // 0' <<<"$comparison") - if [[ $compare_status == ahead || $compare_status == diverged ]] && [ "$ahead_by" -gt 0 ]; then - age_days=$(((now - last_modified) / 86400)) - echo "::error::$name ($repo $ref) is $age_days days old with $ahead_by newer upstream commit(s); run: nix flake update $name" - [ -n "${STALE_NAMES_FILE:-}" ] && printf '%s\n' "$name" >>"$STALE_NAMES_FILE" - stale=1 - else - echo "$name: no newer commits on $ref (compare status: $compare_status)" - fi - done <<<"$age_inputs" -fi -exit "$stale" diff --git a/scripts/promote-major-release.sh b/scripts/promote-major-release.sh deleted file mode 100755 index cf0b7ce..0000000 --- a/scripts/promote-major-release.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# promote-major-release.sh [--no-canary] -# -# Moves the floating major tag for a published release tag. must be a -# stable vMAJOR.MINOR.PATCH tag, optionally followed by SemVer build metadata -# (+rev.1), already present in the current checkout; the commit it points at -# is handed to promote-major-tag.sh. -# -# A tag that is not vMAJOR.MINOR.PATCH[+metadata] is an error, except with --no-canary -# (a calling repo): that repo may publish component-prefixed tags, which have no -# floating major tag, so the script prints a notice and exits 0. -set -euo pipefail - -tag="${1:-}" -mode="${2:-}" -tag_re='^v[0-9]+\.[0-9]+\.[0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' - -if [[ ! "$tag" =~ $tag_re ]]; then - if [[ "$mode" == "--no-canary" ]]; then - echo "::notice::'$tag' is not a vMAJOR.MINOR.PATCH release tag; no floating major tag to move." - exit 0 - fi - echo "::error::'$tag' is not a release tag (vMAJOR.MINOR.PATCH)" - exit 1 -fi - -here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec "$here/promote-major-tag.sh" "$(git rev-parse "refs/tags/$tag^{commit}")" "$mode" diff --git a/scripts/promote-major-tag.sh b/scripts/promote-major-tag.sh deleted file mode 100755 index 67f7347..0000000 --- a/scripts/promote-major-tag.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# promote-major-tag.sh [--gate-verified | --no-canary] -# -# Moves the floating major tag (v1 for v1.x.y) to , the commit of the -# highest vX.Y.Z tag pointing at it, once the Canary check on succeeded. -# A tag may carry SemVer build metadata (vX.Y.Z+rev.1). Prerelease tags never -# match and never move the floating tag. -# -# Called by promote-major-tag.yml (release published, workflow_dispatch, and -# workflow_call from a dryvist repo's release caller) and by the `promote` job -# in canary.yml (push to main). Whichever arrives second promotes. Exit 0 with a -# notice means nothing to do yet: no release tag on the commit, or the Canary -# check is missing or still running. Exit 1 means the Canary concluded -# otherwise, or the move would go backwards. -# -# --gate-verified: the caller has already seen the Canary summary job succeed -# (canary.yml `needs`), so the check-run lookup is skipped. -# -# --no-canary: the repo has no Canary check, so the check-run lookup is skipped -# and the move needs only the release tag. -# -# Required env: GH_TOKEN (checks: read on the repo), GITHUB_REPOSITORY. - -set -euo pipefail - -sha="${1:?usage: promote-major-tag.sh [--gate-verified | --no-canary]}" -gate_verified=false -no_canary=false -case "${2:-}" in - --gate-verified) gate_verified=true ;; - --no-canary) no_canary=true ;; -esac - -tag_re='^v[0-9]+\.[0-9]+\.[0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' -tag="$(git tag --points-at "$sha" | grep -E "$tag_re" | sort -V | tail -n 1 || true)" -if [[ -z "$tag" ]]; then - echo "::notice::$sha has no vX.Y.Z tag; nothing to promote." - exit 0 -fi -major="${tag%%.*}" - -if [[ "$gate_verified" != true && "$no_canary" != true ]]; then - check="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=Canary" \ - --jq '.check_runs | sort_by(.started_at) | last // empty | "\(.status) \(.conclusion)"')" - if [[ -z "$check" ]]; then - echo "::notice::No Canary check-run on $sha yet; the Canary run on main promotes $tag once it finishes." - exit 0 - fi - read -r status conclusion <<<"$check" - if [[ "$status" != completed ]]; then - echo "::notice::Canary on $sha is $status; the Canary run on main promotes $tag once it finishes." - exit 0 - fi - if [[ "$conclusion" != success ]]; then - echo "::error::Canary on $sha concluded '$conclusion'; not promoting $tag." - exit 1 - fi -fi - -if current="$(git rev-parse -q --verify "refs/tags/$major^{commit}")"; then - if ! git merge-base --is-ancestor "$current" "$sha"; then - echo "::error::$major is $current, which is not an ancestor of $tag ($sha). Refusing to move it backwards." - exit 1 - fi -fi -git tag -f "$major" "$sha" -git push -f origin "refs/tags/$major" -echo "::notice::$major -> $sha ($tag)" diff --git a/scripts/release-tag-guard.sh b/scripts/release-tag-guard.sh deleted file mode 100755 index 7977b50..0000000 --- a/scripts/release-tag-guard.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "$GH_TOKEN" -: "$GH_REPO" - -prs=$(gh pr list --repo "$GH_REPO" --state merged --label "autorelease: pending" \ - --base develop --limit 1000 --json number,mergeCommit) -rows=$(jq -r '.[] | [.number, .mergeCommit.oid] | @tsv' <<< "$prs") -safe=true - -while IFS=$'\t' read -r number sha; do - [[ -n "$number" ]] || continue - status=$(gh api "repos/$GH_REPO/compare/$sha...main" --jq .status) - printf 'PR #%s sha=%s status=%s\n' "$number" "$sha" "$status" - case "$status" in - ahead|identical) ;; - *) safe=false ;; - esac -done <<< "$rows" - -printf 'safe=%s\n' "$safe" diff --git a/scripts/terrakube-gated-apply.sh b/scripts/terrakube-gated-apply.sh deleted file mode 100755 index 0bb3f72..0000000 --- a/scripts/terrakube-gated-apply.sh +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/env bash -# terrakube-gated-apply.sh — invoked by the `trigger` job in _converge.yml -# for target: terrakube-apply. -# -# Exchanges this job's own GitHub OIDC token for a Terrakube bearer token -# (Federated OIDC Authentication, org Security -> Federated Credentials — -# never OpenBao, which Terrakube has no integration to mint against), -# cancels any already-queued job for the workspace, plans from the working -# directory, creates a plan-and-apply job pinned to that plan's own upload, -# approves it only when the plan's own summary shows zero destroys, and -# waits for a terminal state. Terrakube has no native FIFO cancel for queued -# jobs (issues terrakube-io/terrakube#1957, #2850), so step 1 does it. -# -# Required env: -# TERRAKUBE_API_ADDR — e.g. https://terrakube-api.example.com -# TERRAKUBE_ORG_ID — organization UUID -# TERRAKUBE_WORKSPACE_ID — workspace UUID -# TERRAKUBE_TEMPLATE_ID — plan -> approval -> apply job template UUID -# INFRA_REPO — owner/repo of the infrastructure root, e.g. -# dryvist/tofu-proxmox -# ACTIONS_ID_TOKEN_REQUEST_TOKEN, -# ACTIONS_ID_TOKEN_REQUEST_URL — auto-injected by the runner when the job -# has `permissions: id-token: write`; no -# `env:` declaration needed in the caller -# WAIT_MINUTES — bounded poll budget (default 10) -# -# Run from a checkout of the desired-state repository (the one carrying -# deployment.json and .infra-ref at its root) — this script reads -# .infra-ref, clones that ref of INFRA_REPO into a scratch directory, -# copies deployment.json into it, and plans from there. -set -euo pipefail - -api() { - # $1 method, $2 path (from $TERRAKUBE_API_ADDR), rest curl args - local method=$1 path=$2 - shift 2 - curl -fsS --max-time 30 -X "$method" \ - -H "Authorization: Bearer ${TERRAKUBE_TOKEN}" \ - -H "Content-Type: application/vnd.api+json" \ - -H "Accept: application/vnd.api+json" \ - "${TERRAKUBE_API_ADDR%/}${path}" "$@" -} - -# Exchanges this job's own GitHub OIDC token (audience "terrakube", matching -# the claim condition on the org's Federated Credential) for a Terrakube -# bearer token. Sets the global TERRAKUBE_TOKEN and masks it — masking must -# happen on the script's own real stdout, so this is called directly -# (never through command substitution, which would swallow the directive -# into the captured value instead of reaching the runner's log parser). -get_oidc_token() { - local resp - resp=$(curl -fsS --max-time 30 -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=terrakube") - TERRAKUBE_TOKEN=$(printf '%s' "$resp" | jq -r '.value') - [[ -n $TERRAKUBE_TOKEN && $TERRAKUBE_TOKEN != null ]] || { - echo "::error::failed to obtain a GitHub OIDC token for Terrakube" - exit 1 - } - echo "::add-mask::${TERRAKUBE_TOKEN}" -} - -# Exports what `tofu`'s cloud-block CLI-driven workflow needs to run -# non-interactively, without a `tofu login`. tofu-proxmox's cloud block -# carries no hostname/organization/workspace by design (main.tf) — all -# three, plus the per-host credential, come from the environment: -# - TF_WORKSPACE must be set explicitly: the stored default is a -# DIFFERENT workspace and fails as a fake 403/401, not a wrong- -# workspace error. -# - TF_TOKEN_ follows Terraform's own convention: dots -> "_", -# hyphens -> "__". -configure_tofu_cli_auth() { - local org_name workspace_name tf_host tf_host_env - org_name=$(api GET "/api/v1/organization/${TERRAKUBE_ORG_ID}" | jq -r '.data.attributes.name') - workspace_name=$(api GET "/api/v1/organization/${TERRAKUBE_ORG_ID}/workspace/${TERRAKUBE_WORKSPACE_ID}" | jq -r '.data.attributes.name') - [[ -n $org_name && $org_name != null ]] || { echo "::error::could not resolve organization ${TERRAKUBE_ORG_ID}'s name"; exit 1; } - [[ -n $workspace_name && $workspace_name != null ]] || { echo "::error::could not resolve workspace ${TERRAKUBE_WORKSPACE_ID}'s name"; exit 1; } - - tf_host=$(printf '%s' "$TERRAKUBE_API_ADDR" | sed -E 's#^[a-zA-Z]+://##; s#/.*##') - tf_host_env=$(printf '%s' "$tf_host" | sed -E 's/\./_/g; s/-/__/g') - - export TF_CLOUD_HOSTNAME="$tf_host" - export TF_CLOUD_ORGANIZATION="$org_name" - export TF_WORKSPACE="$workspace_name" - export "TF_TOKEN_${tf_host_env}=${TERRAKUBE_TOKEN}" -} - -# Cancels any job still `pending` or `waitingApproval` for the workspace, so -# an older push never applies after a newer one supersedes it. -cancel_queued_jobs() { - local ids - ids=$(api GET "/api/v1/organization/${TERRAKUBE_ORG_ID}/workspace/${TERRAKUBE_WORKSPACE_ID}/job" | - jq -r '.data[] | select(.attributes.status == "pending" or .attributes.status == "waitingApproval") | .id') - local id - for id in $ids; do - echo "cancelling queued job ${id}" - api PATCH "/api/v1/organization/${TERRAKUBE_ORG_ID}/job/${id}" \ - -d '{"data":{"type":"job","attributes":{"status":"cancelled"}}}' >/dev/null - done -} - -# Creates the plan-and-apply job, pinned to $1 (a configuration-version -# terraformContent.tar.gz URL), using template $2. Prints the new job id. -create_job() { - local override_source=$1 template_id=$2 - api POST "/api/v1/organization/${TERRAKUBE_ORG_ID}/job" -d "$( - jq -n --arg t "$template_id" --arg src "$override_source" --arg ws "$TERRAKUBE_WORKSPACE_ID" ' - {data: {type: "job", attributes: { - templateReference: $t, - overrideBranch: "remote-content", - overrideSource: $src - }, relationships: {workspace: {data: {type: "workspace", id: $ws}}}}}' - )" | jq -r '.data.id' -} - -# Polls job $1 until its Plan step has finished — status "waitingApproval" -# — or the job reaches a terminal state on its own (a plan failure never -# reaches approval). Prints whichever status it settles on, or "timeout". -# Reading the Plan step's log before this returns is the race the log-read -# used to have: right after create_job, the plan has not necessarily run -# yet, so the log request could hit an empty or stale output. -wait_for_plan() { - local job_id=$1 elapsed=0 status - while (( elapsed < WAIT_SECONDS )); do - status=$(api GET "/api/v1/organization/${TERRAKUBE_ORG_ID}/job/${job_id}" | jq -r '.data.attributes.status') - case "$status" in - waitingApproval|completed|failed|cancelled|rejected|denied) echo "$status"; return ;; - esac - sleep 10 - elapsed=$((elapsed + 10)) - done - echo "timeout" -} - -# Reads job $1's Plan step output — call only once wait_for_plan reports -# waitingApproval, so the step has actually finished — and decides approve -# vs. park. Prints "approve" or "park: ". Strips ANSI colour -# codes before matching: the plan log is colourised. -gate_plan() { - local job_id=$1 - local plan_step_id output log summary destroy - # The job's `step` relationship lists step ids; the Plan step is - # stepNumber 100 (fixed by the plan-and-apply template's tcl). - plan_step_id=$(api GET "/api/v1/organization/${TERRAKUBE_ORG_ID}/job/${job_id}/step" | - jq -r '.data[] | select(.attributes.stepNumber == 100) | .id') - output=$(api GET "/api/v1/organization/${TERRAKUBE_ORG_ID}/job/${job_id}/step/${plan_step_id}" | jq -r '.data.attributes.output') - log=$(curl -fsS --max-time 30 -H "Authorization: Bearer ${TERRAKUBE_TOKEN}" "$output" | sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g') - summary=$(printf '%s\n' "$log" | grep -Eo 'Plan: [0-9]+ to add, [0-9]+ to change, [0-9]+ to destroy\.' | tail -1) - if [[ -z $summary ]]; then - # No changes at all reads as a clean, approvable no-op. - if printf '%s\n' "$log" | grep -q 'No changes'; then - echo "approve" - return - fi - echo "park: could not find a plan summary line" - return - fi - destroy=$(printf '%s\n' "$summary" | grep -Eo '[0-9]+ to destroy' | grep -Eo '^[0-9]+') - if [[ $destroy -eq 0 ]]; then - echo "approve" - else - echo "park: ${summary}" - fi -} - -# Clones INFRA_REPO@$1 (a release ref) into $2 (a fresh directory) and -# copies this checkout's deployment.json into its root. Asserts the copy -# matches the source byte-for-byte — that directory becomes the tarball -# `tofu plan` uploads, so this is checking the tarball's content, not a -# proxy for it. -assemble_tree() { - local infra_ref=$1 tree_dir=$2 - git clone --quiet --depth 1 --branch "$infra_ref" \ - "https://github.com/${INFRA_REPO}.git" "$tree_dir" - cp deployment.json "${tree_dir}/deployment.json" - cmp -s deployment.json "${tree_dir}/deployment.json" || { - echo "::error::deployment.json did not copy into the assembled tree unchanged" - exit 1 - } -} - -approve_job() { - api PATCH "/api/v1/organization/${TERRAKUBE_ORG_ID}/job/$1" \ - -d '{"data":{"type":"job","attributes":{"status":"approved"}}}' >/dev/null -} - -# Polls job $1's status until it is one of the terminal states or -# $WAIT_SECONDS elapses. Prints the final status. -wait_for_terminal() { - local job_id=$1 elapsed=0 status - while (( elapsed < WAIT_SECONDS )); do - status=$(api GET "/api/v1/organization/${TERRAKUBE_ORG_ID}/job/${job_id}" | jq -r '.data.attributes.status') - case "$status" in - completed|failed|cancelled|rejected|denied) echo "$status"; return ;; - esac - sleep 10 - elapsed=$((elapsed + 10)) - done - echo "timeout" -} - -main() { - : "${TERRAKUBE_API_ADDR:?required}" - : "${TERRAKUBE_ORG_ID:?required}" - : "${TERRAKUBE_WORKSPACE_ID:?required}" - : "${TERRAKUBE_TEMPLATE_ID:?required}" - : "${INFRA_REPO:?required}" - : "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:?required — the job needs permissions: id-token: write}" - : "${ACTIONS_ID_TOKEN_REQUEST_URL:?required — the job needs permissions: id-token: write}" - : "${WAIT_MINUTES:=10}" - WAIT_SECONDS=$((WAIT_MINUTES * 60)) - - [[ -f .infra-ref ]] || { echo "::error::.infra-ref not found in the desired-state repository root"; exit 1; } - local infra_ref - infra_ref=$(cat .infra-ref) - - get_oidc_token - configure_tofu_cli_auth - cancel_queued_jobs - - local tree_dir - tree_dir=$(mktemp -d) - assemble_tree "$infra_ref" "$tree_dir" - - # A plain `tofu plan` CLI run also creates a job, whose id is the run - # number printed in its own URL; that job's own overrideSource already - # carries the configuration-version tarball this script reuses below — - # confirmed live (run 1025 -> job 1026: POSTing a new job with that - # overrideSource worked). - local plan_run_output - plan_run_output=$(cd "$tree_dir" && tofu init -no-color && tofu plan -no-color 2>&1) || { echo "$plan_run_output"; exit 1; } - local plan_job_id - plan_job_id=$(printf '%s\n' "$plan_run_output" | grep -Eo '/runs/[A-Za-z0-9_-]+' | tail -1 | sed 's#/runs/##') - [[ -n $plan_job_id ]] || { echo "::error::could not parse a run id from tofu plan's output"; exit 1; } - local override_source - override_source=$(api GET "/api/v1/organization/${TERRAKUBE_ORG_ID}/job/${plan_job_id}" | jq -r '.data.attributes.overrideSource') - [[ -n $override_source && $override_source != null ]] || { - echo "::error::job ${plan_job_id} has no overrideSource" - exit 1 - } - - local job_id - job_id=$(create_job "$override_source" "$TERRAKUBE_TEMPLATE_ID") - echo "created job ${job_id}" - - local plan_status - plan_status=$(wait_for_plan "$job_id") - case "$plan_status" in - waitingApproval) : ;; - timeout) echo "::error::job ${job_id} did not reach waitingApproval within ${WAIT_MINUTES}m"; exit 1 ;; - *) echo "::error::job ${job_id} ended in ${plan_status} before reaching approval"; exit 1 ;; - esac - - local decision - decision=$(gate_plan "$job_id") - local confirmation_url="${TERRAKUBE_API_ADDR}/api/v1/organization/${TERRAKUBE_ORG_ID}/job/${job_id}" - if [[ $decision == approve ]]; then - approve_job "$job_id" - else - echo "confirmation_url=${confirmation_url}" >>"${GITHUB_OUTPUT:-/dev/null}" - # A parked job must not exit 0: nobody hears about a green run. The red - # run IS the notification; report-and-close still closes the window - # (its own `if:` runs on always()). - echo "::error::parked: ${decision#park: } — ${confirmation_url}" - exit 1 - fi - - local final_status - final_status=$(wait_for_terminal "$job_id") - echo "confirmation_url=${confirmation_url}" >>"${GITHUB_OUTPUT:-/dev/null}" - [[ $final_status == completed ]] || { echo "::error::job ${job_id} ended in ${final_status}"; exit 1; } - echo "job ${job_id} completed" -} - -# --self-check: exercises gate_plan's and wait_for_plan's logic against -# fixtures, without a runner or a real Terrakube. -if [[ ${1:-} == --self-check ]]; then - fail=0 - export TERRAKUBE_ORG_ID=org1 TERRAKUBE_TOKEN=fake-token - - check_gate() { - local _fixture_log=$1 want=$2 - curl() { printf '%s' "$_fixture_log"; } - api() { - case "$2" in - */step) echo '{"data":[{"id":"s1","attributes":{"stepNumber":100}}]}' ;; - *) echo '{"data":{"attributes":{"output":"http://fixture"}}}' ;; - esac - } - local got - got=$(gate_plan job1) - [[ $got == "$want"* ]] || { echo "FAIL: log=$_fixture_log -> got '$got', want prefix '$want'" >&2; fail=1; } - } - - check_gate $'Plan: 2 to add, 0 to change, 0 to destroy.' "approve" - check_gate $'Plan: 1 to add, 0 to change, 1 to destroy.' "park" - check_gate $'No changes. Your infrastructure matches the configuration.' "approve" - check_gate $'garbage, no plan summary here' "park" - # A colourised log (the real 1026 log was) must strip before matching. - check_gate $'\x1b[32mPlan: 2 to add, 0 to change, 0 to destroy.\x1b[0m' "approve" - check_gate $'\x1b[1;31mPlan: 0 to add, 0 to change, 1 to destroy.\x1b[0m' "park" - - check_wait_for_plan() { - local want=$1 - shift - local -a seq=("$@") - # A plain shell counter would not work here: wait_for_plan reads - # status via `status=$(api ... | jq ...)`, a command substitution that - # forks a subshell — any plain variable api() increments is a copy - # discarded when that subshell exits. A file survives the fork. - local counter_file - counter_file=$(mktemp) - echo 0 >"$counter_file" - api() { - local i s - i=$(<"$counter_file") - s=${seq[$i]} - echo $((i + 1)) >"$counter_file" - echo "{\"data\":{\"attributes\":{\"status\":\"$s\"}}}" - } - sleep() { :; } # no real waiting in a self-check - # Exactly enough budget for one api() call per seq entry: the last - # entry either returns a terminal status or the loop exhausts the - # budget right after consuming it (the "timeout" case). - WAIT_SECONDS=$((${#seq[@]} * 10)) - local got - got=$(wait_for_plan job1) - rm -f "$counter_file" - [[ $got == "$want" ]] || { echo "FAIL: seq=${seq[*]} -> got '$got', want '$want'" >&2; fail=1; } - } - - check_wait_for_plan waitingApproval pending waitingApproval - check_wait_for_plan failed pending failed - check_wait_for_plan timeout pending pending pending - - [[ $fail == 0 ]] && echo "self-check OK: 9 cases" - exit $fail -fi - -main "$@" diff --git a/tests/bot-sweep-policy.test.js b/tests/bot-sweep-policy.test.js deleted file mode 100644 index 5d56a50..0000000 --- a/tests/bot-sweep-policy.test.js +++ /dev/null @@ -1,99 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const test = require('node:test'); -const { spawnSync } = require('node:child_process'); -const path = require('node:path'); - -const script = path.join(__dirname, '..', 'scripts', 'bot-sweep-policy.sh'); -const projectionAuthor = 'app/openbao-service-broker'; - -function pr({ author = projectionAuthor, base = 'main', head = 'docs-projection', files = ['index.md'], additions = 10, deletions = 10 } = {}) { - return { - author: { login: author }, - baseRefName: base, - headRefName: head, - changedFiles: files.length, - additions, - deletions, - files: files.map((file) => ({ path: file })), - }; -} - -function run(input, repo = 'dryvist/docs') { - return spawnSync('bash', [script], { - input: typeof input === 'string' ? input : JSON.stringify(input), - encoding: 'utf8', - env: { - ...process.env, - REPO: repo, - SMALL_DIFF_MAX_FILES: '5', - SMALL_DIFF_MAX_LINES: '100', - SMALL_DIFF_PROJECTION_AUTHOR: projectionAuthor, - SMALL_DIFF_PROJECTION_REPO: 'dryvist/docs', - }, - }); -} - -function assertPolicy(input, expected, repo) { - const result = run(input, repo); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, `${expected}\n`); - assert.equal(result.stdout.trim().split('\n').length, 1); -} - -test('allows release-please PRs targeting develop through the remaining gates', () => { - assertPolicy(pr({ author: 'app/jacobpevans-release-please', base: 'develop', head: 'release-please--branches--develop' }), 'proceed'); -}); - -test('allows release-please PRs targeting main through the remaining gates', () => { - assertPolicy(pr({ author: 'app/jacobpevans-release-please', base: 'main', head: 'release-please--branches--main' }), 'proceed'); -}); - -test('allows Renovate PRs through the remaining gates', () => { - assertPolicy(pr({ author: 'app/renovate' }), 'proceed'); -}); - -test('allows a small projection PR in the configured repo', () => { - assertPolicy(pr(), 'proceed'); -}); - -test('allows exactly five files and 100 changed lines', () => { - assertPolicy(pr({ files: ['a.md', 'b.md', 'c.md', 'd.md', 'e.md'], additions: 50, deletions: 50 }), 'proceed'); -}); - -test('skips a projection PR with more than five files', () => { - assertPolicy(pr({ files: ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md'] }), 'skip:too-large'); -}); - -test('skips a projection PR with more than 100 changed lines', () => { - assertPolicy(pr({ additions: 51, deletions: 50 }), 'skip:too-large'); -}); - -test('skips workflow paths and secret, credential, auth, permission, and env paths', () => { - for (const file of [ - '.github/workflows/publish.yml', - 'config/SECRETS.yml', - 'config/credentials.txt', - 'service/auth/token.json', - 'service/permissions.yml', - 'src/.env.local', - 'src/tls.pem', - ]) { - assertPolicy(pr({ files: [file] }), 'skip:sensitive-path'); - } -}); - -test('treats an incomplete changed file list as unverifiable', () => { - assertPolicy({ ...pr(), changedFiles: 2 }, 'skip:size-unverifiable'); -}); - -test('skips the projection author in any other repo', () => { - assertPolicy(pr(), 'skip:author-not-allowed-here', 'dryvist/other'); -}); - -test('returns exit 2 for invalid PR JSON', () => { - const result = run('{not-json'); - assert.equal(result.status, 2); - assert.equal(result.stdout, ''); -}); diff --git a/tests/canary/ansible/site.yml b/tests/canary/ansible/site.yml deleted file mode 100644 index c20aa66..0000000 --- a/tests/canary/ansible/site.yml +++ /dev/null @@ -1,9 +0,0 @@ ---- -- name: Canary fixture for the ansible reusable - hosts: localhost - connection: local - gather_facts: false - tasks: - - name: Report the canary marker - ansible.builtin.debug: - msg: canary diff --git a/tests/canary/docs/canary.md b/tests/canary/docs/canary.md deleted file mode 100644 index 8324f86..0000000 --- a/tests/canary/docs/canary.md +++ /dev/null @@ -1,3 +0,0 @@ -# Canary docs fixture - -Linted by the docs stack of the caller-contract canary. Content is not used. diff --git a/tests/canary/nix/default.nix b/tests/canary/nix/default.nix deleted file mode 100644 index a7bd0aa..0000000 --- a/tests/canary/nix/default.nix +++ /dev/null @@ -1,3 +0,0 @@ -# Canary fixture: size-checked by the nix stack's file-size job. nix_validate -# runs `nix flake check` on the repo root flake, not on this file. -{ } diff --git a/tests/canary/python/pyproject.toml b/tests/canary/python/pyproject.toml deleted file mode 100644 index e6097d2..0000000 --- a/tests/canary/python/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[project] -name = "canary" -version = "0.0.0" -requires-python = ">=3.10" -dependencies = [] diff --git a/tests/canary/tofu/main.tf b/tests/canary/tofu/main.tf deleted file mode 100644 index fb9b981..0000000 --- a/tests/canary/tofu/main.tf +++ /dev/null @@ -1,8 +0,0 @@ -terraform { - required_version = ">= 1.6.0" -} - -output "canary" { - description = "Canary fixture output; read by nothing." - value = "ok" -} diff --git a/tests/cancel-on-close.test.js b/tests/cancel-on-close.test.js deleted file mode 100644 index 2403578..0000000 --- a/tests/cancel-on-close.test.js +++ /dev/null @@ -1,94 +0,0 @@ -'use strict'; - -// cancel-on-close.sh: cancel queued and in-progress runs, skip the current run, -// and treat a 404 or already-completed response as cancelled. A fake `gh` on PATH -// answers from fixtures, and the real `jq` applies the script's --jq filter, so -// the test needs no network. -const assert = require('node:assert/strict'); -const test = require('node:test'); -const { spawnSync } = require('node:child_process'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const script = path.join(__dirname, '..', 'scripts', 'cancel-on-close.sh'); - -const FAKE_GH = `#!/bin/sh -echo "$*" >> "$DIR/calls" -case "$1 $2" in - "run list") - [ -f "$DIR/list-fail" ] && { echo "list failed" >&2; exit 1; } - filter= - while [ $# -gt 0 ]; do [ "$1" = "--jq" ] && filter=$2; shift; done - jq -r "$filter" "$DIR/list.json" ;; - "run cancel") - mode=$(cat "$DIR/mode-$3" 2>/dev/null || echo ok) - case "$mode" in - ok) exit 0 ;; - 404) echo "HTTP 404: Not Found" >&2; exit 1 ;; - missing) echo "could not find any workflow run with ID $3" >&2; exit 1 ;; - completed) echo "HTTP 409: Cannot cancel a workflow run that is completed." >&2; exit 1 ;; - *) echo "HTTP 500: boom" >&2; exit 1 ;; - esac ;; -esac -`; - -function run({ runs, modes = {}, listFail = false, runId = '999' }) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-')); - fs.writeFileSync(path.join(dir, 'gh'), FAKE_GH, { mode: 0o755 }); - fs.writeFileSync(path.join(dir, 'list.json'), JSON.stringify(runs)); - for (const [id, mode] of Object.entries(modes)) fs.writeFileSync(path.join(dir, `mode-${id}`), mode); - if (listFail) fs.writeFileSync(path.join(dir, 'list-fail'), ''); - const r = spawnSync('bash', [script, 'feature/x'], { - env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, DIR: dir, REPO: 'dryvist/r', RUN_ID: runId, GH_TOKEN: 'test' }, - encoding: 'utf8', - }); - const callsFile = path.join(dir, 'calls'); - const calls = fs.existsSync(callsFile) ? fs.readFileSync(callsFile, 'utf8').split('\n') : []; - // "run cancel --repo ..." -> - const cancelled = calls.filter((l) => l.startsWith('run cancel')).map((l) => l.split(' ')[2]); - return { ...r, cancelled }; -} - -const run1 = (databaseId, status) => ({ databaseId, status }); - -test('cancels queued and in-progress runs and skips completed runs and the current run', () => { - const r = run({ - runs: [run1(1, 'queued'), run1(2, 'in_progress'), run1(3, 'completed'), run1(999, 'in_progress')], - runId: '999', - }); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.deepEqual(r.cancelled, ['1', '2']); -}); - -test('treats a 404, a missing run and an already-completed response as cancelled', () => { - const r = run({ - runs: [run1(1, 'queued'), run1(2, 'in_progress'), run1(3, 'queued')], - modes: { 1: '404', 2: 'completed', 3: 'missing' }, - }); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.deepEqual(r.cancelled, ['1', '2', '3']); - assert.match(r.stdout, /treated as cancelled/); -}); - -test('fails on any other cancel error and stops before the next run', () => { - const r = run({ - runs: [run1(1, 'queued'), run1(2, 'queued')], - modes: { 1: '500' }, - }); - assert.notEqual(r.status, 0); - assert.match(r.stderr, /HTTP 500/); - assert.deepEqual(r.cancelled, ['1']); -}); - -test('fails when the run list cannot be read', () => { - const r = run({ runs: [], listFail: true }); - assert.notEqual(r.status, 0); - assert.deepEqual(r.cancelled, []); -}); - -test('succeeds without cancelling anything when no run is queued or in progress', () => { - const r = run({ runs: [run1(1, 'completed')] }); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.deepEqual(r.cancelled, []); -}); diff --git a/tests/check-template-callers.test.js b/tests/check-template-callers.test.js deleted file mode 100644 index b9ca18e..0000000 --- a/tests/check-template-callers.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import test from 'node:test'; - -const repo = fileURLToPath(new URL('../', import.meta.url)); -const script = join(repo, 'scripts/check-template-callers.sh'); - -// A caller shaped like a template's ci-gate.yml: one job that calls _ci-gate.yml. -function caller({ withBlock = '', grant = ['contents: read', 'pull-requests: read', 'actions: write'] } = {}) { - const perms = grant.map((line) => ` ${line}`).join('\n'); - return `name: CI Gate -on: - pull_request: -jobs: - gate: - permissions: -${perms} - uses: dryvist/.github/.github/workflows/_ci-gate.yml@v1 -${withBlock}`; -} - -// Copies the script and the workflows into a throwaway root, applies an optional -// edit to the copied gate, writes the callers under callers/, and runs the checker. -function run({ gateEdit, callers }) { - const root = mkdtempSync(join(tmpdir(), 'template-callers-')); - try { - mkdirSync(join(root, 'scripts')); - cpSync(script, join(root, 'scripts/check-template-callers.sh')); - cpSync(join(repo, '.github'), join(root, '.github'), { recursive: true }); - const gate = join(root, '.github/workflows/_ci-gate.yml'); - if (gateEdit) writeFileSync(gate, gateEdit(readFileSync(gate, 'utf8'))); - mkdirSync(join(root, 'callers')); - const paths = Object.entries(callers).map(([name, text]) => { - const path = join(root, 'callers', `${name}.yml`); - writeFileSync(path, text); - return path; - }); - return spawnSync('bash', [join(root, 'scripts/check-template-callers.sh'), ...paths], { - cwd: root, - encoding: 'utf8', - }); - } finally { - rmSync(root, { recursive: true, force: true }); - } -} - -test('a caller that passes known inputs and grants the gate its scopes passes', () => { - const r = run({ callers: { 'python-template': caller({ withBlock: ' with:\n profile: python\n' }) } }); - assert.equal(r.status, 0, r.stdout + r.stderr); -}); - -test('a with key the gate does not declare fails, naming the template and key', () => { - const r = run({ callers: { 'cc-edge-pack-template': caller({ withBlock: ' with:\n bogus_key: true\n' }) } }); - assert.equal(r.status, 1); - assert.match(r.stdout, /cc-edge-pack-template: job 'gate' passes 'bogus_key'/); -}); - -test('a required gate input the caller omits fails, naming the template and input', () => { - const r = run({ - gateEdit: (text) => text.replace(/^ {4}inputs:\n/m, ' inputs:\n must_pass:\n type: boolean\n required: true\n'), - callers: { 'tofu-aws-templates': caller() }, - }); - assert.equal(r.status, 1); - assert.match(r.stdout, /tofu-aws-templates: job 'gate' omits required input 'must_pass'/); -}); - -test('a caller that grants less than the gate needs fails, naming the scope', () => { - const r = run({ callers: { 'python-template': caller({ grant: ['contents: read', 'pull-requests: read'] }) } }); - assert.equal(r.status, 1); - assert.match(r.stdout, /python-template: job 'gate' grants actions: none; _ci-gate.yml needs write/); -}); - -test('a caller with no job that calls _ci-gate.yml fails', () => { - const r = run({ - callers: { 'docs-template': 'name: docs\non: push\njobs:\n build:\n runs-on: ubuntu-24.04\n steps:\n - run: true\n' }, - }); - assert.equal(r.status, 1); - assert.match(r.stdout, /docs-template: no job calls _ci-gate.yml/); -}); diff --git a/tests/ci-gate-aggregate.test.mts b/tests/ci-gate-aggregate.test.mts deleted file mode 100644 index 59f873f..0000000 --- a/tests/ci-gate-aggregate.test.mts +++ /dev/null @@ -1,80 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { test } from 'node:test'; - -const script = fileURLToPath(new URL('../scripts/ci-gate-aggregate.mts', import.meta.url)); - -function runAggregator(jobs: object, allowedSkips = '', allowedFailures = '') { - return spawnSync(process.execPath, [script], { - encoding: 'utf8', - env: { - ...process.env, - CI_GATE_JOBS: JSON.stringify(jobs), - CI_GATE_ALLOWED_SKIPS: allowedSkips, - CI_GATE_ALLOWED_FAILURES: allowedFailures, - }, - }); -} - -test('unknown results fail with the job name and result', () => { - const run = runAggregator({ detect_changes: { result: 'abandoned' } }); - - assert.equal(run.status, 1); - assert.equal(run.stderr.trim(), 'Merge Gate failed: job "detect_changes" returned unknown result "abandoned".'); -}); - -test('cancelled jobs remain named failures', () => { - const run = runAggregator({ changes: { result: 'cancelled' } }); - - assert.equal(run.status, 1); - assert.equal(run.stderr.trim(), 'Merge Gate failed: job "changes" returned cancelled.'); -}); - -test('a cancelled watchdog remains a failure even though watchdog skips are allowed', () => { - const run = runAggregator({ watchdog: { result: 'cancelled' } }, 'watchdog'); - - assert.equal(run.status, 1); - assert.equal(run.stderr.trim(), 'Merge Gate failed: job "watchdog" returned cancelled.'); -}); - -test('successes and explicitly allowed skips and failures pass', () => { - const run = runAggregator( - { - changes: { result: 'success' }, - lint: { result: 'skipped' }, - scope: { result: 'failure' }, - }, - 'lint', - 'scope', - ); - - assert.equal(run.status, 0); - assert.match(run.stdout, /Merge Gate passed/); -}); - -test('focused PR passes when ci succeeds and Molecule has no selected scenarios', () => { - const run = runAggregator( - { - ci: { result: 'success' }, - molecule: { result: 'skipped' }, - }, - 'molecule', - ); - - assert.equal(run.status, 0); - assert.match(run.stdout, /Merge Gate passed: 2 jobs checked\./); -}); - -test('focused PR still fails if ci fails while Molecule has no selected scenarios', () => { - const run = runAggregator( - { - ci: { result: 'failure' }, - molecule: { result: 'skipped' }, - }, - 'molecule', - ); - - assert.equal(run.status, 1); - assert.equal(run.stderr.trim(), 'Merge Gate failed: job "ci" returned failure.'); -}); diff --git a/tests/ci-gate-nix-build-opt-in.test.js b/tests/ci-gate-nix-build-opt-in.test.js deleted file mode 100644 index 5d8bd22..0000000 --- a/tests/ci-gate-nix-build-opt-in.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const workflow = readFileSync( - new URL('../.github/workflows/_ci-gate.yml', import.meta.url), - 'utf8', -); - -const nixBuildJob = workflow.slice( - workflow.indexOf('\n nix-build:\n'), - workflow.indexOf('\n markdown-lint:\n'), -); - -test('nix-build is gated on the nix_build toggle alone, not on profile nix', () => { - const ifLine = nixBuildJob.match(/^\s+if: \$\{\{ (.*) \}\}$/m); - assert.ok(ifLine, 'nix-build job has an if: expression'); - assert.match(ifLine[1], /inputs\.nix_build\b/); - assert.doesNotMatch(ifLine[1], /inputs\.profile == 'nix'/); -}); - -test('nix_build_command is an empty-by-default string input forwarded as build-command', () => { - const input = workflow.slice( - workflow.indexOf('\n nix_build_command:\n'), - workflow.indexOf('\n nix_skip_deps_only:\n'), - ); - assert.match(input, /type: string/); - assert.match(input, /default: ''/); - assert.match(nixBuildJob, /with:\n\s+build-command: \$\{\{ inputs\.nix_build_command \}\}/); -}); diff --git a/tests/ci-gate-path-filter-base.test.js b/tests/ci-gate-path-filter-base.test.js deleted file mode 100644 index eb2f8ec..0000000 --- a/tests/ci-gate-path-filter-base.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const workflow = readFileSync( - new URL('../.github/workflows/_ci-gate.yml', import.meta.url), - 'utf8', -); - -test('both shared path filters compare pushes against the pushed branch, and PRs against their base', () => { - const filters = workflow - .split('\n - uses: dorny/paths-filter@') - .slice(1) - .map((step) => step.split('\n - uses:')[0]); - assert.equal(filters.length, 2); - for (const filter of filters) { - assert.match(filter, /^\s+base: \$\{\{ github\.event_name == 'push' && github\.ref \|\| '' \}\}$/m); - } -}); diff --git a/tests/ci-gate-unmapped.test.js b/tests/ci-gate-unmapped.test.js deleted file mode 100644 index 61cbbbf..0000000 --- a/tests/ci-gate-unmapped.test.js +++ /dev/null @@ -1,71 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import test from 'node:test'; - -const script = fileURLToPath(new URL('../scripts/ci-gate-unmapped.sh', import.meta.url)); - -function run(env) { - const dir = mkdtempSync(join(tmpdir(), 'ci-gate-unmapped-')); - const output = join(dir, 'github-output'); - try { - const result = spawnSync('bash', [script], { - encoding: 'utf8', - env: { PATH: process.env.PATH, GITHUB_OUTPUT: output, ...env }, - }); - const written = (() => { - try { - return readFileSync(output, 'utf8'); - } catch { - return ''; - } - })(); - return { ...result, written }; - } finally { - rmSync(dir, { recursive: true, force: true }); - } -} - -test('a path no filter covers warns and sets full=true without failing', () => { - const run1 = run({ - ALL: '["flake.lock","svc/app.yml","README.md"]', - NIX: '["flake.lock"]', - MARKDOWN: '["README.md"]', - }); - - assert.equal(run1.status, 0); - assert.match(run1.stdout, /^::warning::unmapped path svc\/app\.yml, running full profile$/m); - assert.equal(run1.written.trim(), 'full=true'); -}); - -test('docs and mapped paths produce no warning and full=false', () => { - const run1 = run({ - ALL: '["nix/a.nix","NOTES.md"]', - NIX: '["nix/a.nix"]', - MARKDOWN: '[]', - }); - - assert.equal(run1.status, 0); - assert.doesNotMatch(run1.stdout, /::warning::/); - assert.equal(run1.written.trim(), 'full=false'); -}); - -test('release-please metadata produces no warning and full=false', () => { - const run1 = run({ - ALL: '["VERSION",".release-please-manifest.json","release-please-config.json","CHANGELOG.md"]', - }); - - assert.equal(run1.status, 0); - assert.doesNotMatch(run1.stdout, /::warning::/); - assert.equal(run1.written.trim(), 'full=false'); -}); - -test('no changed files at all is not an error', () => { - const run1 = run({}); - - assert.equal(run1.status, 0); - assert.equal(run1.written.trim(), 'full=false'); -}); diff --git a/tests/ci-gate-uses-resolve.test.js b/tests/ci-gate-uses-resolve.test.js deleted file mode 100644 index 67d6c1d..0000000 --- a/tests/ci-gate-uses-resolve.test.js +++ /dev/null @@ -1,215 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import test from 'node:test'; - -const script = fileURLToPath(new URL('../scripts/ci-gate-uses-resolve.sh', import.meta.url)); -const clean = 'jobs:\n a:\n runs-on: ubuntu-24.04\n'; - -// Builds a throwaway caller repo, runs the checker inside it, and removes it. -function runInCaller(callerYaml, extraFiles = {}, env = {}) { - const root = mkdtempSync(join(tmpdir(), 'ci-gate-uses-')); - try { - mkdirSync(join(root, '.github', 'workflows'), { recursive: true }); - writeFileSync(join(root, '.github', 'workflows', 'caller.yml'), callerYaml); - for (const [path, body] of Object.entries(extraFiles)) { - mkdirSync(dirname(join(root, path)), { recursive: true }); - writeFileSync(join(root, path), body); - } - return spawnSync('bash', [script], { - cwd: root, - encoding: 'utf8', - env: { PATH: process.env.PATH, ...env }, - }); - } finally { - rmSync(root, { recursive: true, force: true }); - } -} - -test('a local reusable reference that exists passes', () => { - const run = runInCaller( - 'jobs:\n gate:\n uses: ./.github/workflows/real.yml\n', - { '.github/workflows/real.yml': 'on: workflow_call\n' }, - ); - - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.match(run.stdout, /all reusable workflow references resolve/); -}); - -test('a missing local reusable reference fails with the caller and target named', () => { - const run = runInCaller('jobs:\n gate:\n uses: ./.github/workflows/missing.yml\n'); - - assert.equal(run.status, 1); - assert.match( - run.stdout, - /^::error file=\.github\/workflows\/caller\.yml::\.github\/workflows\/caller\.yml calls \.\/\.github\/workflows\/missing\.yml, which does not resolve/m, - ); -}); - -test('third-party action references are ignored', () => { - const run = runInCaller( - 'jobs:\n a:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n', - ); - - assert.equal(run.status, 0, run.stdout + run.stderr); -}); - -test('a dryvist ref outside the public shared repos is not checked, only noticed', () => { - // No GH_TOKEN is set, so reaching `gh api` would fail the run. A zero exit - // with the notice proves the reference was skipped, not looked up. - const ref = 'dryvist/private-thing/.github/workflows/x.yml@main'; - const run = runInCaller(`jobs:\n a:\n uses: ${ref}\n`, {}, { PIN_POLICY_MODE: 'warn' }); - - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.match(run.stdout, new RegExp(`::notice::not checked \\(${ref.replace(/[./]/g, '\\$&')}\\)`)); - assert.doesNotMatch(run.stdout, /::error/); -}); - -test('uses: refs are not pin-checked here; pr-policy owns that rule', () => { - const run = runInCaller('jobs:\n a:\n uses: dryvist/tool/action@main\n', {}, { - PIN_POLICY_MODE: 'fail', - }); - - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.doesNotMatch(run.stdout, /::(warning|error)/); -}); - -test('pin policy: a flake input without ?ref=v is a warning in warn mode', () => { - const run = runInCaller(clean, { 'flake.nix': '{\n inputs.tool.url = "github:dryvist/nix-tool";\n}\n' }, { - PIN_POLICY_MODE: 'warn', - }); - - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.match(run.stdout, /^::warning file=flake\.nix,line=2::dryvist flake input has no \?ref=v/m); -}); - -test('pin policy: legacy JacobPEvans owner and git+https dryvist flake inputs are checked too', () => { - const run = runInCaller(clean, { - 'flake.nix': [ - '{', - ' inputs.a.url = "github:JacobPEvans/nix-devenv";', - ' inputs.b.url = "git+https://github.com/dryvist/nix-devenv.git?ref=develop";', - ' inputs.c.url = "github:JacobPEvans/nix-devenv?ref=v1";', - '}', - '', - ].join('\n'), - }, { PIN_POLICY_MODE: 'warn' }); - - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.match(run.stdout, /^::warning file=flake\.nix,line=2::dryvist flake input has no \?ref=v/m); - assert.match(run.stdout, /^::warning file=flake\.nix,line=3::dryvist flake input has no \?ref=v/m); - assert.doesNotMatch(run.stdout, /line=4::/); -}); - -test('pin policy: a flake input without ?ref=v is an error and exits 1 in fail mode', () => { - const run = runInCaller(clean, { 'flake.nix': '{\n inputs.tool.url = "github:dryvist/nix-tool";\n}\n' }, { - PIN_POLICY_MODE: 'fail', - }); - - assert.equal(run.status, 1, run.stdout + run.stderr); - assert.match(run.stdout, /^::error file=flake\.nix,line=2::dryvist flake input has no \?ref=v/m); -}); - -test('pin policy: a flake input with a floating major or an exact release tag passes, a branch does not', () => { - for (const ref of ['v1', 'v1.9.10']) { - const pinned = runInCaller(clean, { 'flake.nix': `inputs.tool.url = "github:dryvist/nix-tool?ref=${ref}";\n` }, { - PIN_POLICY_MODE: 'fail', - }); - assert.equal(pinned.status, 0, ref + pinned.stdout + pinned.stderr); - assert.doesNotMatch(pinned.stdout, /::(warning|error)/); - } - - for (const ref of ['main', 'develop', 'v1.9', 'v1.9.10-rc1']) { - const branch = runInCaller(clean, { 'flake.nix': `inputs.tool.url = "github:dryvist/nix-tool?ref=${ref}";\n` }, { - PIN_POLICY_MODE: 'fail', - }); - assert.equal(branch.status, 1, ref + branch.stdout + branch.stderr); - } -}); - -test('pin policy: a remote dryvist flake in .envrc is a warning in warn mode', () => { - const run = runInCaller(clean, { '.envrc': 'use flake github:dryvist/nix-tool\n' }, { PIN_POLICY_MODE: 'warn' }); - - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.match(run.stdout, /^::warning file=\.envrc,line=1::use flake loads a remote dryvist flake/m); -}); - -test('pin policy: a remote dryvist flake in .envrc is an error and exits 1 in fail mode', () => { - const run = runInCaller(clean, { '.envrc': 'use flake "git+https://github.com/dryvist/nix-tool"\n' }, { - PIN_POLICY_MODE: 'fail', - }); - - assert.equal(run.status, 1, run.stdout + run.stderr); - assert.match(run.stdout, /^::error file=\.envrc,line=1::use flake loads a remote dryvist flake/m); -}); - -test('pin policy: a local .envrc flake passes in fail mode', () => { - const run = runInCaller(clean, { '.envrc': 'use flake .\n' }, { PIN_POLICY_MODE: 'fail' }); - - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.doesNotMatch(run.stdout, /::(warning|error)/); -}); - -test('pin policy: a dryvist git source on main is a warning in warn mode', () => { - const requirements = [ - 'collections:', - ' - name: git+https://github.com/dryvist/ansible-tool.git', - ' type: git', - ' version: main', - '', - ].join('\n'); - const run = runInCaller(clean, { 'requirements.yml': requirements }, { PIN_POLICY_MODE: 'warn' }); - - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.match( - run.stdout, - /^::warning file=requirements\.yml,line=4::dryvist git source pinned to branch main; pin a release version/m, - ); -}); - -test('pin policy: a dryvist git source on develop or HEAD is an error in fail mode', () => { - const requirements = [ - 'roles:', - ' - src: git+https://github.com/dryvist/ansible-tool.git', - ' scm: git', - ' version: develop', - ' - git+https://github.com/dryvist/other-tool.git,HEAD', - '', - ].join('\n'); - const run = runInCaller(clean, { 'requirements.yml': requirements }, { PIN_POLICY_MODE: 'fail' }); - - assert.equal(run.status, 1, run.stdout + run.stderr); - assert.match( - run.stdout, - /^::error file=requirements\.yml,line=4::dryvist git source pinned to branch develop/m, - ); - assert.match( - run.stdout, - /^::error file=requirements\.yml,line=5::dryvist git source pinned to branch head/m, - ); -}); - -test('pin policy: a release version or a non-dryvist source on a branch passes in fail mode', () => { - const requirements = [ - 'collections:', - ' - name: git+https://github.com/dryvist/ansible-tool.git', - ' version: v1.2.0', - ' - name: git+https://github.com/other/tool.git', - ' version: main', - '', - ].join('\n'); - const run = runInCaller(clean, { 'requirements.yml': requirements }, { PIN_POLICY_MODE: 'fail' }); - - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.doesNotMatch(run.stdout, /::(warning|error)/); -}); - -test('pin policy: an unknown PIN_POLICY_MODE is rejected with exit 2', () => { - const run = runInCaller(clean, {}, { PIN_POLICY_MODE: 'strict' }); - - assert.equal(run.status, 2, run.stdout + run.stderr); - assert.match(run.stdout, /PIN_POLICY_MODE must be warn or fail/); -}); diff --git a/tests/ci-gate-watchdog.test.js b/tests/ci-gate-watchdog.test.js deleted file mode 100644 index e88ca79..0000000 --- a/tests/ci-gate-watchdog.test.js +++ /dev/null @@ -1,155 +0,0 @@ -const assert = require('node:assert/strict'); -const { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { spawnSync } = require('node:child_process'); -const { test } = require('node:test'); - -const script = path.join(__dirname, '../scripts/ci-gate-watchdog.sh'); -const cancelCall = 'api -X POST repos/example/repo/actions/runs/123/cancel\n'; - -// created_at as the Actions API formats it: UTC, whole seconds. -const createdAgo = (seconds) => new Date(Date.now() - seconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); -const job = (id, name, status, ageSeconds = 0) => ({ id, name, status, created_at: createdAgo(ageSeconds) }); - -// Runs the watchdog against a fake gh. Each entry of `polls` is the jobs listing -// returned by one poll; the last listing repeats if the script polls past it. -// The fake gh applies the --jq filter with the real jq, and sleep is stubbed so -// polls run back to back. -function runWatchdog(t, polls) { - const dir = mkdtempSync(path.join(os.tmpdir(), 'ci-gate-watchdog-')); - t.after(() => rmSync(dir, { recursive: true, force: true })); - const calls = path.join(dir, 'calls'); - polls.forEach((jobs, i) => writeFileSync(path.join(dir, `poll-${i + 1}.json`), JSON.stringify({ jobs }))); - writeFileSync(path.join(dir, 'polls'), String(polls.length)); - - const gh = path.join(dir, 'gh'); - writeFileSync( - gh, - `#!/bin/sh -dir=$(dirname "$0") -case "$*" in - *"repos/example/repo/actions/runs/123/jobs?per_page=100"*) - n=$(cat "$dir/seen" 2>/dev/null || echo 0) - n=$((n + 1)) - echo "$n" > "$dir/seen" - last=$(cat "$dir/polls") - [ "$n" -le "$last" ] || n=$last - expr="" - prev="" - for arg in "$@"; do - [ "$prev" = "--jq" ] && expr="$arg" - prev="$arg" - done - jq -r "$expr" "$dir/poll-$n.json" - ;; - *"repos/example/repo/actions/runs/123/cancel"*) - printf '%s\\n' "$*" >> "$CALLS" - ;; - *) - exit 1 - ;; -esac -`, - ); - chmodSync(gh, 0o755); - const sleep = path.join(dir, 'sleep'); - writeFileSync(sleep, '#!/bin/sh\nexit 0\n'); - chmodSync(sleep, 0o755); - - const run = spawnSync('bash', [script], { - encoding: 'utf8', - timeout: 30_000, - env: { - ...process.env, - CALLS: calls, - GH_TOKEN: 'test', - PATH: `${dir}:${process.env.PATH}`, - QUEUE_TIMEOUT_MINUTES: '10', - REPO: 'example/repo', - RUN_ID: '123', - }, - }); - return { run, cancelled: existsSync(calls) ? readFileSync(calls, 'utf8') : '' }; -} - -for (const status of ['queued', 'pending', 'requested']) { - test(`a ${status} job older than the limit cancels the run`, (t) => { - const { run, cancelled } = runWatchdog(t, [ - [job(456, 'shared-gate / Nix Validate / Validate', status, 3600)], - ]); - - assert.equal(run.status, 0, run.stderr); - assert.match(run.stdout, /Timeout \(10m\) reached/); - assert.match(run.stdout, /Stuck job: shared-gate \/ Nix Validate \/ Validate \(\w+, \d+s since created\)/); - assert.match(run.stdout, /Cancelling workflow run 123/); - assert.equal(cancelled, cancelCall); - }); -} - -test('a queued job younger than the limit that then completes does not cancel', (t) => { - const { run, cancelled } = runWatchdog(t, [ - [job(456, 'shared-gate / Nix Validate / Validate', 'queued', 60)], - [job(456, 'shared-gate / Nix Validate / Validate', 'completed', 90)], - ]); - - assert.equal(run.status, 0, run.stderr); - assert.match(run.stdout, /All sibling jobs completed/); - assert.equal(cancelled, ''); -}); - -test('a job that appears on a later poll is watched and cancels the run past the limit', (t) => { - const { run, cancelled } = runWatchdog(t, [ - [job(1, 'shared-gate / Molecule (a)', 'in_progress', 120)], - [job(1, 'shared-gate / Molecule (a)', 'completed', 120), job(2, 'shared-gate / Molecule (b)', 'queued', 3600)], - ]); - - assert.equal(run.status, 0, run.stderr); - assert.match(run.stdout, /Stuck job: shared-gate \/ Molecule \(b\) \(queued, \d+s since created\)/); - assert.equal(cancelled, cancelCall); -}); - -test('waiting (environment approval) and in_progress jobs past the limit are never cancelled', (t) => { - const { run, cancelled } = runWatchdog(t, [ - [job(7, 'deploy', 'waiting', 3600), job(10, 'build', 'in_progress', 3600)], - [job(7, 'deploy', 'completed', 3600), job(10, 'build', 'completed', 3600)], - ]); - - assert.equal(run.status, 0, run.stderr); - assert.match(run.stdout, /All sibling jobs completed/); - assert.equal(cancelled, ''); -}); - -test('the exempt Queue Watchdog and Merge Gate jobs are ignored', (t) => { - const { run, cancelled } = runWatchdog(t, [ - [job(8, 'Queue Watchdog', 'in_progress'), job(9, 'Merge Gate', 'queued', 3600)], - ]); - - assert.equal(run.status, 0, run.stderr); - assert.match(run.stdout, /All sibling jobs completed/); - assert.equal(cancelled, ''); -}); - -test('exempt names match after a called-workflow prefix, and only as the whole last segment', (t) => { - const { run, cancelled } = runWatchdog(t, [ - [ - job(8, 'nix / Queue Watchdog', 'in_progress', 3600), - job(9, 'ansible / Merge Gate', 'pending', 3600), - job(10, 'gate / AI Merge Gate', 'pending', 3600), - ], - ]); - - assert.equal(run.status, 0, run.stderr); - assert.match(run.stdout, /gate \/ AI Merge Gate/); - assert.notEqual(cancelled, ''); -}); - -test('all siblings completed exits 0 without cancelling', (t) => { - const { run, cancelled } = runWatchdog(t, [ - [job(1, 'lint', 'completed', 600), job(2, 'test', 'completed', 600)], - ]); - - assert.equal(run.status, 0, run.stderr); - assert.match(run.stdout, /All sibling jobs completed/); - assert.equal(cancelled, ''); -}); diff --git a/tests/docs-publisher-provenance.test.js b/tests/docs-publisher-provenance.test.js deleted file mode 100644 index b6df1e7..0000000 --- a/tests/docs-publisher-provenance.test.js +++ /dev/null @@ -1,121 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const test = require('node:test'); - -const run = require('../.github/scripts/docs-publisher-provenance.js'); -const { isGeneratedPath, sourceLine } = run; - -test('separates generated output from repository control plane', () => { - assert.equal(isGeneratedPath('docs.json'), true); - assert.equal(isGeneratedPath('guides/example.mdx'), true); - assert.equal(isGeneratedPath('images/example.svg'), true); - assert.equal(isGeneratedPath('.github/workflows/ci.yml'), false); - assert.equal(isGeneratedPath('.claude/skills/example/SKILL.md'), false); - assert.equal(isGeneratedPath('AGENTS.md'), false); - assert.equal(isGeneratedPath('scripts/validate-mermaid.sh'), false); - assert.equal(isGeneratedPath('renovate.json'), false); -}); - -test('extracts publisher source metadata without logging the body', () => { - assert.equal( - sourceLine('Summary\n\nDocs-Publisher-Source: 0123456789abcdef0123456789abcdef01234567\n'), - 'Docs-Publisher-Source: 0123456789abcdef0123456789abcdef01234567', - ); - assert.equal(sourceLine('Summary only'), undefined); -}); - -function harness({ files, author = 'publisher[bot]', branch = 'chore/publisher/update', body = '' }) { - const failures = []; - const infos = []; - return { - failures, - infos, - github: { - paginate: async () => files.map((filename) => ({ filename })), - rest: { pulls: { listFiles: {} } }, - }, - context: { - repo: { owner: 'example', repo: 'docs' }, - payload: { - pull_request: { - number: 7, - body, - head: { ref: branch }, - user: { login: author }, - }, - }, - }, - core: { - info: (message) => infos.push(message), - setFailed: (message) => failures.push(message), - }, - }; -} - -test('allows control-plane changes without publisher identity', async () => { - const state = harness({ - author: 'contributor', - branch: 'feature/ci', - files: ['.github/workflows/x.yml', '.claude/settings.json', 'scripts/build.ts', 'renovate.json', 'AGENTS.md'], - }); - await run(state); - assert.deepEqual(state.failures, []); -}); - -test('rejects generated paths without the configured publisher', async () => { - const previousLogin = process.env.DOCS_PUBLISHER_LOGIN; - delete process.env.DOCS_PUBLISHER_LOGIN; - const state = harness({ files: ['docs.json'] }); - await run(state); - assert.equal(state.failures.length, 1); - if (previousLogin === undefined) delete process.env.DOCS_PUBLISHER_LOGIN; - else process.env.DOCS_PUBLISHER_LOGIN = previousLogin; -}); - -test('accepts a pinned generated projection from the publisher', async () => { - const previousLogin = process.env.DOCS_PUBLISHER_LOGIN; - process.env.DOCS_PUBLISHER_LOGIN = 'publisher[bot]'; - const state = harness({ - files: ['docs.json', 'guides/example.mdx'], - body: 'Docs-Publisher-Source: 0123456789abcdef0123456789abcdef01234567', - }); - await run(state); - assert.deepEqual(state.failures, []); - if (previousLogin === undefined) delete process.env.DOCS_PUBLISHER_LOGIN; - else process.env.DOCS_PUBLISHER_LOGIN = previousLogin; -}); - -test('rejects publisher-written paths without its branch or source trailer', async () => { - const previousLogin = process.env.DOCS_PUBLISHER_LOGIN; - process.env.DOCS_PUBLISHER_LOGIN = 'publisher[bot]'; - try { - const wrongBranch = harness({ - files: ['docs.json'], - branch: 'feature/docs', - body: 'Docs-Publisher-Source: 0123456789abcdef0123456789abcdef01234567', - }); - await run(wrongBranch); - assert.equal(wrongBranch.failures.length, 1); - - const missingTrailer = harness({ files: ['guides/example.mdx'] }); - await run(missingTrailer); - assert.equal(missingTrailer.failures.length, 1); - } finally { - if (previousLogin === undefined) delete process.env.DOCS_PUBLISHER_LOGIN; - else process.env.DOCS_PUBLISHER_LOGIN = previousLogin; - } -}); - -test('rejects a source trailer that names a repository', async () => { - const previousLogin = process.env.DOCS_PUBLISHER_LOGIN; - process.env.DOCS_PUBLISHER_LOGIN = 'publisher[bot]'; - const state = harness({ - files: ['docs.json'], - body: 'Docs-Publisher-Source: owner/source@0123456789abcdef0123456789abcdef01234567', - }); - await run(state); - assert.equal(state.failures.length, 1); - if (previousLogin === undefined) delete process.env.DOCS_PUBLISHER_LOGIN; - else process.env.DOCS_PUBLISHER_LOGIN = previousLogin; -}); diff --git a/tests/flake-lock-fresh-tags.test.js b/tests/flake-lock-fresh-tags.test.js deleted file mode 100644 index 35298d4..0000000 --- a/tests/flake-lock-fresh-tags.test.js +++ /dev/null @@ -1,181 +0,0 @@ -'use strict'; - -// flake-lock-fresh.sh against local bare repositories (FLAKE_LOCK_REMOTE_BASE=file://...), so no network. -// dryvist/r: main holds c1 then c2; the annotated tag v1 was moved from c1 to c2; side holds s1, off c1. -// dryvist/shadow: the same tags, plus a branch named v1 at s1. -const assert = require('node:assert/strict'); -const { after, before, test } = require('node:test'); -const { spawnSync } = require('node:child_process'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { pathToFileURL } = require('node:url'); - -const script = path.join(__dirname, '..', 'scripts', 'flake-lock-fresh.sh'); -const commits = {}; -let root; -let remote; - -function git(cwd, ...args) { - const r = spawnSync( - 'git', - ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', - '-c', 'tag.gpgsign=false', ...args], - { cwd, encoding: 'utf8', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } }, - ); - assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); - return r.stdout.trim(); -} - -before(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'flf-tags-')); - const work = path.join(root, 'work'); - fs.mkdirSync(work); - git(work, 'init', '-q', '-b', 'main'); - git(work, 'commit', '-q', '--allow-empty', '-m', 'c1'); - commits.c1 = git(work, 'rev-parse', 'HEAD'); - git(work, 'tag', '-a', '-m', 'v1', 'v1', commits.c1); - git(work, 'commit', '-q', '--allow-empty', '-m', 'c2'); - commits.c2 = git(work, 'rev-parse', 'HEAD'); - git(work, 'tag', '-f', '-a', '-m', 'v1', 'v1', commits.c2); - git(work, 'checkout', '-q', '-b', 'side', commits.c1); - git(work, 'commit', '-q', '--allow-empty', '-m', 's1'); - commits.s1 = git(work, 'rev-parse', 'HEAD'); - git(work, 'checkout', '-q', 'main'); - git(work, 'branch', 'v1', commits.s1); - git(work, 'tag', 'v1.0.0', commits.c1); - - remote = path.join(root, 'remote'); - fs.mkdirSync(path.join(remote, 'dryvist'), { recursive: true }); - for (const name of ['r', 'shadow']) { - const bare = path.join(remote, 'dryvist', `${name}.git`); - git(root, 'clone', '-q', '--bare', work, bare); - git(bare, 'config', 'uploadpack.allowFilter', 'true'); - } -}); - -after(() => fs.rmSync(root, { recursive: true, force: true })); - -// A lock with one root input, `name`, from dryvist/ at `ref` (undefined = no ref) locked at `rev`. -function lockWith(name, ref, rev) { - const original = { type: 'github', owner: 'dryvist', repo: name, ...(ref === undefined ? {} : { ref }) }; - return { - version: 7, - root: 'root', - nodes: { - root: { inputs: { in: 'in' } }, - in: { original, locked: { type: 'github', owner: 'dryvist', repo: name, rev } }, - }, - }; -} - -function run(lock, policy) { - const dir = fs.mkdtempSync(path.join(root, 'case-')); - const lockPath = path.join(dir, 'flake.lock'); - const names = path.join(dir, 'stale-names'); - fs.writeFileSync(lockPath, JSON.stringify(lock)); - const env = { - ...process.env, - OWNERS: 'dryvist', - FLAKE_LOCK_REMOTE_BASE: pathToFileURL(remote).href, - STALE_NAMES_FILE: names, - ...(policy ? { FLAKE_REF_POLICY: policy } : {}), - }; - if (!policy) delete env.FLAKE_REF_POLICY; - delete env.GIT_TOKEN; - const r = spawnSync('bash', [script, lockPath], { env, encoding: 'utf8' }); - return { ...r, names: fs.readFileSync(names, 'utf8').split('\n').filter(Boolean) }; -} - -test('a vN ref resolves to the commit its annotated tag peels to', () => { - const r = run(lockWith('r', 'v1', commits.c2)); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.match(r.stdout, new RegExp(`^in: at head ${commits.c2}$`, 'm')); - assert.deepEqual(r.names, []); -}); - -test('a tag is resolved before a branch of the same name', () => { - const atTag = run(lockWith('shadow', 'v1', commits.c2)); - assert.equal(atTag.status, 0, atTag.stdout + atTag.stderr); - // The branch v1 points at s1; locking s1 is fresh only if the branch was consulted. - const atBranch = run(lockWith('shadow', 'v1', commits.s1)); - assert.equal(atBranch.status, 1, atBranch.stdout + atBranch.stderr); - assert.deepEqual(atBranch.names, ['in']); -}); - -test('in fail mode a dryvist input that names a branch or no ref fails and is not listed for relock', () => { - for (const ref of ['main', undefined]) { - const r = run(lockWith('r', ref, commits.c2), 'fail'); - assert.equal(r.status, 1, String(ref)); - assert.match(r.stdout, /dryvist input in \(dryvist\/r\) names (ref main|no ref); name a floating major tag \(vN\) or an exact release tag instead/); - assert.deepEqual(r.names, []); - } -}); - -test('in fail mode an exact release tag is a pin and passes', () => { - const r = run(lockWith('r', 'v1.0.0', commits.c1), 'fail'); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.doesNotMatch(r.stdout, /::error::/); - assert.deepEqual(r.names, []); -}); - -test('with no policy set a dryvist input that names a branch fails', () => { - const r = run(lockWith('r', 'main', commits.c2)); - assert.equal(r.status, 1, r.stdout + r.stderr); - assert.match(r.stdout, /::error::dryvist input in \(dryvist\/r\) names ref main/); -}); - -test('in warn mode a dryvist input naming a branch is a warning and keeps the branch-head check', () => { - const fresh = run(lockWith('r', 'main', commits.c2), 'warn'); - assert.equal(fresh.status, 0, fresh.stdout + fresh.stderr); - assert.match(fresh.stdout, /::warning::dryvist input in \(dryvist\/r\) names ref main/); - const stale = run(lockWith('r', 'main', commits.c1), 'warn'); - assert.equal(stale.status, 1, stale.stdout + stale.stderr); - assert.deepEqual(stale.names, ['in']); -}); - -test('a floating tag that moved past the locked rev is not stale while the rev is in its history', () => { - const r = run(lockWith('r', 'v1', commits.c1)); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.match(r.stdout, new RegExp(`^in: ${commits.c1} is in the history of v1`, 'm')); - assert.deepEqual(r.names, []); -}); - -test('a locked rev outside the tag history is stale and listed for relock', () => { - const r = run(lockWith('r', 'v1', commits.s1)); - assert.equal(r.status, 1, r.stdout + r.stderr); - assert.match(r.stdout, new RegExp(`in \\(dryvist/r v1\\) locked at ${commits.s1}, which is not in the history of v1`)); - assert.deepEqual(r.names, ['in']); -}); - -// --pr mode makes no network call for an input whose ref changed, so these cases need no remote. -function runPr(baseLock, headLock) { - const dir = fs.mkdtempSync(path.join(root, 'pr-')); - fs.writeFileSync(path.join(dir, 'base.lock'), JSON.stringify(baseLock)); - fs.writeFileSync(path.join(dir, 'flake.lock'), JSON.stringify(headLock)); - const env = { ...process.env, OWNERS: 'dryvist', FLAKE_REF_POLICY: 'fail' }; - delete env.GIT_TOKEN; - return spawnSync('bash', [script, '--pr', path.join(dir, 'base.lock'), path.join(dir, 'flake.lock')], { env, encoding: 'utf8' }); -} - -test('--pr: an input moved from a branch to a floating tag is a deliberate re-pin and is not compared', () => { - const r = runPr(lockWith('r', 'main', commits.c2), lockWith('r', 'v1', commits.c1)); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.match(r.stdout, /^in: ref changed from main to v1; the new rev is not compared with the base rev$/m); -}); - -test('--pr: a base lock file that does not exist means the lock is new and passes', () => { - const dir = fs.mkdtempSync(path.join(root, 'pr-')); - fs.writeFileSync(path.join(dir, 'flake.lock'), JSON.stringify(lockWith('r', 'v1', commits.c2))); - const env = { ...process.env, OWNERS: 'dryvist', FLAKE_REF_POLICY: 'fail' }; - delete env.GIT_TOKEN; - const r = spawnSync('bash', [script, '--pr', path.join(dir, 'absent.lock'), path.join(dir, 'flake.lock')], { env, encoding: 'utf8' }); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.match(r.stdout, /^in: unchanged from base$/m); -}); - -test('--pr: an input whose ref and rev are unchanged passes', () => { - const r = runPr(lockWith('r', 'v1', commits.c2), lockWith('r', 'v1', commits.c2)); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.match(r.stdout, /^in: unchanged from base$/m); -}); diff --git a/tests/flake-lock-fresh.test.js b/tests/flake-lock-fresh.test.js deleted file mode 100644 index 9d3cf84..0000000 --- a/tests/flake-lock-fresh.test.js +++ /dev/null @@ -1,141 +0,0 @@ -'use strict'; - -// flake-lock-fresh.sh: a direct input owned by one of OWNERS must be locked at its branch head. -// A fake `git` on PATH answers ls-remote, so the test needs no network. -const assert = require('node:assert/strict'); -const test = require('node:test'); -const { spawnSync } = require('node:child_process'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const script = path.join(__dirname, '..', 'scripts', 'flake-lock-fresh.sh'); -const HEAD = 'a'.repeat(40); -const STALE = 'b'.repeat(40); - -function run(lock, { base, status = 'ahead', maxAgeDays = 0 } = {}) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flf-')); - // Fake git: every ref resolves to HEAD. - fs.writeFileSync(path.join(dir, 'git'), `#!/bin/sh\nprintf '%s\\t%s\\n' ${HEAD} "$3"\n`, { mode: 0o755 }); - // Fake curl: the compare API answers with `status`. - fs.writeFileSync(path.join(dir, 'curl'), `#!/bin/sh\necho '{"status":"${status}","ahead_by":1}'\n`, { mode: 0o755 }); - fs.writeFileSync(path.join(dir, 'flake.lock'), JSON.stringify(lock)); - const args = []; - if (base) { - fs.writeFileSync(path.join(dir, 'base.lock'), JSON.stringify(base)); - args.push('--pr', path.join(dir, 'base.lock')); - } - const names = path.join(dir, 'stale-names'); - const r = spawnSync('bash', [script, ...args, path.join(dir, 'flake.lock')], { - env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, OWNERS: 'ours other-ours', STALE_NAMES_FILE: names, MAX_AGE_DAYS: String(maxAgeDays) }, - encoding: 'utf8', - }); - return { ...r, names: fs.readFileSync(names, 'utf8').split('\n').filter(Boolean) }; -} - -const node = (owner, rev, ref, lastModified) => ({ - original: { type: 'github', owner, repo: 'r', ...(ref ? { ref } : {}) }, - locked: { type: 'github', owner, repo: 'r', rev, ...(lastModified === undefined ? {} : { lastModified }) }, -}); - -const lock = (nodes) => ({ - version: 7, - root: 'root', - nodes: { root: { inputs: Object.fromEntries(Object.keys(nodes).map((k) => [k, k])) }, ...nodes }, -}); - -test('passes when every owned input is at its branch head', () => { - const r = run(lock({ a: node('ours', HEAD, 'main'), b: node('Ours', HEAD) })); - assert.equal(r.status, 0, r.stdout + r.stderr); -}); - -test('fails and names the input when an owned input is stale', () => { - const r = run(lock({ a: node('ours', HEAD, 'main'), b: node('ours', STALE, 'develop') })); - assert.equal(r.status, 1); - assert.match(r.stdout, /b \(ours\/r develop\) locked at b{40}, branch head is a{40}/); - assert.deepEqual(r.names, ['b']); -}); - -test('checks every owner in OWNERS, not only the first', () => { - const r = run(lock({ c: node('Other-Ours', STALE) })); - assert.equal(r.status, 1); - assert.deepEqual(r.names, ['c']); -}); - -test('never lists a third-party input, even a stale one', () => { - const r = run(lock({ nixpkgs: node('NixOS', STALE, 'nixos-unstable'), b: node('ours', STALE) })); - assert.equal(r.status, 1); - assert.deepEqual(r.names, ['b']); -}); - -test('fails an owned input pinned to a revision, without listing it for relock', () => { - const pinned = node('ours', STALE); - pinned.original.rev = STALE; - const r = run(lock({ p: pinned })); - assert.equal(r.status, 1); - assert.match(r.stdout, /owned input p \(ours\/r\) is pinned to a rev; track a ref instead/); - assert.deepEqual(r.names, []); -}); - -// --pr mode: deterministic against the base lock; the fake git (every head = HEAD) is never consulted. -const OLD = 'c'.repeat(40); - -test('pr: passes an unchanged lock even when the branch head moved', () => { - const r = run(lock({ a: node('ours', OLD, 'main') }), { base: lock({ a: node('ours', OLD, 'main') }), status: 'behind' }); - assert.equal(r.status, 0, r.stdout + r.stderr); -}); - -test('pr: passes a fast-forward of a locked rev', () => { - const r = run(lock({ a: node('ours', STALE, 'main') }), { base: lock({ a: node('ours', OLD, 'main') }), status: 'ahead' }); - assert.equal(r.status, 0, r.stdout + r.stderr); -}); - -test('pr: fails a locked rev that does not descend from the base rev', () => { - for (const status of ['behind', 'diverged']) { - const r = run(lock({ a: node('ours', STALE, 'main') }), { base: lock({ a: node('ours', OLD, 'main') }), status }); - assert.equal(r.status, 1, status); - assert.match(r.stdout, /a \(ours\/r\) moves from c{40} to b{40}, which does not descend from it/); - } -}); - -test('pr: fails an owned input pinned to a revision', () => { - const pinned = node('ours', OLD); - pinned.original.rev = OLD; - const r = run(lock({ p: pinned }), { base: lock({ p: pinned }) }); - assert.equal(r.status, 1); - assert.match(r.stdout, /is pinned to a rev/); -}); - -test('pr: passes a new input with no base entry', () => { - const r = run(lock({ a: node('ours', STALE, 'main') }), { base: lock({}), status: 'behind' }); - assert.equal(r.status, 0, r.stdout + r.stderr); -}); - -const secondsAgo = (days) => Math.floor(Date.now() / 1000) - days * 86400; - -test('age check includes old third-party root inputs when upstream has newer commits', () => { - const r = run(lock({ nixpkgs: node('NixOS', STALE, 'nixos-unstable', secondsAgo(31)) }), { maxAgeDays: 30 }); - assert.equal(r.status, 1, r.stdout + r.stderr); - assert.match(r.stdout, /nixpkgs \(NixOS\/r nixos-unstable\) is 31 days old with 1 newer upstream commit/); - assert.deepEqual(r.names, ['nixpkgs']); -}); - -test('age check passes a recently locked root input even when upstream has newer commits', () => { - const r = run(lock({ nixpkgs: node('NixOS', STALE, 'nixos-unstable', secondsAgo(29)) }), { maxAgeDays: 30 }); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.deepEqual(r.names, []); -}); - -test('age check passes an old root input when upstream has no newer commits', () => { - const r = run(lock({ nixpkgs: node('NixOS', STALE, 'nixos-unstable', secondsAgo(181)) }), { maxAgeDays: 30, status: 'behind' }); - assert.equal(r.status, 0, r.stdout + r.stderr); - assert.match(r.stdout, /no newer commits on nixos-unstable/); - assert.deepEqual(r.names, []); -}); - -test('age check flags an old root input when a diverged upstream ref has newer commits', () => { - const r = run(lock({ nixpkgs: node('NixOS', STALE, 'nixos-unstable', secondsAgo(31)) }), { maxAgeDays: 30, status: 'diverged' }); - assert.equal(r.status, 1, r.stdout + r.stderr); - assert.match(r.stdout, /nixpkgs \(NixOS\/r nixos-unstable\) is 31 days old with 1 newer upstream commit/); - assert.deepEqual(r.names, ['nixpkgs']); -}); diff --git a/tests/fork-pr-runner-guard.test.js b/tests/fork-pr-runner-guard.test.js deleted file mode 100644 index d8a8105..0000000 --- a/tests/fork-pr-runner-guard.test.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const { readFileSync } = require('node:fs'); -const test = require('node:test'); - -const GUARD = '!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository'; - -for (const file of ['.github/workflows/_ci-gate.yml']) { - test(`${file}: every caller-selected runner is guarded for any event that carries a pull request`, () => { - const text = readFileSync(file, 'utf8'); - const flat = text.replace(/\n\s*/g, ' '); - assert.doesNotMatch(flat, /\(github\.event_name != 'pull_request' \|\| github\.event\.pull_request\.head/); - const selectors = text - .split('\n') - .filter((line) => /^\s+(runs-on|runner_label): \$\{\{.*inputs\.\w*(runner|label)/.test(line)); - for (const line of selectors) { - assert.ok(line.includes(GUARD), `unguarded runner selector: ${line.trim()}`); - } - }); -} diff --git a/tests/promote-major-tag.test.js b/tests/promote-major-tag.test.js deleted file mode 100644 index d0a756d..0000000 --- a/tests/promote-major-tag.test.js +++ /dev/null @@ -1,232 +0,0 @@ -'use strict'; - -// scripts/promote-major-tag.sh against a real bare origin. A fake `gh` on PATH -// returns the Canary check-run state from FAKE_CHECK, mirroring the jq output. - -const assert = require('node:assert/strict'); -const { execFileSync, spawnSync } = require('node:child_process'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const test = require('node:test'); - -const SCRIPT = path.join(__dirname, '../scripts/promote-major-tag.sh'); -const FAKE_GH = '#!/bin/sh\nprintf "%s" "$FAKE_CHECK"\n'; - -const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); - -// Origin with v1.0.0 (commit A) and the floating v1 on A. Commit B carries -// v1.1.0 and is pushed; commit C carries no tag. -function fixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'promote-')); - const origin = path.join(root, 'origin.git'); - const work = path.join(root, 'work'); - const bin = path.join(root, 'bin'); - fs.mkdirSync(bin); - fs.writeFileSync(path.join(bin, 'gh'), FAKE_GH, { mode: 0o755 }); - git(root, 'init', '-q', '--bare', origin); - git(root, 'init', '-q', work); - git(work, 'config', 'user.name', 'test'); - git(work, 'config', 'user.email', 'test@example.invalid'); - git(work, 'config', 'tag.gpgSign', 'false'); - git(work, 'remote', 'add', 'origin', origin); - git(work, 'commit', '-q', '--allow-empty', '-m', 'a'); - const a = git(work, 'rev-parse', 'HEAD'); - git(work, 'tag', 'v1.0.0', a); - git(work, 'tag', 'v1', a); - git(work, 'push', '-q', 'origin', 'HEAD', '--tags'); - git(work, 'commit', '-q', '--allow-empty', '-m', 'b'); - const b = git(work, 'rev-parse', 'HEAD'); - git(work, 'tag', 'v1.1.0', b); - git(work, 'push', '-q', 'origin', 'HEAD', '--tags'); - git(work, 'commit', '-q', '--allow-empty', '-m', 'c'); - const c = git(work, 'rev-parse', 'HEAD'); - return { work, origin, bin, a, b, c }; -} - -const remoteTag = (origin, tag) => - git(origin, 'rev-parse', '--verify', `refs/tags/${tag}^{commit}`); - -function promote({ work, bin }, sha, { check = '', flags = [] } = {}) { - return spawnSync('bash', [SCRIPT, sha, ...flags], { - cwd: work, - encoding: 'utf8', - env: { - ...process.env, - PATH: `${bin}:${process.env.PATH}`, - GH_TOKEN: 'test', - GITHUB_REPOSITORY: 'dryvist/.github', - FAKE_CHECK: check, - }, - }); -} - -test('moves the floating major tag once Canary succeeded on the release commit', () => { - const f = fixture(); - const r = promote(f, f.b, { check: 'completed success' }); - assert.equal(r.status, 0, r.stderr); - assert.equal(remoteTag(f.origin, 'v1'), f.b); -}); - -test('exits 0 without moving the tag while Canary is still running', () => { - const f = fixture(); - const r = promote(f, f.b, { check: 'in_progress ' }); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /::notice::Canary on .* is in_progress/); - assert.equal(remoteTag(f.origin, 'v1'), f.a); -}); - -test('exits 0 without moving the tag when no Canary check-run exists yet', () => { - const f = fixture(); - const r = promote(f, f.b, { check: '' }); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /::notice::No Canary check-run/); - assert.equal(remoteTag(f.origin, 'v1'), f.a); -}); - -test('fails and leaves the tag alone when Canary concluded failure', () => { - const f = fixture(); - const r = promote(f, f.b, { check: 'completed failure' }); - assert.equal(r.status, 1); - assert.match(r.stdout, /::error::Canary on .* concluded 'failure'/); - assert.equal(remoteTag(f.origin, 'v1'), f.a); -}); - -test('exits 0 without moving anything when the commit carries no release tag', () => { - const f = fixture(); - const r = promote(f, f.c, { check: 'completed success' }); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /::notice::.* has no vX\.Y\.Z tag/); - assert.equal(remoteTag(f.origin, 'v1'), f.a); -}); - -test('--gate-verified skips the check-run lookup', () => { - const f = fixture(); - // The check would fail if read; the flag means the caller already verified the summary. - const r = promote(f, f.b, { check: 'completed failure', flags: ['--gate-verified'] }); - assert.equal(r.status, 0, r.stderr); - assert.equal(remoteTag(f.origin, 'v1'), f.b); -}); - -test('refuses to move the floating tag backwards', () => { - const f = fixture(); - promote(f, f.b, { check: 'completed success' }); - const r = promote(f, f.a, { check: 'completed success' }); - assert.equal(r.status, 1); - assert.match(r.stdout, /Refusing to move it backwards/); - assert.equal(remoteTag(f.origin, 'v1'), f.b); -}); - -test('--no-canary moves the floating tag without reading the Canary check-run', () => { - const f = fixture(); - // The check would fail if read; --no-canary means consumer repos have no Canary to read. - const r = promote(f, f.b, { check: 'completed failure', flags: ['--no-canary'] }); - assert.equal(r.status, 0, r.stderr); - assert.equal(remoteTag(f.origin, 'v1'), f.b); -}); - -test('never moves the floating tag for a prerelease-only commit', () => { - const f = fixture(); - git(f.work, 'commit', '-q', '--allow-empty', '-m', 'd'); - const d = git(f.work, 'rev-parse', 'HEAD'); - git(f.work, 'tag', 'v1.2.0-rc.1', d); - git(f.work, 'push', '-q', 'origin', 'HEAD', '--tags'); - const r = promote(f, d, { flags: ['--no-canary'] }); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /::notice::.* has no vX\.Y\.Z tag/); - assert.equal(remoteTag(f.origin, 'v1'), f.a); -}); - -const RELEASE = path.join(__dirname, '../scripts/promote-major-release.sh'); - -function release({ work, bin }, tag, mode = '') { - return spawnSync('bash', [RELEASE, tag, mode], { - cwd: work, - encoding: 'utf8', - env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, GH_TOKEN: 'test', GITHUB_REPOSITORY: 'example/repo', FAKE_CHECK: '' }, - }); -} - -test('promote-major-release moves the floating tag for a stable tag with --no-canary', () => { - const f = fixture(); - const r = release(f, 'v1.1.0', '--no-canary'); - assert.equal(r.status, 0, r.stderr); - assert.equal(remoteTag(f.origin, 'v1'), f.b); -}); - -test('promote-major-release fails on a non-release tag when the Canary gate applies', () => { - const f = fixture(); - const r = release(f, 'name-v1.1.0'); - assert.equal(r.status, 1); - assert.match(r.stdout, /::error::'name-v1\.1\.0' is not a release tag/); -}); - -test('promote-major-release skips a component-prefixed tag with --no-canary', () => { - const f = fixture(); - const r = release(f, 'name-v1.1.0', '--no-canary'); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /::notice::'name-v1\.1\.0' is not a vMAJOR\.MINOR\.PATCH release tag/); - assert.equal(remoteTag(f.origin, 'v1'), f.a); -}); - -// A new commit on top of the fixture carrying `name`, pushed with its tag. -function tagged(f, name) { - git(f.work, 'commit', '-q', '--allow-empty', '-m', name); - const sha = git(f.work, 'rev-parse', 'HEAD'); - git(f.work, 'tag', name, sha); - git(f.work, 'push', '-q', 'origin', 'HEAD', '--tags'); - return sha; -} - -test('moves the floating tag for a stable tag with build metadata', () => { - const f = fixture(); - const sha = tagged(f, 'v1.1.0+rev.1'); - const r = promote(f, sha, { flags: ['--no-canary'] }); - assert.equal(r.status, 0, r.stderr); - assert.equal(remoteTag(f.origin, 'v1'), sha); -}); - -for (const name of ['v1.2.0+rev.1', 'v1.2.0+cli.0.4.53']) { - test(`promote-major-release accepts ${name}`, () => { - const f = fixture(); - const sha = tagged(f, name); - const r = release(f, name, '--no-canary'); - assert.equal(r.status, 0, r.stderr); - assert.equal(remoteTag(f.origin, 'v1'), sha); - }); -} - -test('never moves the floating tag for a prerelease tag with build metadata', () => { - const f = fixture(); - const sha = tagged(f, 'v1.2.0-rc.1+build'); - const r = promote(f, sha, { flags: ['--no-canary'] }); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /::notice::.* has no vX\.Y\.Z tag/); - assert.equal(remoteTag(f.origin, 'v1'), f.a); - const rel = release(f, 'v1.2.0-rc.1+build', '--no-canary'); - assert.equal(rel.status, 0, rel.stderr); - assert.match(rel.stdout, /::notice::.* is not a vMAJOR\.MINOR\.PATCH release tag/); - assert.equal(remoteTag(f.origin, 'v1'), f.a); -}); - -for (const name of ['v1.1.0+', 'v1.1.0+a..b', 'v1.1.0+a_b', 'v1.1.0+a.']) { - test(`promote-major-release rejects malformed build metadata ${name}`, () => { - const f = fixture(); - const r = release(f, name, '--no-canary'); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /::notice::.* is not a vMAJOR\.MINOR\.PATCH release tag/); - assert.equal(remoteTag(f.origin, 'v1'), f.a); - assert.equal(release(f, name).status, 1); - }); -} - -test('still refuses to move the floating tag backwards for a metadata tag', () => { - const f = fixture(); - promote(f, f.b, { check: 'completed success' }); - git(f.work, 'tag', 'v1.0.0+rev.1', f.a); - git(f.work, 'push', '-q', 'origin', '--tags'); - const r = promote(f, f.a, { check: 'completed success' }); - assert.equal(r.status, 1); - assert.match(r.stdout, /Refusing to move it backwards/); - assert.equal(remoteTag(f.origin, 'v1'), f.b); -}); diff --git a/tests/release-tag-guard.test.js b/tests/release-tag-guard.test.js deleted file mode 100644 index c242867..0000000 --- a/tests/release-tag-guard.test.js +++ /dev/null @@ -1,98 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const test = require('node:test'); -const { spawnSync } = require('node:child_process'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const script = path.join(__dirname, '..', 'scripts', 'release-tag-guard.sh'); - -const FAKE_GH = [ - '#!/bin/sh', - 'printf "%s\\n" "$*" >> "$CALLS"', - 'case "$1 $2" in', - ' "pr list") cat "$PR_LIST" ;;', - ' api\\ *)', - ' sha=${2#*/compare/}; sha=${sha%...main}', - " status=$(jq -r --arg sha \"$sha\" '.[$sha]' \"$STATUSES\")", - ' [ "$status" != null ] || { echo "unexpected compare sha: $sha" >&2; exit 1; }', - ' printf "%s\\n" "$status" ;;', - ' *) echo "unexpected gh invocation: $*" >&2; exit 1 ;;', - 'esac', -].join('\n'); - -function pullRequest(number, sha) { - return { number, mergeCommit: { oid: sha } }; -} - -function run(prs, statuses = {}) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rtg-')); - const listPath = path.join(dir, 'prs.json'); - const statusesPath = path.join(dir, 'statuses.json'); - const callsPath = path.join(dir, 'calls'); - fs.writeFileSync(path.join(dir, 'gh'), FAKE_GH, { mode: 0o755 }); - fs.writeFileSync(listPath, JSON.stringify(prs)); - fs.writeFileSync(statusesPath, JSON.stringify(statuses)); - - const result = spawnSync('bash', [script], { - encoding: 'utf8', - env: { - ...process.env, - PATH: dir + ':' + process.env.PATH, - GH_TOKEN: 'test-token', - GH_REPO: 'dryvist/example', - PR_LIST: listPath, - STATUSES: statusesPath, - CALLS: callsPath, - }, - }); - const calls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, 'utf8').trim().split('\n') : []; - return { ...result, calls }; -} - -function assertResult(result, safe, decisions) { - assert.equal(result.status, 0, result.stdout + result.stderr); - assert.ok(result.stdout.endsWith('safe=' + safe + '\n'), result.stdout); - for (const [number, sha, status] of decisions) { - assert.ok(result.stdout.includes('PR #' + number + ' sha=' + sha + ' status=' + status)); - } - assert.ok( - result.calls[0].startsWith( - 'pr list --repo dryvist/example --state merged --label autorelease: pending --base develop --limit 1000 --json number,mergeCommit', - ), - result.calls[0], - ); -} - -test('has no tag blocker when there are no pending release PRs', () => { - assertResult(run([], {}), 'true', []); -}); - -test('allows a pending release PR whose merge commit is an ancestor of main', () => { - assertResult(run([pullRequest(11, 'a11')], { a11: 'ahead' }), 'true', [[11, 'a11', 'ahead']]); -}); - -test('allows a pending release PR whose merge commit is identical to main', () => { - assertResult(run([pullRequest(12, 'a12')], { a12: 'identical' }), 'true', [[12, 'a12', 'identical']]); -}); - -test('blocks a pending release PR whose merge commit is not yet on main', () => { - assertResult(run([pullRequest(13, 'a13')], { a13: 'behind' }), 'false', [[13, 'a13', 'behind']]); -}); - -test('blocks a pending release PR whose merge commit diverged from main', () => { - assertResult(run([pullRequest(14, 'a14')], { a14: 'diverged' }), 'false', [[14, 'a14', 'diverged']]); -}); - -test('blocks when one of two pending release PR merge commits is not on main', () => { - assertResult( - run([pullRequest(15, 'a15'), pullRequest(16, 'a16')], { a15: 'ahead', a16: 'diverged' }), - 'false', - [ - [15, 'a15', 'ahead'], - [16, 'a16', 'diverged'], - ], - ); -}); diff --git a/tests/renovate-config-validate.test.js b/tests/renovate-config-validate.test.js deleted file mode 100644 index 98f3291..0000000 --- a/tests/renovate-config-validate.test.js +++ /dev/null @@ -1,17 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const { readFileSync } = require('node:fs'); -const test = require('node:test'); - -const validator = readFileSync('.github/workflows/renovate-config-validate.yml', 'utf8'); - -test('Renovate config validator keeps its strict, configurable contract', () => { - assert.match(validator, /workflow_call:/); - assert.match(validator, /config_file:/); - assert.match(validator, /RENOVATE_CONFIG_FILE/); - assert.match(validator, /renovate-config-validator --strict/); - assert.match(validator, /RENOVATE_VERSION: "\d+\.\d+\.\d+"/); - assert.match(validator, /node-version: "\d+\.\d+\.\d+"/); - assert.match(validator, /key: renovate-cli-\$\{\{ runner\.os \}\}-\$\{\{ env\.RENOVATE_VERSION \}\}/); -}); diff --git a/tests/reusable-workflows.test.js b/tests/reusable-workflows.test.js deleted file mode 100644 index 00b17f2..0000000 --- a/tests/reusable-workflows.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import assert from 'node:assert/strict'; -import { readdirSync, readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import test from 'node:test'; - -const workflowsDir = fileURLToPath(new URL('../.github/workflows/', import.meta.url)); - -// A reusable workflow declares `workflow_call:` under `on:` (two-space indent). -const readWorkflow = (name) => readFileSync(`${workflowsDir}${name}`, 'utf8'); -const workflows = readdirSync(workflowsDir).filter((name) => name.endsWith('.yml')); -const reusables = workflows.filter((name) => /^ {2}workflow_call:/m.test(readWorkflow(name))); -const centralized = [ - '_agent-pr-body.yml', - '_ansible-ci.yml', - '_cribl-pack-validate.yml', - '_file-size.yml', - '_markdown-lint.yml', - '_nix-build.yml', - '_nix-validate.yml', - '_osv-scan.yml', - '_project-router.yml', - '_python-ci.yml', - '_python-security.yml', - '_renovate-annotations.yml', - '_tofu-ci.yml', - '_token-limits.yml', - '_zizmor.yml', -]; - -test('generic reusable workflows are not duplicated in this repository', () => { - assert.deepEqual(workflows.filter((name) => centralized.includes(name)), []); -}); - -test('the scan finds the shared gate among the reusable workflows', () => { - assert.ok(reusables.includes('_ci-gate.yml'), `reusables found: ${reusables.join(', ')}`); -}); - -// The top-level concurrency block of a workflow, or null when it has none. -function topLevelConcurrency(text) { - const match = text.match(/^concurrency:\n((?: .*\n?)+)/m); - return match ? match[1] : null; -} - -// Why a reusable's top-level concurrency block is unsafe, or null when allowed. -// Allowed: a non-cancelling lock whose group does not reference github.workflow. -function concurrencyProblem(block) { - const group = block.match(/^ group:\s*(.*)$/m)?.[1] ?? ''; - const cancel = block.match(/^ cancel-in-progress:\s*(.*)$/m)?.[1]?.trim() ?? ''; - if (cancel !== 'false') { - return `cancel-in-progress is "${cancel || 'unset'}", so a cancelling group cancels sibling calls in one caller run`; - } - if (group.includes('github.workflow')) { - return "the group references github.workflow, which collides with the caller's own group and deadlocks"; - } - return null; -} - -test('a reusable workflow carries a concurrency block only as a non-cancelling, repository-keyed lock', () => { - const offenders = []; - for (const name of reusables) { - const block = topLevelConcurrency(readWorkflow(name)); - const problem = block === null ? null : concurrencyProblem(block); - if (problem) offenders.push(`${name}: ${problem}`); - } - assert.deepEqual(offenders, [], `top-level concurrency rejected:\n${offenders.join('\n')}`); -}); - -test('the concurrency rule rejects a cancelling group and a github.workflow group', () => { - assert.match(concurrencyProblem(' group: x\n cancel-in-progress: true\n'), /cancels sibling calls/); - assert.match( - concurrencyProblem(' group: y-${{ github.workflow }}\n cancel-in-progress: false\n'), - /deadlocks/, - ); - assert.equal( - concurrencyProblem(' group: flake-lock-${{ github.repository }}\n cancel-in-progress: false\n'), - null, - ); -}); - -test('ansible_lint_args is declared on the gate and forwarded to lint_args', () => { - const gate = readWorkflow('_ci-gate.yml'); - assert.match(gate, /^ {6}ansible_lint_args:$/m); - assert.match(gate, /^ {6}lint_args: \$\{\{ inputs\.ansible_lint_args \}\}$/m); -}); diff --git a/tests/test_disclosure_denylist_scan.py b/tests/test_disclosure_denylist_scan.py deleted file mode 100644 index 713e34e..0000000 --- a/tests/test_disclosure_denylist_scan.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Unit tests for scripts/disclosure-denylist-scan.sh. - -Builds a tiny throwaway git repo, runs the scanner against it, and asserts: - - a hit exits 1 and prints only "path:line" (never the matched term) - - a clean diff exits 0 - - a PR title/body hit is caught the same way, never echoing the text -""" - -import os -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path - -SCRIPT = ( - Path(__file__).resolve().parents[1] / "scripts" / "disclosure-denylist-scan.sh" -) - - -def run_git(repo, *args): - subprocess.run( - ["git", "-C", str(repo), *args], - check=True, - capture_output=True, - env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", - "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}, - ) - - -class DisclosureDenylistScanTest(unittest.TestCase): - def setUp(self): - self.tmp = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) - self.repo = self.tmp / "repo" - self.repo.mkdir() - run_git(self.repo, "init", "-q") - (self.repo / "README.md").write_text("hello\n") - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "base") - run_git(self.repo, "rev-parse", "HEAD") - self.base_sha = subprocess.run( - ["git", "-C", str(self.repo), "rev-parse", "HEAD"], - check=True, capture_output=True, text=True, - ).stdout.strip() - - self.denylist = self.tmp / "denylist.txt" - self.denylist.write_text("SECRET-TERM\n# a comment\n\nANOTHER-TERM\n") - - def _run(self, env_extra=None): - env = {**os.environ} - if env_extra: - env.update(env_extra) - return subprocess.run( - ["bash", str(SCRIPT), str(self.denylist), self.base_sha], - cwd=self.repo, capture_output=True, text=True, env=env, - ) - - def test_clean_diff_passes(self): - (self.repo / "notes.txt").write_text("nothing sensitive here\n") - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "clean change") - - result = self._run() - self.assertEqual(result.returncode, 0, result.stderr) - self.assertNotIn("SECRET-TERM", result.stdout) - - def test_denylisted_term_in_diff_fails_without_leaking_it(self): - (self.repo / "notes.txt").write_text("contains SECRET-TERM right here\n") - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "leaky change") - - result = self._run() - self.assertEqual(result.returncode, 1) - self.assertIn("notes.txt:1", result.stdout) - self.assertNotIn("SECRET-TERM", result.stdout) - self.assertNotIn("SECRET-TERM", result.stderr) - - def test_existing_term_in_touched_file_is_not_rescanned(self): - (self.repo / "CHANGELOG.md").write_text("old SECRET-TERM entry\n") - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "history") - self.base_sha = subprocess.run( - ["git", "-C", str(self.repo), "rev-parse", "HEAD"], - check=True, capture_output=True, text=True, - ).stdout.strip() - (self.repo / "CHANGELOG.md").write_text("new entry\nold SECRET-TERM entry\n") - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "prepend") - - result = self._run() - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - def test_added_line_hit_reports_its_new_line_number(self): - (self.repo / "README.md").write_text("hello\nfine\nnow ANOTHER-TERM\n") - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "append") - - result = self._run() - self.assertEqual(result.returncode, 1) - self.assertIn("README.md:3", result.stdout) - self.assertNotIn("ANOTHER-TERM", result.stdout + result.stderr) - - def test_added_line_starting_with_plus_plus_is_scanned(self): - (self.repo / "README.md").write_text("hello\n++ ANOTHER-TERM\nafter\n") - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "plus-prefixed line") - - result = self._run() - self.assertEqual(result.returncode, 1) - self.assertIn("README.md:2", result.stdout) - self.assertNotIn("ANOTHER-TERM", result.stdout + result.stderr) - - def test_pr_title_hit_fails_without_leaking_it(self): - result = self._run(env_extra={"PR_TITLE": "fix ANOTHER-TERM handling"}) - self.assertEqual(result.returncode, 1) - self.assertIn("PR title:1", result.stdout) - self.assertNotIn("ANOTHER-TERM", result.stdout) - - def test_term_inside_a_larger_word_does_not_hit(self): - """The false-positive fix: a denylisted term is only a hit as a whole token. - - Without `grep -w` these three lines each matched, which is what made the - gate fire on ordinary prose that merely contains the term as a substring. - """ - (self.repo / "notes.txt").write_text( - "prefixSECRET-TERM\n" - "SECRET-TERMsuffix\n" - "wrappedSECRET-TERMinside\n" - ) - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "substring only") - - result = self._run() - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - def test_standalone_term_still_hits(self): - """Effectiveness is preserved: the same term as its own token still fails. - - Paired with the test above — the fix must narrow matching without - blinding the gate, so this asserts recall at each separator a real leak - would use. - """ - for line in ("SECRET-TERM", "host SECRET-TERM here", - "(SECRET-TERM)", "SECRET-TERM.example", "a/SECRET-TERM/b"): - with self.subTest(line=line): - (self.repo / "notes.txt").write_text(line + "\n") - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "leak") - - result = self._run() - self.assertEqual(result.returncode, 1, f"missed: {line!r}") - self.assertIn("notes.txt:1", result.stdout) - self.assertNotIn("SECRET-TERM", result.stdout) - self.assertNotIn("SECRET-TERM", result.stderr) - - def test_underscore_bounds_a_term(self): - """`grep -w` counts `_` as a word character, so it is normalized away. - - Without the normalization this line escapes the gate, because `_` glues - the term to the neighbouring token and `-w` then sees one long word. - """ - (self.repo / "notes.txt").write_text("prefix_SECRET-TERM_suffix\n") - run_git(self.repo, "add", "-A") - run_git(self.repo, "commit", "-q", "-m", "underscore adjacent") - - result = self._run() - self.assertEqual(result.returncode, 1, result.stdout + result.stderr) - self.assertIn("notes.txt:1", result.stdout) - self.assertNotIn("SECRET-TERM", result.stdout) - - RENOVATE_BODY = ( - "Bumps a dependency.\n" - "### Release Notes\n" - "upstream says SECRET-TERM\n" - "### Configuration\n" - "{config}\n" - ) - - def test_renovate_release_notes_are_not_scanned(self): - body = self.RENOVATE_BODY.format(config="schedule: at any time") - result = self._run(env_extra={"PR_BODY": body, "PR_AUTHOR": "renovate[bot]"}) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - def test_renovate_body_outside_release_notes_still_hits(self): - body = self.RENOVATE_BODY.format(config="ANOTHER-TERM") - result = self._run(env_extra={"PR_BODY": body, "PR_AUTHOR": "renovate[bot]"}) - self.assertEqual(result.returncode, 1) - self.assertEqual(result.stdout.split(), ["PR", "body:5"]) - self.assertNotIn("ANOTHER-TERM", result.stdout + result.stderr) - - def test_release_notes_heading_from_a_person_is_still_scanned(self): - body = self.RENOVATE_BODY.format(config="nothing") - result = self._run(env_extra={"PR_BODY": body, "PR_AUTHOR": "someone"}) - self.assertEqual(result.returncode, 1) - self.assertIn("PR body:3", result.stdout) - - def test_missing_denylist_is_instrument_error(self): - result = subprocess.run( - ["bash", str(SCRIPT), str(self.tmp / "nope.txt"), self.base_sha], - cwd=self.repo, capture_output=True, text=True, - ) - self.assertEqual(result.returncode, 2) - - -if __name__ == "__main__": - unittest.main()