redline-gate.yml

Reusable gate: checklist, ADR-for-big-diffs, dependency review, diff secret scan, label-aware aggregation. Active.

What this is

The reusable merge-readiness gate every onboarded GitHub repo requires to merge. Four checks aggregated into one required status.

How to onboard it

Installed in two places, once each. The reusable workflow is copied by hand into the org's .github repository as .github/workflows/redline-gate.yml — see Installation. The per-repo caller is written by redline init on every onboarded GitHub repo, so after the one-time org step no repository needs manual work.

  • Lives in: The org .github repo, at .github/workflows/redline-gate.yml — copied there once by hand (see Installation). Every onboarded repo's caller workflow (templates/redline.yml) references it by org path as <org>/.github/.github/workflows/redline-gate.yml@main.
  • Trigger: workflow_call, invoked by the calling repo's Redline workflow on every pull request.

How to use it

Nothing directly — it's called by the caller workflow your repo already has. If it fails, see The merge gate for what each check expects and how to satisfy or exempt it.

What a run does, in order:

  • checklist ("PR checklist") — passes only if the PR body has a ## Launch readiness section with every box ticked. Fails loudly, not silently, if the heading is missing entirely.
  • adr ("ADR required for significant changes") — passes if changed lines are at or below adr-diff-threshold (default 300), or the PR body links docs/adr/, or the no-adr label is applied.
  • dependency-review — actions/dependency-review-action@v4; fails at or above fail-on-dependency-severity (default high).
  • secrets ("Secret scan (diff)") — trufflehog, pinned to a commit SHA, scanning only the PR's diff range with --results=verified --fail; fails on any verified secret.
  • gate (aggregate, name: gate) — needs all four. dependency-review and secrets are hard-fail and never label-exemptible. checklist and adr are soft-fail: the redline-exempt or redline-sync label downgrades a failure there to a warning. The branch ruleset requires the check context redline-gate / gate — the caller job id plus this aggregate job's id.

Active — this is what redline init wires up on GitHub today.

Expected output

One required status check, redline-gate / gate, on every pull request. The four sub-checks report individually beside it. dependency-review comments its findings on the PR; the secret scan fails the check without echoing what it found. A soft-fail check downgraded by the redline-exempt or redline-sync label reports as a warning rather than a failure, and the aggregate still passes.

How to edit it

  1. Edit the YAML in workflows/ or .github/workflows/workflows/ holds files destined for other repositories; .github/workflows/ is this repository's own CI. The two are not interchangeable — check where this one lives before editing.
  2. actionlintCI lints .github/workflows/*.yml, workflows/*.yml and templates/redline.yml together. workflows/ is pointed at explicitly because actionlint's own discovery would skip it.
  3. node scripts/check-pins.mjsIf you add a third-party action, pin it to a 40-character commit SHA with a trailing # vX.Y.Z comment. First-party actions/* are referenced by tag. The pin checker re-resolves the SHA against the tag the comment claims.
  4. node scripts/validate.mjsAsserts the workflow files the bundle depends on still exist, and that the gate's job ids still match the required check name derived from them.

The full file

workflows/redline-gate.yml · 333 lines · 15.8 KB
# Reusable readiness gate. Host in the org `.github` repo at
# .github/workflows/redline-gate.yml; repos call it from .github/workflows/redline.yml
# (see templates/redline.yml).
#
# IMPORTANT — required-status-check naming. A reusable workflow reports its check runs
# as "<caller job id> / <called job id>". templates/redline.yml names the caller job
# `redline-gate`, and the aggregate job below is `gate`, so the context a branch ruleset
# must require is exactly:
#
#     redline-gate / gate
#
# Changing either job id changes the required context and will silently block every PR
# in every onboarded repo. `npx redlinegate verify` checks the
# real name after onboarding.
name: Redline Gate

on:
  workflow_call:
    inputs:
      adr-diff-threshold:
        description: Changed lines above which an ADR link (or the no-adr label) is required
        type: number
        default: 300
      fail-on-dependency-severity:
        description: Minimum dependency-review severity that fails the gate
        type: string
        default: high
      soft-fail-labels:
        description: >-
          Comma-separated labels that downgrade the process checks (checklist, ADR) to
          warnings. Security checks are never downgraded.
        type: string
        default: redline-exempt,redline-sync
      rung:
        description: >-
          The enforcement rung this repository sits at: observe, warn, block-blocker or
          block-high. `observe` and `warn` report and never block; the block-* rungs stop a
          merge on a finding at or above their severity. Written by `redline init --rung`,
          which refuses a promotion the repository's recorded evidence does not support.
        type: string
        default: observe
      stand-down:
        description: >-
          Comma-separated gate jobs another tool in the calling repository already covers:
          `policy`, `dependencies`, `secrets`. A stood-down job is skipped and the
          aggregate below reads a skip as a pass. Written by `redline init` from the tools
          the repository DECLARED, never from detection alone — `dependencies` and
          `secrets` are the two jobs no label can waive, so a wrong guess here is the one
          that removes a security check nobody asked to remove. Empty runs every job.
        type: string
        default: ''
      exemption-enforcement:
        description: >-
          What a soft-fail label without a valid "## Redline exemption" block does.
          `warn` accepts it and says what is missing; `require` refuses it. Repos move
          from warn to require one standards version after the block was introduced, so
          nobody's open pull request is failed by a rule that did not exist when they
          opened it.
        type: string
        default: warn

permissions:
  contents: read
  pull-requests: write

jobs:
  checklist:
    name: PR checklist
    runs-on: ubuntu-latest
    steps:
      - name: Verify the launch-readiness checklist is complete
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
        run: |
          set -euo pipefail
          body=$(gh pr view "$PR" --repo "$REPO" --json body --jq '.body // ""')

          # Only the Launch readiness section is enforced. "Change type" is a pick-one
          # list, so requiring every box there would make the gate unpassable.
          section=$(printf '%s\n' "$body" | awk '
            /^##[[:space:]]+Launch readiness/ { inside = 1; next }
            /^##[[:space:]]/                  { inside = 0 }
            inside                            { print }
          ')

          if [[ -z "${section//[[:space:]]/}" ]]; then
            echo "::error::No '## Launch readiness' section in the PR description. Restore .github/pull_request_template.md and complete the checklist."
            exit 1
          fi

          unchecked=$(printf '%s\n' "$section" | grep -c '^[[:space:]]*- \[ \]' || true)
          if [[ "$unchecked" -gt 0 ]]; then
            printf '%s\n' "$section" | grep '^[[:space:]]*- \[ \]' || true
            echo "::error::$unchecked unchecked launch-readiness item(s). Tick each one, or delete the line and say why in the PR description."
            exit 1
          fi
          echo "Launch readiness complete."

  adr:
    name: ADR required for significant changes
    runs-on: ubuntu-latest
    steps:
      - name: Check diff size against ADR link
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
          THRESHOLD: ${{ inputs.adr-diff-threshold }}
        run: |
          set -euo pipefail
          pr=$(gh pr view "$PR" --repo "$REPO" --json additions,deletions,body,labels)
          changed=$(jq '.additions + .deletions' <<<"$pr")
          has_adr=$(jq -r '.body // ""' <<<"$pr" | grep -c 'docs/adr/' || true)
          exempt=$(jq -r '.labels[].name' <<<"$pr" | grep -cx 'no-adr' || true)
          echo "changed=$changed threshold=$THRESHOLD adr-links=$has_adr no-adr-label=$exempt"
          if [[ "$changed" -gt "$THRESHOLD" && "$has_adr" -eq 0 && "$exempt" -eq 0 ]]; then
            echo "::error::${changed} changed lines with no ADR link. Add docs/adr/NNNN-*.md and link it in the PR body, or apply the 'no-adr' label with a justification comment."
            exit 1
          fi

  policy:
    if: ${{ !contains(format(',{0},', inputs.stand-down), ',policy,') }}
    # The deterministic tier: the rules a checker can decide, evaluated with no
    # model call. They cost nothing to run and they cannot hallucinate, which is
    # the point — an author cannot argue with a model about whether the word TODO
    # appears on a line.
    #
    # Soft-fail, like the other process checks. The never-exemptible set is
    # exactly dependency-review and secrets and this does not join it: these are
    # code-standard findings, not a security boundary.
    name: Deterministic policy
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - name: Evaluate the deterministic rules
        env:
          BASE: ${{ github.event.pull_request.base.sha }}
          HEAD: ${{ github.event.pull_request.head.sha }}
          RUNG: ${{ inputs.rung }}
          REDLINE_CLI_VERSION: '0.0.3'
        run: |
          set -euo pipefail
          # Three dots: the diff against the merge base, not against the tip of
          # the base branch. Two dots would attribute every commit that landed on
          # main since this branch started to this pull request's author.
          git diff "${BASE}...${HEAD}" > /tmp/redline.diff
          # The rung decides the floor. Without passing it, block-high behaved
          # exactly like block-blocker — the strictest rung on the ladder did
          # nothing the one below it did not already do.
          case "$RUNG" in
            block-high) fail_on=HIGH ;;
            *) fail_on=BLOCKER ;;
          esac
          npx --yes "redlinegate@${REDLINE_CLI_VERSION}" policy \
            --diff-file /tmp/redline.diff --fail-on "$fail_on"

  dependency-review:
    if: ${{ !contains(format(',{0},', inputs.stand-down), ',dependencies,') }}
    name: Dependency review
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/dependency-review-action@v4
        with:
          fail-on-severity: ${{ inputs.fail-on-dependency-severity }}
          comment-summary-in-pr: on-failure

  secrets:
    if: ${{ !contains(format(',{0},', inputs.stand-down), ',secrets,') }}
    name: Secret scan (diff)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Scan the PR range
        # Pinned to the commit behind tag v3.97.1 (2026-08-24). This action runs with
        # access to the PR diff, so the ref must be immutable — a tag can be moved.
        # GitHub Actions does not allow expressions in a step `uses:`, so the pin is
        # reviewed here, in the org `.github` repo, which is the right place for it.
        # scripts/check-pins.mjs re-resolves it against the upstream tag in CI.
        uses: trufflesecurity/trufflehog@20652fbbdefffcdaa493a5bf57ab2ac6b1db715b # v3.97.1
        with:
          base: ${{ github.event.pull_request.base.sha }}
          head: ${{ github.event.pull_request.head.sha }}
          # verified only: `unknown` produces false positives, and this is a blocking
          # check. Unverified candidates are caught by GHAS secret scanning instead.
          #
          # No `--fail` here. The action's own entrypoint already passes it before
          # appending extra_args, and trufflehog rejects a repeated flag outright —
          # `flag 'fail' cannot be repeated` — so the scan never ran at all. This
          # job is outside the enforcement ladder and outside label exemption, so
          # that failure blocked every pull request in every onboarded repository
          # for a reason no author could act on or waive.
          extra_args: --results=verified

  gate:
    # Aggregate. The branch ruleset requires the check "redline-gate / gate".
    name: gate
    needs: [checklist, adr, policy, dependency-review, secrets]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Aggregate results
        env:
          RESULTS: ${{ toJSON(needs) }}
          LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
          SOFT_FAIL_LABELS: ${{ inputs.soft-fail-labels }}
          EXEMPTION_ENFORCEMENT: ${{ inputs.exemption-enforcement }}
          RUNG: ${{ inputs.rung }}
          # Through the environment, never interpolated into the script body:
          # `${{ github.event.pull_request.body }}` inline is a script injection
          # anyone who can open a pull request can use.
          PR_BODY: ${{ github.event.pull_request.body }}
          # Pinned, not @latest: the gate's behaviour must not change under a
          # repository because a release happened overnight.
          REDLINE_CLI_VERSION: '0.0.3'
        run: |
          set -euo pipefail

          # Security checks can never be downgraded by a label.
          HARD_JOBS='["dependency-review","secrets"]'

          # `skipped` is a pass. None of these jobs has `needs`, so the only thing
          # that skips one is its own stand-down guard above — a deliberate
          # instruction from the calling repository, not a job that fell over.
          # Without this the aggregate failed every repository that narrowed the
          # gate, which is the opposite of what narrowing it asks for.
          PASSED='["success","skipped"]'

          # `.value.result as $r` first: inside `$passed | index(...)` the input
          # is $passed, so referring to `.value.result` there indexes the array
          # with a string and jq exits 5 — which `set -e` turns into a gate that
          # fails every pull request.
          hard_failed=$(jq -r --argjson hard "$HARD_JOBS" --argjson passed "$PASSED" \
            'to_entries[] | .value.result as $r | select($passed | index($r) | not) | select(.key as $k | $hard | index($k)) | .key' <<<"$RESULTS")
          soft_failed=$(jq -r --argjson hard "$HARD_JOBS" --argjson passed "$PASSED" \
            'to_entries[] | .value.result as $r | select($passed | index($r) | not) | select(.key as $k | $hard | index($k) | not) | .key' <<<"$RESULTS")

          labelled=false
          for label in ${SOFT_FAIL_LABELS//,/ }; do
            if jq -e --arg l "$label" 'index($l)' <<<"$LABELS" >/dev/null; then
              labelled=true
              echo "Soft-fail label present: $label"
            fi
          done

          # A label alone records nothing: not who accepted the failing check, not
          # why, not until when. The pull request must also carry a
          # "## Redline exemption" block with a reason and an expiry, and the CLI
          # is what reads it — re-implementing that parse here would eventually
          # disagree with the audit that reads the same block later.
          #
          # The body is written to a file rather than passed as an argument: a
          # pull request body is attacker-controlled text full of backticks and
          # $(...), and interpolating it into a command is how one becomes a
          # command.
          exempt=false
          if [[ "$labelled" == true ]]; then
            printf '%s' "$PR_BODY" > /tmp/redline-pr-body.md
            # Every failing soft check must be covered, one at a time. Calling
            # exempt without --scope never evaluated the scope at all, so an
            # exemption written for `checklist` silently waived `adr` and the
            # deterministic `policy` job too — the field was recorded and ignored.
            exempt=true
            for check in $soft_failed; do
              if ! npx --yes "redlinegate@${REDLINE_CLI_VERSION}" exempt \
                   --body-file /tmp/redline-pr-body.md --scope "$check"; then
                exempt=false
                break
              fi
            done
            if [[ "$exempt" == true ]]; then
              :
            elif [[ "$EXEMPTION_ENFORCEMENT" != "require" ]]; then
              echo "::warning::This exemption has no valid \`## Redline exemption\` block. It is being accepted this time; once this repository moves to require, it will not be."
              exempt=true
            else
              echo "::error::A soft-fail label was applied without a valid \`## Redline exemption\` block (reason and expiry required)."
            fi
          fi

          {
            echo "### Redline gate"
            echo
            jq -r 'to_entries[] | "- \(.key): \(.value.result)"' <<<"$RESULTS"
          } >> "$GITHUB_STEP_SUMMARY"

          # The security floor is not on the ladder. Dependency review and the
          # secret scan block at every rung including observe: the ladder governs
          # how strictly a repository's own standards are enforced, not whether
          # the organisation's security minimum applies to it.
          if [[ -n "$hard_failed" ]]; then
            echo "::error::Redline gate failed on security checks (not label-exemptible, and not on the enforcement ladder): $hard_failed"
            exit 1
          fi

          # Below block-blocker the process checks report and do not block. The
          # findings are still produced, still commented, and still collected —
          # observe is not "off", it is "measured and not yet enforced", which is
          # the whole point of having a rung below blocking at all.
          # A rung this gate does not recognise is NOT enforcing. cli/config
          # reads an unrecognised value back as `observe` for the same reason: a
          # typo must never be able to make a repository stricter than anyone
          # chose, and the two halves disagreeing meant a typo blocked every pull
          # request here while the CLI reported the repository as observing.
          case "$RUNG" in
            block-blocker|block-high) enforcing=true ;;
            *) enforcing=false ;;
          esac

          if [[ -n "$soft_failed" ]]; then
            if [[ "$enforcing" != true ]]; then
              echo "::warning::Process checks failed: $soft_failed. This repository is at rung '$RUNG', which reports and does not block. The findings are recorded either way."
              echo "- **not enforced (rung: $RUNG):** $soft_failed" >> "$GITHUB_STEP_SUMMARY"
            elif [[ "$exempt" == true ]]; then
              echo "::warning::Process checks failed but a valid exemption is recorded: $soft_failed. A reviewer is accepting this deliberately, with a reason and an expiry."
              echo "- **exempted:** $soft_failed" >> "$GITHUB_STEP_SUMMARY"
            else
              echo "::error::Redline gate failed: $soft_failed"
              exit 1
            fi
          fi

          echo "Redline gate passed."