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 a record file under docs/adr/ (the template's docs/adr/NNNN-*.md placeholder does not count), or the no-adr label is applied. redline init --no-adr turns the check off for the repository.
  • dependency-review — actions/dependency-review-action@v5; 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 · 357 lines · 17.7 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, block-blocker or
          block-high. `observe` reports and never blocks; 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.
          `require` refuses it; `warn` accepts it and says what is missing. `warn` was
          the default while the block was new (0.0.2), so nobody's open pull request
          was failed by a rule that did not exist when they opened it. That grace is
          over. It only decides anything at a blocking rung: below one, a failing
          process check does not stop a merge either way.
        type: string
        default: require

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")
          # A link to a record file, as a path or inside a URL. Any `docs/adr/`
          # counted before, and the pull request template's own
          # `docs/adr/NNNN-*.md` prompt is one, so every pull request opened from
          # the template passed this check without a record. The `*` is what
          # keeps the prompt out: a real file name has none.
          has_adr=$(jq -r '.body // ""' <<<"$pr" | grep -cE 'docs/adr/[A-Za-z0-9][A-Za-z0-9._-]*\.md' || 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@v7
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          # setup-node v5+ caches dependencies on its own when the repository's
          # package.json names npm as its packageManager. This job installs
          # nothing, so there is nothing to cache, and the caching step's lockfile
          # lookup is one more way for someone else's repository to fail the gate.
          package-manager-cache: false
      - 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: 'latest'
        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
          # Installed into a directory of its own and run from there, not with npx
          # from the checkout. npm reads the checkout's package.json before it
          # does anything, and one whose devEngines names pnpm, yarn or bun —
          # which `pnpm init` writes by default — makes npm 10.9+ refuse to run
          # npx at all, failing this job on every pull request. Installing
          # elsewhere also keeps the pull request's own lockfile and node_modules
          # out of the tool that judges it. Still run from the checkout, so it
          # reads this repository's .redline.json.
          npm install --prefix "$RUNNER_TEMP/redline" --no-save --no-audit --no-fund --ignore-scripts \
            "redlinegate@${REDLINE_CLI_VERSION}" > /dev/null
          "$RUNNER_TEMP/redline/node_modules/.bin/redline" 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@v7
      - uses: actions/dependency-review-action@v5
        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@v7
        with:
          fetch-depth: 0
      - name: Scan the PR range
        # Pinned to the commit behind tag v3.99.2 (2026-10-08). 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@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2
        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 }}
          # `latest`, deliberately: the gate tracks every release so new checks
          # reach every repository without a pin bump. The cost is that a publish
          # changes gate behaviour with no pull request here — a vendored copy
          # (`redline init --gate-source local`) is stamped with a fixed version
          # instead and is the option for a repository that needs that control.
          REDLINE_CLI_VERSION: 'latest'
        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."