CI that shows on every pull request keeps reviews honest. The workflow YAML in the repo is that contract, versioned with the code it checks.

YAML is the contract

The workflow YAML says both when something runs and with what rights, so it is the whole contract rather than just a recipe. A on: block names the triggers, a permissions: block pins what the workflow can touch, and a jobs: section declares the runners and steps. Because on is read before any job runs, a workflow is also where you decide that a pull_request must pass tests to be mergeable or a schedule can run it unsupervised. Version the file with the code it checks, so a branch’s CI always reflects the branch’s own contract.

Jobs and steps run in order

Jobs default to running in parallel, while steps inside a job run sequentially, so the graph you get is the combination of those two rules. A failing step makes the rest of its job conditional; whether later jobs still run is controlled by needs/if, and by the job-level concurrency and matrix settings. This ordering is what turns a workflow into a gate that blocks a merge on red, yet it is fully readable from the file. The practical habit is to keep the fast checks early and the expensive ones downstream, so the gate fails cheap before it fails slowly.

Secrets stay close to the runner

Secrets are injected on the runner, scoped to the workflow, and never printed, but a workflow with broad permissions can still read the repository, so the smallest effective grant is the safety mile. Give each job the permissions it needs instead of the default wider set, and scope environments so secrets reach only the jobs that truly run there. Validate the effective permissions the way you would a production role: a workflow is code that runs with your credentials on every trigger, so a malicious or outdated entry is a long-term credential you are checking in. Rotate anything that must live outside the branch.

When it breaks

A green workflow means nothing if branch protection does not require it. The classic failure is renaming the gate job from test to verify while the rule still waits for the old name: requests block forever on a check that never reports, or an administrator allows bypass and the gate becomes decoration. Fork requests add a second variant: secrets stay read-only, a job needing a deploy token is skipped, and a queue that treats skipped as passed ships untested code. Audit the required list after every rename.

The other failures look like flakes until you read the file. A cache keyed only on runner.os restores last week’s node_modules after the lockfile changed, so green means stale, and a cache written by a fork request can poison main when keys collide. Secrets leak quietly: set -x, verbose curl, or echo ${{ secrets.TOKEN }} prints the value into logs every reader keeps. Two pushes sharing one concurrency group cancel each other, the deploy job dies halfway, and the retry assumes the first run finished.

Check it quickly

Save the following as gate_check.py in a scratch directory and run python3 gate_check.py. It uses only the standard library and writes no files. The bad sample is a negative control, not a template.

"""Minimal gate check for a GitHub Actions workflow (standard library only)."""
import re

GOOD = """name: gate
on:
  pull_request:
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - run: npm test
  gate:
    needs: [test]
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    permissions: {}
    steps:
      - run: echo gate ok
"""

BAD = """name: gate
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo ${{ secrets.DEPLOY_TOKEN }}
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - run: echo deploy
"""


def check_workflow(text):
    """Return a list of gate violations found in a workflow string."""
    errors = []
    if not re.search(r"(?m)^permissions\s*:", text):
        errors.append("missing top-level permissions: pin contents: read at the top")
    else:
        top = text.split("jobs:")[0] if "jobs:" in text else text
        if re.search(r"write-all|write", top):
            errors.append("top-level permissions too broad: use contents: read")
    if "jobs:" not in text:
        return errors + ["missing jobs: section"]
    jobs = {}
    current = None
    buf = []
    for line in text.split("jobs:", 1)[1].splitlines():
        head = re.match(r"^  ([A-Za-z0-9_-]+):\s*$", line)
        if head:
            if current:
                jobs[current] = "\n".join(buf)
            current = head.group(1)
            buf = [line]
        elif current is not None:
            buf.append(line)
    if current:
        jobs[current] = "\n".join(buf)
    if not jobs:
        return errors + ["no jobs found under jobs:"]
    if not any(re.search(r"(?m)^\s+needs\s*:", chunk) for chunk in jobs.values()):
        errors.append("missing needs: no job waits for tests, the gate cannot order checks")
    if not any(re.search(r"(?m)^\s+if\s*:", chunk) for chunk in jobs.values()):
        errors.append("missing if: no job guards on the event, costly work runs everywhere")
    for name, chunk in jobs.items():
        if not re.search(r"(?m)^\s+permissions\s*:", chunk):
            errors.append(f"job '{name}' missing permissions: grant each job least privilege")
        elif re.search(r"write-all|contents\s*:\s*write", chunk):
            errors.append(f"job '{name}' permissions too broad: keep contents: read or {{}}")
    for line in text.splitlines():
        if "secrets." in line and re.search(r"\becho\b|set\s+-x|curl\s+.*-v", line):
            errors.append(f"possible secret leak to logs: {line.strip()[:80]}")
            break
    return errors


def check(ok, message):
    if not ok:
        raise AssertionError(message)


def main():
    good = check_workflow(GOOD)
    bad = check_workflow(BAD)
    print(f"good workflow: {len(good)} problems")
    for item in good:
        print(f"  unexpected: {item}")
    print(f"bad workflow: {len(bad)} problems")
    for item in bad:
        print(f"  found: {item}")
    check(not good, f"good sample must pass, got: {good}")
    check(len(bad) >= 3, f"bad sample must fail visibly, got: {bad}")
    check(any("needs" in item for item in bad), "bad sample must expose missing needs")
    check(any("permissions" in item for item in bad), "bad sample must expose permissions")
    check(any("secret" in item for item in bad), "bad sample must expose secret leak")
    print("PASS: gate waits on tests, guards on the event, and stays least-privilege")


if __name__ == "__main__":
    main()

The bad sample fails the three gate rules at once. A missing top-level permissions block and a job without its own grant fail least privilege, and contents: write fails it explicitly. No job declares needs, so nothing waits for tests, and no job declares if, so costly work runs on every push. Echoing secrets.DEPLOY_TOKEN trips the leak check. The good sample passes because test stays read-only and gate waits on it behind an event guard.

The assertions passed with Python 3.12 on macOS. The fixture matches indentation with regular expressions, not a real YAML parser, so it misses anchors and reusable workflows. It also cannot see branch protection or secret rotation. Before deployment, check a real workflow and confirm the required name in the branch settings.

Further reading

Sources