Skip to content

Repository files navigation

ci-workflows

Reusable, configurable CI execution for the melodic-software org: composite actions that install and run each code-quality tool, plus the runner scripts they bundle.

Consumed by reference from a consumer job, never copied:

jobs:
  markdown:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@<sha>
      - uses: melodic-software/ci-workflows/.github/actions/markdown@<sha>

This repo is public, so its actions and reusable workflows are consumable by any repository — public or private, in or out of the org — with no access configuration and no PAT. The runner fetches the referenced action directly, and an action's bundled script is reached via $GITHUB_ACTION_PATH without any checkout of this repo. (Public is required because a public consumer such as melodic-software/claude-code-plugins can only uses: public repos.)

Contract

  • Configurable, not forkable. Each action exposes typed inputs with global-standard defaults. Consumers override repo-specific scope (globs, paths, tool versions, config location) through inputs — never by editing the action.
  • Pin by SHA. Reference every action at a full commit SHA; Dependabot (github-actions, weekly) opens bump PRs that are reviewed and merged manually. Dependabot updates only uses: SHAs — the tool versions pinned in each action's version:/analyzer-version: input default (and the checksum-verified install URLs) have no package manifest it can track, so the scheduled tool-version-drift-check workflow watches upstream releases and files an advisory issue when a default falls behind.
  • Each consumer aggregates locally. One action runs one tool inside a consumer job. The required-check contract is a single check named ci-status, produced by a thin gateway job the consumer keeps local so the required-check name stays un-nested. The gateway needs: the lane jobs and fails if any failed or was cancelled. Whether a skipped lane passes is the consumer's policy via the ci-status action's treat-skipped-as input — see that action below for when a skip must block rather than pass.

Versioning

Every tag is full SemVer (vX.Y.Z). A release is cut whenever main changes by something worth pinning to — release.yml's manual workflow_dispatch (patch/minor/major) makes each release a deliberate act, and cutting one after every meaningful change keeps main at the latest tag so Dependabot's github-actions group bumps consumers to a tagged SHA instead of tracking main HEAD by drift. Because nothing cuts a release automatically, the scheduled release-gap-check workflow watches for the failure mode of that deliberateness — main running ahead of the newest published Release for too long — and files an advisory rolling issue; cutting the release stays manual. There is no calendar cadence; GitHub's own guidance is silent on release frequency, and a tag-per-change policy is a closer fit for a repository whose only "release" event is "a consumer might need to pin to this." GitHub's reusable-workflow reference guidance treats a SHA, a release tag, and a branch as equally valid {ref} forms and states plainly that "Using the commit SHA is the safest option for stability and security" (Reuse workflows) — this repo's tag-per-change practice and every consumer's SHA pin both sit inside that guidance, not around it.

No floating major tag (v1). GitHub's action-release guidance recommends "keeping major (v1) and minor (v1.1) tags current to the latest appropriate commit" so a consumer written as uses: owner/action@v1 keeps receiving non-breaking updates automatically (Releasing and maintaining actions). That guidance is scoped to actions consumed by a floating tag resolved at Actions runtime, and its own reusable-workflow reference page makes no equivalent recommendation. Neither the letter nor the premise of that guidance applies here: every consumer of this repository pins by 40-character commit SHA — the runner-policy allowlist requires it fleet-wide — so no consumer's workflow run ever resolves @v1, and a floating major tag would exist only as a label nothing at runtime reads. It would also actively conflict with the pin-comment convention consumers now follow (melodic-software/standards#248): that convention's primary form, # vX.Y.Z, asserts the comment names the release the pinned SHA corresponds to, a claim a moving tag cannot keep true once it moves. This is declined outright, not deferred by oversight; the trigger for revisiting it is this repository ever moving consumers onto tag-resolved-at-runtime references, which the current SHA-pin governance model gives no reason to expect.

v0.x carries no stability guarantee. Per SemVer's own terms, "Major version zero (0.y.z) is for initial development. Anything MAY change at any time. The public API SHOULD NOT be considered stable" (SemVer, item 4). This repository is v0.7.0 at the time of writing: a release may still ship a change a post-1.0 line would have to treat as its own minor/patch distinction. Committing to v1.0.0 — SemVer's "defines the public API" milestone (SemVer, item 5) — is a deliberate, separate decision this document defers rather than resolves; nothing here should be read as an implicit 1.0 commitment or a timeline toward one.

A caller's SHA pin does not cascade. Pinning this repository by SHA proves only that the one referenced file's bytes are fixed at that commit; GitHub resolves a reusable workflow's own nested calls to further reusable workflows or actions independently, at their own {ref} (Reusing workflow configurations). GitHub's docs do not call this out directly, but it follows structurally from that per-reference resolution model, and it is exactly the gap reported in a community discussion about a workflow whose nested composite actions kept tracking a branch after the caller pinned the calling workflow file to a commit. This repository closes that gap on its own side, not the consumer's: every external reference its own actions and workflows make — third-party actions such as astral-sh/setup-uv, anthropics/claude-code-action, and peter-evans/create-issue-from-file — is itself pinned to a full commit SHA with the same dual-form trailing comment consumers use (melodic-software/standards#248). A consumer's pin on this repository is only as trustworthy as this repository's own pins one level down, and this repository keeps that chain closed rather than asking every consumer to audit it.

Actions

  • .github/actions/markdown — markdownlint-cli2 over the repo's markdown.

  • .github/actions/shellcheck — ShellCheck over the repo's shell scripts (installs a pinned, checksum-verified binary). Its default discovery remains tracked *.sh/*.bash; extra-globs adds tracked extensionless inputs as newline-delimited Git pathspecs, with optional extra-exclude-codes scoped only to that extra lane so ordinary scripts keep the stricter result.

  • .github/actions/shfmt — shfmt formatting check over the repo's shell scripts, driven by the caller's .editorconfig (installs a pinned, checksum-verified binary).

  • .github/actions/powershell — PSScriptAnalyzer over the repo's PowerShell, via the bundled Invoke-Pssa.ps1. Each file is analyzed exactly once and any analyzer or rule error fails closed. PSUseCorrectCasing remains disabled while the upstream runspace-affinity defect is open; retrying a crashing rule is not a quality gate.

  • .github/actions/pulumi-deploy-guard — verifies the complete Pulumi personal OIDC allow-policy set against a versioned exact-claim contract, then exports stack state without plaintext secrets and classifies reviewed operational resource URNs as existing or first-apply. Both GitHub IaC repositories call this one implementation after OIDC authentication and before minting their broad GitHub governance token. Contract v2 uses GitHub immutable owner/repo IDs and rejects Pulumi's *, ?, and . pattern operators. Callers reserve its exact workflow name uniquely and require paired live positive/near-match negative token-exchange evidence before removing the legacy trust rules.

  • .github/actions/editorconfig — editorconfig-checker validation of tracked files against the repo's .editorconfig.

  • .github/actions/typostypos spell-check over source against a caller-supplied config.

  • .github/actions/gitleaks — gitleaks secret scan over a directory or local Git history against a caller-supplied config. Git mode requires a valid, non-shallow local worktree or repository and explicitly scans commits reachable from HEAD and every locally present ref under refs/ (--all), including refs the remote advertised and the caller fetched into the checkout. Callers must use fetch-depth: 0 for advertised branch and tag history and fetch every other intended ref because hidden, unadvertised, or unfetched remote refs are absent locally and cannot be scanned. The action installs a pinned, checksum-verified binary, unconditionally redacts secret values, validates requested reports, and fails closed on missing, malformed, or operationally incomplete results.

  • .github/actions/actionlint — actionlint over the repo's GitHub Actions workflow files, with the canonical checksum-pinned ShellCheck release installed explicitly so embedded shell validation is identical on hosted and self-hosted workers.

  • .github/actions/lefthook-validate — installs a checksum-pinned Lefthook binary and runs its official validate command against the caller's fully loaded config. Native discovery is the default; config-file selects an explicit main config through Lefthook's documented LEFTHOOK_CONFIG override. extends fragments, remotes, and the matching local config are still loaded. The version and checksum inputs let a caller align the gate with an older consumer pin when necessary. This is a composed schema/load gate; Lefthook does not define it as a command or glob behavior test.

  • .github/actions/check-jsonschema — check-jsonschema validation of JSON/YAML against one schema per call (call once per schema group).

  • .github/actions/ci-status — aggregates a caller-built needs.*.result string into the single required gate check: success passes, anything else fails naming the offending result. treat-skipped-as is the caller's policy for skippedpass (default) or fail for repos where a skipped lane means one that should have run did not, such as a runner selector falling back. An unrecognised policy value fails rather than defaulting. Empty input fails closed. GitHub offers no "all other jobs" selector, so the needs list and the matching results string stay caller-owned.

  • .github/actions/change-detection — decides which CI lanes a pull request's changed files make relevant, so a caller can skip lanes at JOB level and stop paying for runs a change cannot affect. Checkout-free: the PR file listing comes from the API, and caller-named filter groups match it with root-anchored gitignore rules — the same matcher claude-security-review.yml's changes job uses, generalized to many named groups. One results JSON output maps each group to "true"/"false" strings; every operational fault (non-PR event, API failure, a listing at the 3,000-file API cap) fails OPEN to "true", while unhonorable pattern syntax (!, ?, +) and malformed group config are hard errors even on fallback runs. The required-check interplay is load-bearing: gate each lane job with !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['<group>'] != 'false' (never == 'true' — an unset output must run the lane, not skip it), keep aggregating through the always-running ci-status gateway with treat-skipped-as: pass (a job-level skip reports skipped, which branch protection counts as success under the single required check), add the detection job itself to the ci-status needs list so a broken filter config goes red instead of riding fail-open to green indefinitely, and never reach for workflow-level on.<event>.paths on a workflow whose check is required — a path-skipped workflow leaves that check Pending forever. Filter conservatively: include .github/** in every group so CI changes re-run everything, and leave content-agnostic lanes (spell-check, secret scan, editorconfig, link integrity, and kin) ungated — any file can carry the defect they gate on. This repo's own ci.yml changes job is the reference wiring.

  • .github/actions/lychee-offline — lychee --offline link/anchor reference-integrity over the repo's docs (deterministic; no network).

  • .github/actions/reference-integrity — resolves file.md "Anchor" prose citations against each cited file's headings and bold lead-ins (dependency-free awk); pairs with lychee-offline, which covers link/fragment targets.

  • .github/actions/exec-bit — verifies every tracked shebang file carries git index mode 100755, so executable scripts keep their bit on checkout.

  • .github/actions/action-metadata-filename — rejects any tracked action.yaml (GitHub also accepts this spelling, but every lane here globs action.yml only); repo-wide, not scoped to .github/actions/.

  • .github/actions/machine-specific-paths — rejects machine-specific absolute / user-home paths in tracked files (portable placeholders allowed).

  • .github/actions/comment-hygiene — scans comments for deferred-work markers (TODO/FIXME/HACK/XXX) and tracker references against its bundled organization policy, with an optional complete caller replacement.

    Local (non-CI) invocation of the four bespoke guards above is not owned here: use the standards local-lane-guards component (pointer; standards ADR-0003 / ci-workflows#190). Composite actions remain the CI wrappers.

  • .github/actions/eol-renormalize — detects index-level line-ending drift via git's clean filter, driven by the caller's .gitattributes (read-only).

  • .github/actions/ruff — Ruff lint + format-check over the repo's Python (via uvx; emits --output-format=github annotations).

  • .github/actions/pyright — Pyright strict, warnings-as-errors type-check over the repo's Python (via uvx).

  • .github/actions/biome — Biome lint + format-check over the repo's JS/TS (via npx; biome ci --error-on-warnings, emits --reporter=github annotations).

  • .github/actions/tsc — TypeScript tsc --noEmit type-check over the repo's TypeScript (via npx).

  • .github/actions/dotnet-build — builds .NET projects with Roslyn analyzers and code-style enforced as warnings-as-errors (the analysis owner: code-quality CAxxxx, code-style IDExxxx, nullable, and compiler warnings). Restores in NuGet locked mode by default: a committed packages.lock.json that drifted from the project dependencies fails with NU1004 instead of silently re-resolving (a no-op for repos without lock-file usage).

  • .github/actions/dotnet-format — verifies the C# formatting the build does not own: whitespace/layout via dotnet format whitespace --verify-no-changes, and using-directive organization via dotnet format style --diagnostics IDE0055 --verify-no-changes (the build-time analyzers own code-style and code-quality, so none of the three lanes double-report).

Each input's meaning and default is documented inline in the action's inputs: block.

Reusable workflows

Hosted workflow defaults use explicit GA operating-system generations (ubuntu-24.04 and windows-2025) instead of moving *-latest aliases. This keeps hosted/self-hosted parity reviews tied to a declared image contract while GitHub continues the normal weekly patching of each hosted image generation.

  • .github/workflows/pulumi-version-drift-check.yml — reusable-only maintenance job for GitHub IaC callers. It accepts only a hosted default-branch push, schedule, or manual dispatch, compares the exact .pulumi.version pin with Pulumi's current stable release, and maintains one marker-identified auditable incident across rename or manual closure without resetting its age. It never changes or auto-merges a pin, retires resolved incidents instead of reusing them, and hard-fails after 14 days of unresolved drift. Drift detection runs on actions/github-script rather than a gh/jq-driven shell script: this reusable runs on whatever runner the caller selects, and a self-hosted image is not guaranteed to ship those CLIs, so the implementation lives directly in the workflow on the action's bundled Node runtime instead of a generated, equality-tested copy of a repo-local .sh source. Per-caller concurrency serializes issue mutation.

  • .github/workflows/issue-triage-label.yml — applies a configured floor label (default priority: needs-triage) to an issue opened or reopened with no label matching a configured prefix (default priority:). Non-gating: never fails a PR or blocks a merge; it only guarantees new issues don't silently drop out of the triage queue for lack of a label. The guard is a label-set membership check (any label starting with label-prefix), not a title/body content match, so an explicit priority label at creation always wins. Fail-closed on a missing label: the raw add-labels endpoint auto-creates an unknown label name instead of failing, so this workflow first calls GET /repos/{owner}/{repo}/labels/{name} and hard-fails the job if that 404s, rather than ever letting a bare, undefined label get created. Label taxonomy stays github-iac-managed; this workflow only applies an existing label. Settled mechanism per claude-code-plugins#506's research-resolution comment. The caller owns the trigger and must include reopened alongside opened so an issue reopened after its tier was cleared re-acquires the floor:

    on:
      issues:
        types: [opened, reopened]
    permissions: {}
    jobs:
      issue-triage-label:
        permissions:
          issues: write
        uses: melodic-software/ci-workflows/.github/workflows/issue-triage-label.yml@<sha>

    Loop-safe by construction: adding a label emits issues.labeled, never opened/reopened, and GitHub does not start new workflow runs at all for GITHUB_TOKEN-authored events (except workflow_dispatch/ repository_dispatch), so this cannot re-trigger itself. Idempotent: a re-run is a no-op once the target label or any other tier-prefixed label is present. A human triager assigning a real tier after this workflow ran is expected to remove the floor label as part of that transition — this workflow only guarantees a floor and does not police tier assignment.

  • .github/workflows/standards-sync.yml — orchestrates exact-file distribution from the schema-v2 component manifest in melodic-software/standards. The standards checkout validates and materializes its own manifest; this workflow resolves one immutable standards SHA, then blocks every write lane until a separate metadata-only credential proves the expected App identity, active selected-repository installation metadata, and two consecutive exact views of the full unfiltered manifest's repository set. A caller's targets filter limits materialization only; it never weakens this installation-scope attestation. After that barrier, the workflow scopes a different GitHub App token to each target and opens a signed, human-reviewed PR enumerating every managed source-to-destination mapping. It never writes a downstream receipt and never copies components declared locally-owned; the owner-scoped attestation token is never passed to checkout or PR mutation. It also arms GitHub auto-merge (squash) via the same target-scoped token on any sync PR auto-merge has never been armed on — unless the manifest opts that target out with automerge: false. Keying on arming history rather than on PR creation means a PR opened while a target was opted out is armed once the opt-out lifts, while a PR someone deliberately disarmed is never overridden. A rejected arm attempt (for example an already-mergeable PR) is logged and does not fail the sync — which is why the watchdog below detects a PR that was never armed as well as one that stayed armed but blocked. See standards-sync-stuck-automerge-alert.yml.

  • .github/workflows/standards-sync-stuck-automerge-alert.yml — scans the standards-sync target repositories, read from the standards manifest at run time (never hardcoded), for open PRs authored by the standards-sync App in two states that stop a sync PR from merging itself, each past threshold-hours (default 4): armed but stuck (auto-merge on, GraphQL mergeStateStatus: BLOCKED), and never armed (no auto-merge and no auto-merge enabled event of any merge method in the timeline — a squash arm records AutoSquashEnabledEvent — in a target the manifest marks automerge: true). The second exists because the sync's arming step downgrades every rejection to a warning: a failed arm otherwise looks exactly like the status quo while the operator believes the PR is armed. Absence of an enabled event is what distinguishes arming that never took from auto-merge that was armed and later turned off, whether by a reviewer or by GitHub itself. A target opted out with automerge: false is never reported unarmed — that is the intended state, not an incident. Neither check covers an armed PR reporting a non-BLOCKED unmergeable state such as DIRTY. Consumed via uses: at job level from a scheduled caller. The tracking issue (a marker-deduped rolling report, the same mechanism link-check.yml and queue-monitor-liveness.yml use) is authored by the App, not by the caller's ambient token, so the caller grants no issues: scope and instead names the destination through the required tracking-issue-repository input: a bare repository name under the caller's own owner that the App is installed on. It is required rather than defaulted to the caller because the caller need not be — and for melodic-software/standards, the sync source, is not — a repository that installation covers. The run fails when it finds any — both states are actionable conditions, not flaky ones, so this is intentionally not advisory.

  • .github/workflows/select-runner.yml — the single organization-approved hosted/self-hosted selector. With self-hosted-only, the selector itself queues on the always-on default melodic-ubuntu-24.04-x64 route so it never spends hosted minutes before returning the caller's admitted managed label — the default tier or the capped review tier — and never runs its own selection on the review tier's small capacity. The prefer-self-hosted and hosted-only selector paths run on the standard ubuntu-24.04 hosted runner (free on public repos, quota-covered on private) so their adaptive and explicit hosted semantics remain available. prefer-hosted-while-free (ci-workflows#252) keeps the selector on the fleet while returning hosted for downstream jobs only when the cached billing probe (billing-minutes-state / CI_HOSTED_MINUTES_STATE) reports free; any other or missing state fails toward the fleet. Phase 0 verified the usage API at HTTP 200 — see prefer-hosted-while-free. Every selector path has a two-minute timeout and returns one runs-on string. A downstream job has its own runner and timeout; the selector's platform limit does not carry into that job. prefer-self-hosted is deliberately fail-open to the configured hosted runner. It uses a read-only observer GitHub App and chooses local when a governed scale-set route has a managed-prefix runner that is online and not explicitly reported as non-ephemeral, regardless of busy state: liveness, not idleness. GitHub natively queues a job until a matching runner is available, failing it only after 24 hours queued, so a busy fleet absorbs bursts without spending hosted minutes; only a fully offline fleet falls back to the hosted route. When that fallback fires (reason: no-online-runner), the selector also writes a check annotation and job summary that name local/self-hosted capacity offline and point at ci-runner host status — GitHub sometimes surfaces a drained self-hosted queue as a billing failure, which is a false lead (ci-workflows#246). Re-running failed jobs reuses the prior attempt's successful selector output; re-running all jobs makes a fresh liveness decision. Neither forces the hosted route. self-hosted-only returns the configured exact managed label for the always-on CI tier without inventoring that tier, so a trusted private workload can blind-queue and wake a scale set (scale-from-zero). Moving the selector itself onto hosted minutes to fail closed on a drained CI pool is deliberately not done here — callers that need an offline-capacity annotation for CI-tier work should use prefer-self-hosted instead. The capped review tier is different: under self-hosted-only it probes inventory and fails closed with no-online-runner (and the same operator-facing capacity text) when nothing is ONLINE, rather than hanging forever (ci-workflows#386). The queue-only label must be one of the centrally allowlisted routes — the default melodic-ubuntu-24.04-x64 tier or the capped melodic-review-ubuntu-24.04-x64 review tier; adding another route requires a reviewed immutable selector revision. Invalid queue-only configuration and selector infrastructure faults fail the selector job instead of falling back to paid hosted execution. Public repositories and fork pull requests — on pull_request and pull_request_target alike — route hosted before the observer-token action can execute, following GitHub's self-hosted runner security guidance. Same-repository pull_request_target and merge_group runs are reviewed local event classes: pull_request_target executes only the trusted base-branch definition, and a merge group can be enqueued only by a write-access user after required checks pass, so metadata-only gates on those events reach governed capacity. Same-repository Dependabot runs route like any push: their lane code executes in ephemeral one-job workers, and the selector sources the observer key from the Dependabot secrets store on Dependabot events, so the org mirrors CI_RUNNER_OBSERVER_PRIVATE_KEY there. Call it exactly once per workflow and feed the single output to every lane's runs-on; per-lane selector fan-out only multiplies identical preflight jobs:

    jobs:
      select-runner:
        uses: melodic-software/ci-workflows/.github/workflows/select-runner.yml@<sha>
        with:
          policy: ${{ vars.CI_RUNNER_POLICY }}
          self-hosted-label: ${{ vars.CI_SELF_HOSTED_LABEL }}
          self-hosted-labels-json: ${{ vars.CI_SELF_HOSTED_LABELS_JSON }}
          hosted-runner: ${{ vars.CI_HOSTED_RUNNER }}
          scope: ${{ vars.CI_RUNNER_SCOPE }}
          managed-runner-prefix: ${{ vars.CI_MANAGED_RUNNER_PREFIX }}
          observer-client-id: ${{ vars.CI_RUNNER_OBSERVER_CLIENT_ID }}
        secrets:
          observer-private-key: ${{ secrets.CI_RUNNER_OBSERVER_PRIVATE_KEY }}
    
      test:
        needs: select-runner
        runs-on: ${{ needs.select-runner.outputs.runner }}
        steps:
          - run: ./test.sh

    Never use secrets: inherit; pass only the observer key. Stable output reasons are online, self-hosted-only, hosted-only, hosted-while-free, hosted-pool-exhausted, billing-unknown, no-online-runner, missing-config, missing-secret, auth-error, api-timeout, api-error, invalid-response, and the strict infrastructure sentinel selector-error. The security eligibility guard also reports hosted-only. selector-conformance.yml runs the deterministic selector test suite and proves the public, hosted-only, and queue-only contracts without accessing local capacity. The tested CommonJS source is generated into the workflow, so the reusable-workflow SHA pins the implementation without a second checkout/ref. This matters because actions inside a called workflow otherwise run in the caller's repository context. A conformance check fails if the executable copy drifts.

    A required reusable gate that declares needs: select-runner must execute after selector failures and skips. GitHub otherwise skips the dependent job after a prerequisite failure, and a skipped required job reports success. It must also report on every outcome: the standards runner-policy validator requires selector-result reporters to declare exactly if: ${{ always() }} so every prerequisite outcome — including cancellation — still materializes the required check. GitHub generally recommends !cancelled() instead of always() for jobs that should stop with a cancelled workflow; this contract deliberately trades that for guaranteed reporting on every outcome — fail-closed on failure/skipped, real validation on confirmed cancelled (see below, #446, and #458). The reusable gate uses its runner input for every prerequisite outcome, so the caller also owns the recovery route; the cost is bounded to one caller-selected reporter run on cancellation. Use the semantic-title gate's fail-closed-on-failure/skipped, real-validate-on-cancelled prerequisite contract (this public-repository example intentionally falls back to hosted Ubuntu):

    pr-title:
      needs: select-runner
      if: ${{ always() }}
      permissions:
        pull-requests: read
        actions: read
      uses: melodic-software/ci-workflows/.github/workflows/semantic-pr.yml@<sha>
      with:
        runner: ${{ needs.select-runner.outputs.runner || 'ubuntu-24.04' }}
        prerequisite-result: ${{ needs.select-runner.result }}

    Selector-dependent callers must grant actions: read on the gate job. Thin callers that omit needs / prerequisite-result still grant actions: read because the reusable's permissions: block requests it for the cancelled-prerequisite resolver; GitHub rejects the reusable at startup if the caller grants a stricter set. Reusable workflows can only use permissions the caller grants; when prerequisite-result is cancelled, the gate lists this run's jobs via the Actions API to distinguish a true cancel (routine concurrency supersede) from a timed-out selector — GitHub collapses job timeouts into needs.*.result == cancelled, but the Jobs API exposes distinct conclusion: timed_out (#458). Without actions: read, or when the lookup fails, the gate fails closed. The heuristic prefers jobs whose name contains Select runner / select-runner; if none match, any timed_out job in the run is treated as fail-closed.

    When the caller workflow is active, the reusable uses the caller's resolved runner value unchanged for success, failure, cancelled, skipped, and empty prerequisite results. failure, skipped, and any unrecognized result then fail before validation — selector breakage stays loud and is never masked behind a green check. A cancelled result proceeds to real validation only after the Actions Jobs API confirms the prerequisite truly concluded cancelled, not timed_out (#458); true cancel is the routine signal that the caller's per-PR concurrency.cancel-in-progress superseded the run (or that someone with actions: write cancelled it manually), the reporter is by then already running on the caller-resolved runner, and the gates read live state where the mechanism allows (semantic-pr's pinned action re-fetches the PR title; do-not-merge-gate re-fetches current labels; pr-issue-linkage re-fetches the current PR body), so the check reports the real gate answer — red only when the gate is actually violated. This supersedes the earlier fail-closed-on-cancelled contract, which reasoned that a cancelled result alone does not prove a successor run will cover the same required check and that "a stale failure left by a superseded run clears on re-run". The no-proven-successor concern is still honored — the gate never passes vacuously on cancelled; it validates — but the clears-on-re-run assumption failed operationally on 2026-08-12 (medley#1769, dotfiles#453): routine supersedes left required checks RED fleet-wide with nothing wrong, and each one needed a manual re-run to clear. A superseded run's job log carries a ::notice marking the cancelled prerequisite after timeout discrimination, so a real prerequisite failure (hard ::error, exit 1) or a timed-out selector (fail-closed after API lookup) stays distinguishable from a supersede.

    The public fallback shown above and the reusable's omitted-input default both preserve ubuntu-24.04. A private self-hosted-only caller cannot reuse the public outputs.runner || 'label' form: on a strict-selector failure select-runner publishes the non-empty unroutable sentinel ci-runner-selection-failed, which a || fallback passes straight through instead of replacing. Gate the fallback on the selector result so the sentinel is ignored:

        runner: ${{ needs.select-runner.result == 'success' && needs.select-runner.outputs.runner || 'melodic-ubuntu-24.04-x64' }}

    The fallback label must itself be routable when selection fails; otherwise the required reporter job never starts and the non-success result cannot fail closed. Both success and reporting paths remain the existing single pr-title / pr-title job on the resolved runner; there is no routine aggregator or extra hosted job. GitHub documents that runs-on accepts an input-backed runner value.

    self-hosted-labels-json is an optional ordered JSON array of exact labels. When present it overrides self-hosted-label; malformed, empty, or duplicate candidate lists route hosted with invalid-response. Candidate priority is the array order, independent of runner API order. self-hosted-only requires exactly one centrally allowlisted candidate. Because GitHub documents runner labels as case-insensitive, candidate and inventory labels are compared through case-normalized keys, case-only duplicates are rejected, and the selector returns the configured spelling. V1's governed labels and name prefixes are conservative ASCII literals provisioned by IaC; this contract does not claim generic Unicode case-fold or collation safety. GitHub's generic default self-hosted labels (self-hosted, OS, and architecture labels), as well as a candidate equal to the hosted fallback, are rejected because returning either as runs-on could escape the managed fleet. Organization routing normally leaves it unset and uses one shared exact label. The personal phase provisions it as operational data so the documented live-proof fallback can switch from one shared label to two host-specific exact labels without a workflow or selector code change.

    GitHub's official runner-scale-set contract routes jobs by scale-set name. Its 2026-03-10 OpenAPI runner schema requires id, name, os, status, busy, and a labels array, but declares ephemeral optional. Live scale-set inventory can represent a JIT runner with an empty label array and omit ephemeral. When exactly one route is configured, the selector can unambiguously attribute such an empty-label runner inside the governed name prefix to that sole scale-set route. With an ordered multi-route list, an empty-label runner cannot be attributed safely and is ineligible; a candidate must be observed explicitly instead. A present non-boolean ephemeral value invalidates the complete inventory, and explicit false excludes and contaminates the inferred single-route namespace.

    When ephemeral is omitted, local selection relies on the governed trust assumption that the configured runner-name prefix and scale-set route are reserved for the ci-runner controller's one-job JIT workers. The REST response does not attest that ownership or lifecycle. The selector rejects visible namespace conflicts, but credentials and configuration must prevent another runner from satisfying the same prefix-and-route contract. Online state is still required in the returned inventory observation.

    V1 compute is Linux x64, but GitHub's official JIT-configuration response reports os: unknown, as can live JIT inventory. The selector therefore accepts case-insensitive linux or unknown only. unknown is not an OS attestation; it is accepted solely under the same governed prefix-and-route/JIT trust assumption. Any explicit bearer of a candidate route reporting another OS contaminates that route. Selected jobs separately assert the official runner context values runner.os == Linux and runner.arch == X64 before substantive work.

    Because downstream runs-on contains only the returned route, namespace integrity is checked across every explicit case-insensitive bearer returned by the paginated inventory—not only the online runner observed by the selector. A route is contaminated when an explicit bearer is outside the managed name prefix, reports ephemeral: false, or reports an OS outside the V1 Linux/JIT-unknown contract; that route is never returned. For one configured route, an empty-label managed runner is its unambiguous inferred bearer. For multiple configured routes, a conforming empty-label managed runner is ineligible because it cannot be attributed, while a nonconforming one contaminates every candidate because its hidden route could be any of them. An explicitly distinct clean lower-priority route remains eligible. Online counts include only eligible runners on clean routes. If every configured route is contaminated, selection fails hosted with invalid-response; omitted-field runners carrying unrelated explicit labels do not poison the managed namespace.

    Organization inventory is organization-wide. Selection therefore relies on IaC giving every same-label runner group identical selected-repository access for the migrated workflows. The selector cannot attest runner-group access parity: runner_group_id is optional in the inventory schema, and an observation without it does not prove which caller repositories can route to that runner.

    CI_HOSTED_RUNNER is operational configuration, but GitHub's runner-inventory API cannot prove that an arbitrary label belongs to hosted infrastructure. The selector therefore allowlists only the reviewed V1 value ubuntu-24.04 and canonicalizes every missing, malformed, unapproved, generic self-hosted, or configured local-candidate value back to it. Introducing another hosted label requires an explicit governance and conformance review.

    Inventory is an observation, not a reservation or snapshot. Pagination can race with registration and status changes between requests; stable total_count and unique runner IDs are fail-closed consistency checks, not snapshot isolation. Several simultaneous selectors can observe the same online runner and select local; GitHub queues that burst until capacity appears. Only when no matching runner is online do later selectors route hosted with no-online-runner. Validation, authentication, API, timeout, malformed-response, and github-script failures produce hosted outputs. A failure of the selector job or hosted runner before outputs exist cannot be converted by workflow expressions; dependent jobs remain blocked and must be rerun. This boundary is intentionally not described as atomic fallback.

  • .github/workflows/link-check.yml — online external-link checker, consumed via uses: at job level from a scheduled caller that grants issues: write. It is advisory: external link health is flaky, so it runs fail: false and maintains a rolling tracking issue rather than gating a build—opening or updating it on failure and, by default, closing it after the next clean run. Inputs (documented inline) let a caller shape the rolling issue — title, labels, native issue type, and the auto-close toggle — so a repo with an established issue scheme adopts the workflow without behavior change. (A whole scheduled job with issue maintenance is a reusable-workflow concern, not a composite action; the deterministic on-disk counterpart is the lychee-offline action above, which feeds ci-status.)

  • .github/workflows/zizmor.yml — GitHub Actions security/static-analysis lint with zizmor (dangerous triggers, excessive permissions, template injection). Advisory by default (fail-on-severity: never surfaces PR annotations without failing); consumed via uses: at job level. The workflow downloads the official x86_64 GNU/Linux archive for the reviewed v1.29.0 release, verifies its committed SHA-256 before extraction, and verifies the CLI-reported version before auditing. latest remains accepted for compatibility but resolves to that reviewed default rather than a mutable release. zizmor runs in its own native --format=github mode, emitting a GitHub annotation for every finding directly and gating on severity via zizmor's own graduated exit codes (informational/low/medium/high) — no SARIF intermediate for gating, no hand-rolled parser. Callers opt into blocking by raising fail-on-severity to low, medium, or high; the legacy fail-on-findings boolean stays a back-compat alias for low. Installation, argument, and collection errors fail closed even in advisory mode. The verified binary runs from a fresh runner-temporary directory with a per-job cache and without Docker, a job/service container, or an installer-time privilege escalation. runner defaults to ubuntu-24.04 and can consume the approved selector output for eligible private, non-fork calls. Callers may opt into upload-sarif: true for durable code-scanning alerts (visibility-only — does not replace fail-on-severity gating). That opt-in requires the calling job to grant security-events: write; reusable workflows cannot elevate caller permissions, so the called job's security-events: write only applies when the caller already granted it. Inputs are documented inline.

  • .github/workflows/osv-scanner.yml — dependency vulnerability scan with Google's official native OSV-Scanner v2.5.0 Linux X64 binary. The exact binary, its provenance, and the SLSA verifier are checksum-pinned; the verifier then attests the expected Google source repository and exact release tag before the scanner runs. One native SARIF scan emits escaped GitHub annotations without retaining or uploading an artifact. Advisory for findings (fail-on-vuln off by default); supply-chain, scanner, and invalid-result errors always fail closed. V2.5.0 scans supported manifests and lockfiles; .NET .csproj/PackageReference and Central Package Management are enabled by default. A committed packages.lock.json remains the reproducibility contract enforced by dotnet-build's locked-mode restore, but is no longer the only .NET coverage path. An empty scan warns (advisory) or fails (blocking) unless the caller declares the repo genuinely dependency-less via allow-no-lockfiles: true. The caller must pass its approved selector output through runner; the native lane needs no Docker socket or privileged worker. Inputs are documented inline. See the official v2.5.0 release.

    “Enabled by default” is not treated as proof that every MSBuild layout is covered. Each consumer's verification run must show nonzero package discovery for its actual .csproj/Central Package Management layout; committed lockfiles and the empty-scan guard remain required until that proof passes. The scanner's documented exit contract is also enforced: only 0 (clean) and 1 (findings) can be completed scans, 128 follows the explicit no-packages policy, and every other code fails closed. Completed exit codes must agree with a regular, non-symlink SARIF file and its finding count. Workflow-command properties and messages are escaped before annotations are emitted.

    The reviewed pin is machine-readable in .github/osv-scanner-pin.json and the workflow verifies the downloaded asset's checksum, SLSA provenance, source, release tag, and reported version before scanning. The release download is accepted only when it matches the reviewed checksum. The daily tool-version-drift-check compares Google's latest stable release and the GitHub-reported asset digests, then refreshes the existing maintenance issue; it never rewrites or auto-merges the pin. Updating requires release review, official asset/provenance checksum verification, exact source and tag verification, and a verification run in a consuming repository. See the official installation and SLSA guidance. Native OSV requires a governed runner; the optional inputs on semantic-pr and native zizmor preserve compatibility.

  • .github/workflows/dependabot-lock-regen.yml — regenerates NuGet packages.lock.json on Dependabot PRs (dotnet restore --force-evaluate) and pushes the result back to the PR branch, covering the lock-file updates Dependabot's NuGet ecosystem misses. Self-guards to dependabot[bot] events on dependabot/nuget/ branches, so the caller is a thin unconditional pull_request job granting contents: write. Inputs, the optional PUSH_TOKEN Dependabot secret, and the default-token no-retrigger caveat are documented inline.

  • .github/workflows/pester.yml — runs a Pester suite on the fixed GitHub-hosted Windows 2025 runner with a pinned Pester install. A whole-job concern (its own runner OS + checkout), so a reusable workflow: the caller passes a run command and owns discovery/reporting/exit; this supplies the hosted runner, pinned Pester, and checkout. Inputs are documented inline.

  • .github/workflows/approval-agent.yml — Approval Agent lane (ci-workflows#256). Guardrails always run (never approve own policy/workflow files; approver ≠ author/pusher; refuse on human-risk findings). Live APPROVE is opt-in via enable-approve: true plus App secrets (default remains COMMENTED). Do not add it to production required checks until a fleet caller lands. Dogfood caller approval-agent-self.yml is workflow_dispatch-only. ADR + probe: docs/topics/claude-review-lanes/approval-agent-ADR.md.

  • .github/workflows/claude-assistant.yml — org @claude mention-responder (ci-workflows#255). V1 is answer / re-review only (tool-allowlist floor; no Edit/Write, no commit/push/merge). Caller owns mention triggers + @claude guards; reusable owns pin, floor tools, timeout, concurrency. Dogfood caller claude-assistant-self.yml. ADR: docs/topics/claude-review-lanes/claude-assistant-ADR.md.

  • .github/workflows/claude-review.yml — automated PR code review with anthropics/claude-code-action. All inputs have public-safe defaults documented inline in the workflow header (the authoritative list). Consume it per the Claude lanes — shared consumption contract below.

  • .github/workflows/claude-security-review.yml — a dedicated LLM security-review pass with anthropics/claude-code-action, sibling of claude-review.yml with the same secrets interface and safe-handling model but a security-only prompt. It reviews the PR's changed files for the vulnerabilities static analysis misses — logic flaws, authorization gaps, injection surfaces, token/secret handling, dangerous workflow patterns (pull_request_target, script injection via the github context), permission-widening config changes, supply-chain pin loosening — and reports findings as a PR review with severity (CRITICAL/IMPORTANT/SUGGESTION) and a confidence axis, security only. The intended promotion path for the VERDICT is to flip to blocking on CRITICAL findings once the lane's precision is proven over a sustained window — an earned promotion (trust-before-scale). Always-report shape: a security pass on every PR is noise in a doc-heavy repo, so the lane scopes itself to security-sensitive surfaces — but the caller must NOT express that scope with a workflow-level on.pull_request.paths filter, because a path miss leaves a required check Pending forever and wedges every prose PR. Instead the caller triggers on all PR events and supplies that scope as a pattern list of root-anchored globs (workflow files, permission/settings configs, hook and shell scripts, auth/token-touching code, network-call sites); the workflow's changes job evaluates it and a not-applicable PR yields a name-stable skipped security-review check. After a successful review the lane persists the reviewed head in a marker comment; on later synchronize pushes it matches only the incremental delta, so a docs-only follow-up does not re-run a full security pass (deleting the marker forces a full re-review). A consumer's ruleset may make that EXECUTION check required (check context <caller job> / security-review); the VERDICT stays advisory.

    Absent-check mitigation (ci-workflows#227): an intermittent pull_request event-delivery gap can leave that required context ABSENT (not failed). Do not move the lane onto pull_request_target / workflow_run. Consumers should expose workflow_dispatch + pr-number on the caller (dogfood: claude-security-review-self.yml) and run the security-review-absent-mitigate companion (schedule / workflow_dispatch) which either posts a FAILED visibility check under the missing context or re-dispatches the caller. Details: docs/topics/claude-review-lanes/security-review-absent-mitigation.md.

    Where that pattern list lives is the caller's choice between two inputs. The conventional shape is paths-file, pointing at a repo-owned file (.github/claude-security-paths) so each repo keeps its own security-sensitive-surface list in its own tree; the inline paths input takes the same content directly and, when non-empty, wins over the file. The file is read from the PR's base branch, never the head, so a PR cannot edit its content to skip its own security review — repointing the input is still possible, but only as a visible caller diff, which is the pre-existing trust boundary. An absent or unreadable file fails open (every PR reviewed, with a warning), matching the changes job's fail-open discipline throughout; both inputs empty means no filtering, so a consumer that passes nothing is unaffected. Patterns are matched as root-anchored gitignore patterns rather than Actions paths: patterns — identical for the ordinary * / ** globs worth writing here, but ! negation cannot be honored as Actions defines it and is rejected outright, so express an exclusion by narrowing the positive patterns. All inputs have public-safe defaults documented inline in the workflow header (the authoritative list). Consume it per the Claude lanes — shared consumption contract below, triggering on all PR events (no workflow-level paths:):

    with:
      paths-file: .github/claude-security-paths
  • .github/workflows/claude-e2e-verify.yml — Claude-powered end-to-end verification of a PR with anthropics/claude-code-action. The caller passes a command that builds and serves its app plus the URL it listens on; the workflow provisions a pinned Playwright/Chromium toolchain, waits for the app to become healthy, then has the agent drive the running app through the caller's journeys and post its findings as a PR comment (the agent step runs continue-on-error). This workflow additionally owns the pinned browser toolchain. All inputs are documented inline in the workflow header (the authoritative list). Consume it per the Claude lanes — shared consumption contract below, with the caller additionally passing:

        with:
          app-start-command: npm ci && npm run build && npm run start
          app-url: http://localhost:3000

    This lane builds, serves, and browser-drives the PR head — it executes PR-authored code — so the fork-PR safety guarantee in the shared contract is what makes it safe: a fork gets no secrets and a read-only token, so that execution has nothing to exfiltrate. The Playwright CLI version is an in-workflow pin watched by tool-version-drift-check, not Dependabot. Promotion: flip to a selector-coupled required gate when the lane's findings prove precision over a sustained window — an earned promotion, mirroring the review lane's discipline.

  • .github/workflows/semantic-pr.yml — validates the PR title against the Conventional Commits spec (wraps the SHA-pinned amannn/action-semantic-pull-request). Gating: a non-conforming title fails the job. Because governed repos squash-merge with the squash title set to PR_TITLE, the PR title becomes the default-branch subject line, so this is the single lever that yields a Conventional-Commits history (no commit-msg hook needed). It is a standalone required check named pr-title, not a ci-status lane — title edits must not re-run the file-lint lanes; with the caller below the check a ruleset must require is pr-title / pr-title per the shared adoption contract. Inputs (runner, prerequisite-result, types, scopes, require-scope, subject-pattern, subject-pattern-error, validate-single-commit, ignore-labels) have spec-aligned defaults documented inline. Consume it from a thin caller that triggers on title-relevant events. prerequisite-result defaults to success for direct callers; selector-dependent required callers must use the fail-closed-on-failure/skipped, real-validate-on-cancelled prerequisite contract above. edited is required so re-titling re-validates; the gate passes on merge_group since the title was validated at PR time. Adopt the canonical block below (not the in-repo .github/workflows/pr-title.yml, which intentionally still triggers on pull_request — see the note after the block):

    on:
      pull_request_target:
        types: [opened, edited, reopened, synchronize]
      merge_group:
    permissions:
      pull-requests: read
      actions: read
    concurrency:
      group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
      cancel-in-progress: true
    jobs:
      pr-title:
        permissions:
          pull-requests: read
          actions: read
        uses: melodic-software/ci-workflows/.github/workflows/semantic-pr.yml@<sha>

    This check reads PR title metadata only — it checks out and runs no head code.

    This block is the canonical pattern to copy. The in-repo .github/workflows/pr-title.yml dogfood caller deliberately stays on pull_request for now: this repo is already gated on its own pr-title / pr-title check, so a PR that flips that caller to pull_request_target would have the required check run the base-branch (still-pull_request) definition and block the flip. The self-flip is therefore deferred; consumers should follow this documented block rather than copying the dogfood file.

  • .github/workflows/pr-issue-linkage.yml — validates the PR body carries a native closing keyword (Closes/Fixes/Resolves #N, including owner/repo#N, or the literal No linked issue when the PR closes nothing) and non-empty ## Summary, ## Fix, ## Verification, and ## Related sections (the four contract headers). Gating: a non-conforming body fails the job. HTML comments are stripped before either check, so an unedited PR template (whose instructional prose lives in comments) fails rather than passing vacuously. Generalizes melodic-software/provisioning's pr-body.yml into a shared reusable workflow — provisioning's own caller predates this workflow and is not required to switch. It is a standalone required check named pr-issue-linkage, not a ci-status lane — body edits must not re-run the file-lint lanes; with the caller below the check a ruleset must require is pr-issue-linkage / pr-issue-linkage per the shared adoption contract. Inputs (runner, prerequisite-result) match do-not-merge-gate.yml's shape. edited is required so a body edit re-validates; the gate passes on merge_group since the body was validated at PR time. Consume it from a thin caller that triggers on body-relevant events:

    on:
      pull_request_target:
        types: [opened, edited, reopened, synchronize]
      merge_group:
    permissions:
      pull-requests: read
      actions: read
    concurrency:
      group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
      cancel-in-progress: true
    jobs:
      pr-issue-linkage:
        permissions:
          pull-requests: read
          actions: read
        uses: melodic-software/ci-workflows/.github/workflows/pr-issue-linkage.yml@<sha>

    This check reads PR body metadata only — it checks out and runs no head code.

    Optional input exempt-authors (comma-separated exact PR-author logins, default empty) skips body validation for the listed authors — matched by exact equality against github.event.pull_request.user.login, never a *[bot] pattern, so no unknown future bot is silently skipped on the gate. It is fail-closed: the empty default exempts no one, so bumping the pinned SHA changes nothing until a caller opts in. Use it for bots whose generated PR bodies cannot carry the closing-keyword + contract-header markers (dependabot/renovate); the caller passes exempt-authors: 'dependabot[bot]' alongside the uses: line.

  • .github/workflows/do-not-merge-gate.yml — fails the job while the calling PR carries a configured label (default do-not-merge). Gating: the label's presence fails the job; a caller that requires this check blocks the merge until the label is removed. It is a standalone required check named do-not-merge, not a ci-status lane; with the caller below the check a ruleset must require is do-not-merge / do-not-merge per the shared adoption contract. Inputs (runner, prerequisite-result, label) mirror semantic-pr's fail-closed-on-failure/skipped, real-validate-on-cancelled prerequisite contract. Adopt the canonical block below:

    on:
      pull_request_target:
        types: [opened, reopened, synchronize, labeled, unlabeled]
      merge_group:
    permissions:
      pull-requests: read
      actions: read
    concurrency:
      group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
      cancel-in-progress: true
    jobs:
      do-not-merge:
        permissions:
          pull-requests: read
          actions: read
        uses: melodic-software/ci-workflows/.github/workflows/do-not-merge-gate.yml@<sha>

    This check reads PR label metadata only — it checks out and runs no head code. labeled/unlabeled are required — without them, adding the label after the check already passed would not re-trigger it, and the merge would never actually be blocked. Even with them present, a same-repo automation that labels using the default GITHUB_TOKEN still does not re-trigger this gate — see the "Known limitation — GITHUB_TOKEN-authored label changes" note below. opened/reopened/synchronize cover the check reporting on every other PR lifecycle event a ruleset's required-status-check needs to see. Unlike the other two gates, this one re-evaluates the label on merge_group itself (looking up the PR named in the merge group's temporary ref and re-checking its current labels via the API): a label — unlike semantic-pr's title or pr-issue-linkage's body — can be added after the PR's own pull_request_target run last passed, e.g. while the PR already sits in the queue, so trusting that earlier result would let a labeled PR merge through.

    Known limitation — GITHUB_TOKEN-authored label changes. The labeled/unlabeled triggers above only re-evaluate the gate when GitHub actually starts a new workflow run for that event. GitHub does not start a run at all for labeled/unlabeled events produced by the default GITHUB_TOKEN — that is a platform-level recursive-run guard (triggering-a-workflow-from-a-workflow), not a gap in the trigger list above. So if a same-repo automation job (e.g. a labeling policy workflow) applies this gate's blocking label using the default GITHUB_TOKEN after the check already reported success on the PR's HEAD SHA, no run — live-refetch or otherwise — is ever triggered, and the earlier green check-run stays on that SHA until a genuinely new qualifying event occurs (e.g. synchronize from a subsequent push, or a non-default-token labeled/unlabeled event). No trigger-list change can close this; the gap is that GitHub never starts a run.

    Adoption requirement — label-setting automation must not use the default GITHUB_TOKEN. Any same-repo workflow that applies or removes this gate's blocking label must authenticate with a GitHub App installation token or a personal access token instead of ${{ secrets.GITHUB_TOKEN }} / ${{ github.token }}. Only a label change authored by a non-default token creates the labeled/unlabeled run that re-evaluates this gate; a default-token label change leaves an already-green check silently unenforced. Verify this for every same-repo labeling automation before requiring do-not-merge / do-not-merge on that repo.

    Known gap with batched merge queues: GitHub's merge queue batches multiple PRs into one merge group by default (max group size 5), and the batch's temporary ref/SHA is named for only the last PR in the batch (a [#1, #2] batch runs as pr-2). The reusable workflow's merge_group handling re-checks only that named PR's labels, so a PR that isn't last in its batch is not individually re-evaluated at merge-queue time. Closing this fully needs either a validated way to enumerate every PR in a batch from a merge_group run (no such API is documented; unverified), or setting merge queue maximum group size to 1 in the repo's ruleset (github-iac) so every merge group is single-PR. Until one of those lands, treat merge_group label coverage as best-effort, not exhaustive, on repos that allow batching.

    Adoption precondition — single-PR merge groups. This workflow does not itself detect or assert the queue's batch size; that only exists as the gap above. Coverage on merge_group runs is exhaustive only when a merge group contains exactly one PR. Before requiring do-not-merge / do-not-merge on a repo with a merge queue, confirm the queue actually produces single-PR groups — today that means the ruleset's merge-queue maximum group size is 1; a repo that instead relies on batch enumeration must first have that enumeration implemented and validated here, which does not exist yet. Making the check required on a queued repo without satisfying this precondition does not fail loudly: it keeps reporting green while under-enforcing on non-tip batch members. Re-verify this precondition whenever the repo's merge-queue configuration changes.

Claude lanes — shared consumption contract

claude-review.yml, claude-security-review.yml, claude-e2e-verify.yml, and claude-assistant.yml share one consumption shape. Each is advisory: it posts PR/issue comments and never gates ci-status. (The advisory verdict is separate from execution evidence: claude-security-review.yml scopes itself to security-sensitive paths, and its name-stable security-review check may be made a required status check — see its entry above. claude-assistant.yml is mention-triggered answer/re-review, not a PR check.) Each is a whole-job concern (job permissions: plus a secrets: interface), which is why each is a reusable workflow rather than a composite action — the caller owns the triggers and the permission grant, and the workflow owns the SHA-pinned anthropics/claude-code-action and the safe handling. Security rules live in CLAUDE.md.

on:
  pull_request:
    types: [<per lane — see below>]
jobs:
  <lane>:
    permissions:
      contents: read
      pull-requests: write
      id-token: write
    uses: melodic-software/ci-workflows/.github/workflows/<lane>.yml@<sha>
    secrets:
      CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

The caller's job must grant those three permissions (a called workflow can only downgrade, not elevate); the CLAUDE_CODE_OAUTH_TOKEN org secret has visibility "all repositories", so every org repo receives it. Pass that one named secret explicitly rather than secrets: inherit, which forwards every parent secret. Fork PRs receive no secrets by design and are not reviewed. On both review lanes the caller can name actors whose comments are withheld from the agent's context — prompt-injection hygiene, not a trigger gate.

What the security lane's required check proves — and does not. Only claude-security-review's check is designed to be required: it reports under its own context (<caller job> / security-review), never through ci-status. Its claim is narrow: a security pass RAN at this head, or the PR was judged not applicable. The verdict stays advisory — findings never fail the job. Execution is enforced in two tiers, split by whether the PR can clear the cause. Caller drift — the action skipping itself because the caller's workflow file differs from the default branch's copy — reports FAILURE, because success, neutral and skipped all satisfy a required check, so failure is the only conclusion that does not silently authorize a merge on absent evidence, and the PR that caused it clears it by merging. An external failure — every class the classifier emits: auth (dead credential, billing), rate-limit (usage limit), overloaded (5xx), and the other catch-all, which also takes an unparsable or missing execution file — emits a loud ::warning annotation and reports SUCCESS: the cause is outside the author's and the org's control, and a required context that reddens on a provider outage locks every merge in the fleet for the length of that outage. That mapping is scoped to pull_request runs; a non-PR event cannot run the review at all and keeps the historical pass-through, and a fork PR skips the job outright — neither green is execution evidence.

The corollary is what the check does not prove. Every non-run reads as success to a ruleset. Four are name-stable job-level skips: a fork PR, an out-of-scope PR, a skip-listed actor, a kill-switched lane. A fifth is not a skip at all — a run whose head was superseded while it queued retires itself step by step and reports a green job having reviewed nothing, on the premise that the newer run for the current head reports the same context. The sixth is the external-failure tier above: a classified infrastructure failure now also reports green, deliberately. So a green required check is not by itself proof that this head was reviewed, and it never establishes that a fork PR was: review fork changes to security-sensitive surfaces by hand.

What that sixth shape costs is worth stating plainly: during a provider outage, merges land unreviewed behind a green required check. The alarm moves off the conclusion onto three surfaces that never depended on it — the outcome composite's machine-readable class=<token> annotation, the failure marker comment on the PR, and the incident aggregator, which reads lane annotations regardless of check-run conclusion and escalates the auth and runner classes — and a rate-limit storm across several distinct PRs in one polling cycle, the shape of an exhausted shared seat — to the attended queue. Availability on that tier is bought by the loud-open itself, helped by the bounded retry below; break-glass on the consumer's ruleset remains the override for caller drift and for any other red an operator must clear by hand.

That floor has an edge worth knowing. It covers what the lane can classify, which means the ruling step has to be reached — a run that dies before it still reddens the check. A genuine runner fault, the job hitting its 45-minute timeout-minutes, or a pre-ruling step throwing (the resolve and outcome steps carry no continue-on-error, so a crashed classifier fails rather than passing through) all land outside the floor. Step-level timeouts are inside it at the default configuration: each attempt is bounded at 18 minutes behind continue-on-error, and at the default retry-delay-seconds the retry budget fits under the job's 45-minute ceiling. A caller that raises retry-delay-seconds far enough can push the retry past that ceiling, landing the run in the job-timeout shape above — outside the floor. The guarantee is "no classified failure blocks a merge", not "no infrastructure problem ever blocks one".

Trigger cadence is per lane, deliberately. claude-review runs on opened / ready_for_review / reopened and not on synchronize: a push does not re-trigger the code review, so re-run the job or workflow_dispatch with the PR number (ci-workflows#254) for a fresh pass. That caps per-PR spend on active branches, and it is safe precisely because the lane's verdict gates nothing. It also skips draft PRs at job level, so an opened event on a draft costs nothing and ready_for_review is what buys the review. claude-security-review keeps synchronize, because its check certifies that a security pass ran at the head being merged — a review of an earlier head is not that evidence, and it reviews drafts. After a successful review it persists that head and, on later pushes, skips with a name-stable success when the incremental delta touches no security-relevant paths (ci-workflows#259). It also accepts workflow_dispatch + pr-number so an absent required check from a pull_request delivery gap can be re-attached without privileged triggers (ci-workflows#227; see docs/topics/claude-review-lanes/security-review-absent-mitigation.md). claude-e2e-verify keeps synchronize too, and gates on nothing but its kill-switches — no draft skip, no skip-actors input — so the most expensive lane has the loosest gate. Scope it with the caller's own trigger types. Take each lane's canonical caller from its own workflow header.

Bounded retry. Every lane makes at most two agent attempts — one automatic retry, never a loop. The retry is deliberately narrow, because a second attempt after the agent has already spoken duplicates its comments: it fires only on zero assistant turns in the first attempt's execution file. Nor does an auth-class failure retry — HTTP 401/402/403, or an authentication_error / billing_error / permission_error in the error payload — because the credential needs an operator and no retry can clear it. The gate also honors the same guards the first attempt does, so a superseded run (and, on the code-review lane, a capped one) never spends a retry. Between the attempts the lane backs off retry-delay-seconds plus a 0–29 second jitter, so lanes retrying against the same contended seat do not re-collide in lockstep. What this buys on the security lane is a real review against the sporadic-429 class, rather than the evidence gap its loud-open tier would otherwise leave behind.

One divergence is worth knowing. The two review lanes demand proof of zero turns: a missing or unparsable execution file is not proof — a hard kill can lose the file after turns were already spent — so they do not retry on one. claude-e2e-verify reads an unreadable file as recording no assistant turn and does retry. It also sets no track_progress tracking comment, so it has no orphan comment to clean up between attempts, which the review lanes do.

Review-count cap (code-review lane only). claude-review.yml stops reviewing a PR after max-reviews-per-pr successful reviews, capping spend on long-lived PRs. The counter is a visible per-PR status comment upserted after each successful review — failed and skipped runs never inflate it — which doubles as the human "was this reviewed" signal; deleting it resets the count, which is fail-open by design. A capped run is a name-stable skip, not a red check. Treat it as a soft cap: concurrent runs for different heads read the counter before either writes it, so a burst can briefly exceed it by the number of concurrent heads.

Kill-switches. Every lane honors two Actions variables at job level: CLAUDE_LANES_DISABLED (all lanes) and a per-lane switch (CLAUDE_REVIEW_DISABLED, CLAUDE_SECURITY_REVIEW_DISABLED, CLAUDE_E2E_VERIFY_DISABLED). true skips the lane's job name-stably — a required security-review check reads the skip as success, so merges are never wedged. An absent variable means enabled; a repository-level variable overrides an organization-level one, so a single repo can opt out (or back in) without an org-wide change. Incident use: set the org-level variable to true to stop a misbehaving lane fleet-wide. While the security lane is disabled NO lane reports security findings — REVIEW.md's code-review exclusion keys on the security workflow file existing, and the file remains — so re-enable promptly and treat the outage window as security-unreviewed.

All four organization variables carry all-repositories visibility. That is a deliberate deviation from the org's selected-visibility convention for Actions variables, not an oversight: a switch scoped to a selection is invisible to every repo outside it, so flipping it during an incident would silently no-op exactly where nobody is looking. A kill-switch is only worth having if it reaches the whole fleet. Do not "correct" the visibility to selected.

Adoption. Each lane's own workflow header carries its canonical caller; copy it from there. Callers are additionally being brought under the org's sync-managed component distribution in melodic-software/standards, which materializes a canonical per-lane caller into every target repo and keeps it current through the ordinary sync PR. Where a repo's caller is sync-managed, change it at the component source and let the sync carry it to every target — never edit the materialized caller in the target repo.

Standalone gate checks — shared adoption contract

semantic-pr.yml, pr-issue-linkage.yml, and do-not-merge-gate.yml are standalone required checks rather than ci-status lanes, and share one adoption shape. The emitted check context is <caller job> / <reusable job>, so a ruleset must require the doubled name (pr-title / pr-title, not bare pr-title) — and only after the caller is merged and emitting the check, or open PRs block on a check that never runs. Rulesets are governed via github-iac.

Each canonical caller triggers on pull_request_target, which runs the base-branch definition — a head-branch edit to the caller cannot bypass the gate. That is safe for all three because each reads PR metadata only and checks out no head code; the per-workflow entry above names which metadata. Under pull_request_target github.ref is the base branch, so the concurrency group keys on github.event.pull_request.number (falling back to github.ref for merge_group) — a github.ref key would collapse all PRs into one group and let one PR's run cancel another's required check.

merge_group is required on any repo with a merge queue: the queue gates on the check, and without the trigger that required check never reports and the queue deadlocks. It is inert where no queue exists.

Triage: fleet-wide single-workflow failure spikes

Before attributing a sudden, fleet-wide spike of failures in one reusable workflow to infrastructure flake, check that reusable's commit history and its job's runner routing (runs-on:, and whether it traces through select-runner.yml) first. A recent change that shells out to a CLI (gh, jq, …) combined with a routing change that moved callers onto a runner image without that CLI produces exactly this signature: many unrelated repos failing the same check at once, often with a low-level exit code rather than an application-level error.

Lesson from the 2026-07-18 do-not-merge spike (~191 failures across 6 repos, exit 127: gh: command not found): do-not-merge-gate.yml's label refetch ran gh api in a run: step on a runner the caller selects, and a routing shift landed it on a self-hosted image that does not ship the gh CLI. Fixed in #144 by porting to actions/github-script, which runs on the action's own bundled Node runtime and has no runner-image tooling dependency; the remaining gh-calling reusables were audited and ported the same way in #209. See REVIEW.md's "Always check" criterion for the standing rule this established.

Policy ownership and action inputs

Reusable rulesets are authored in melodic-software/standards. Consumers receive them through a tool-native package/reference or as managed files at the tool's normal root path. Config-driven actions default to those root paths and fail clearly when a required file is absent; an explicit input can select a repository-owned config where the tool supports customization.

Comment hygiene is the intentional CI-only exception: its default policy ships inside the action and is resolved through $GITHUB_ACTION_PATH, so consumers do not need another repository file. patterns-file accepts a complete replacement for repositories with a genuinely different policy. The small configs under fixtures/ exist only to exercise action and CI-check contracts; they are not mirrors of the standards catalog.

About

SHA-pinned, configurable CI composite actions (one per code-quality tool) and reusable workflows; consumers aggregate them into a local ci-status gate.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages