DEV Community

Emery Lin
Emery Lin

Posted on

Status Checks Don't Review the Workflow Files They Run

A green check means the workflows already on the branch executed. It does not mean the YAML you just changed is safe to become the next default-branch runner. Treat .github/workflows/** as production code: dry-run it, attach fixtures for every new job, then attach a merge receipt before you click merge.

Status checks are a report about a run. Workflow files are the machine that will run on every later push. Those are different objects. If an agent rewrites a job, retargets a secret, or swaps pull_request for pull_request_target, the old green check is the wrong signal.

This article is a local-first merge path for CI YAML. You keep the remote check. You stop treating it as a review of the workflow files themselves.

The gap you actually have

GitHub (and most CI products) evaluate the workflows that exist on the head SHA. They do not certify that those files are well-formed, secret-safe, or equivalent to what you tested last week. A passing unit job can sit next to a broken deploy.yml you never executed.

Agent-written YAML makes the gap wider. The model will invent a runner label, a context, or an environment name that looks plausible. Your app tests still pass. The next default-branch push is when the fiction becomes a privileged job.

You need a gate that lives outside the files under edit. If the only required check is defined in the YAML being rewritten, a broken edit can delete the gate.

What “good” means before merge

Use four artifacts, not another emoji reaction on the PR:

  1. A path lock: .github/workflows/** is CODEOWNERS-protected.
  2. A dry-run: lint plus a local execution attempt for every changed workflow.
  3. Fixtures: a matrix file that names the jobs you added and the inputs they expect.
  4. A merge receipt: a committed YAML document the remote check verifies, signed by the local guard.

The receipt is the merge ticket. The green check is only evidence that the receipt’s commands ran.

Step 1: Freeze the protected surface

Put ownership on the directory that can mint secrets and runners. One reviewer is enough if that reviewer is not the author of the YAML.

# .github/CODEOWNERS
.github/workflows/    @your-org/ci-maintainers
.github/merge-receipt.schema.json  @your-org/ci-maintainers
scripts/workflow_merge_guard.py    @your-org/ci-maintainers
Enter fullscreen mode Exit fullscreen mode

Keep the guard script and the schema off the path an agent casually rewrites. If a PR touches both a workflow and the guard, stop. That combination is a policy change, not a feature branch.

Add a local hook so the rule fires before the remote ever sees the YAML.

# .git/hooks/pre-push  (install via core.hooksPath)
#!/usr/bin/env bash
set -euo pipefail
python3 scripts/workflow_merge_guard.py --mode pre-push
Enter fullscreen mode Exit fullscreen mode

Remote enforcement without a local hook is late. Local enforcement without a remote check is optional. You want both.

Step 2: Dry-run the YAML, not the app

Install two tools that do not need your cloud minutes. actionlint catches syntax, undefined actions, and impossible if: expressions. nektos/act attempts a local run. Neither is GitHub-hosted Actions. Both beat merging on hope.

# Install once. Pin versions in your team doc.
# actionlint: https://github.com/rhysd/actionlint
# act:        https://github.com/nektos/act

changed=$(git diff --name-only origin/main...HEAD -- '.github/workflows')

if [ -z "$changed" ]; then
  echo "no workflow files in the diff"
  exit 0
fi

actionlint $changed

for f in $changed; do
  # List jobs; fail closed if the file does not parse.
  act --list --workflows "$f"
done
Enter fullscreen mode Exit fullscreen mode

If act cannot pull an image, that is a finding, not a skip. Record it on the receipt as dry_run: blocked and do not merge. A workflow that only runs on a label you do not own is untested YAML.

Step 3: Require fixtures for every new job

A new job without a named fixture is an untested production entrypoint. You do not need a full end-to-end deploy. You need a documented input set and a command that exercises the job graph.

Keep the contract next to the workflow, not in a chat log.

# .github/workflow-fixtures/ci-unit.yaml
workflow: ci.yml
job: unit
event: pull_request
inputs:
  python_version: "3.12"
secrets_expected: []
local_command: |
  act pull_request -W .github/workflows/ci.yml -j unit \
    --input python_version=3.12
Enter fullscreen mode Exit fullscreen mode

Rules you can enforce in code:

  1. Every job id in a changed workflow must appear in .github/workflow-fixtures/.
  2. secrets_expected must be a subset of secrets referenced by that job.
  3. local_command must be the command the guard actually ran.
  4. A job that lists secrets cannot use pull_request_target without an explicit privilege: elevated flag and a human owner.

That last rule is the one agents miss. pull_request_target plus a checkout of untrusted code is how a green PR becomes a secret leak. The fixture file makes the privilege visible.

Step 4: Write a merge receipt the remote can verify

The receipt is small on purpose. If it needs a novel, your change is too large.

# .github/merge-receipt.yml
schema: merge-receipt/v1
pr_head: "${GIT_SHA}"
protected_paths:
  - .github/workflows/
changed_workflows:
  - path: .github/workflows/ci.yml
    jobs: [unit, lint]
    privilege: unprivileged
dry_run:
  actionlint: pass
  act: pass
  notes: "act used the pull_request event; no secrets injected"
fixtures:
  - .github/workflow-fixtures/ci-unit.yaml
  - .github/workflow-fixtures/ci-lint.yaml
flake_ledger: .github/flake-ledger.json
generated_by: human   # or "agent" — see below
owner: "@ci-maintainers"
Enter fullscreen mode Exit fullscreen mode

generated_by: agent is not a slur. It is a risk label. Agent YAML gets the same dry-run plus a required human owner. It does not get a faster merge.

Here is a guard you can run locally and in CI. It is a proposal: read it, pin the schema, then wire it to your hooks.

#!/usr/bin/env python3
"""workflow_merge_guard.py — fail closed on workflow diffs without a receipt."""
from __future__ import annotations

import argparse, json, subprocess, sys
from pathlib import Path

import yaml  # PyYAML; pin it in requirements-dev.txt

ROOT = Path(__file__).resolve().parents[1]
RECEIPT = ROOT / ".github" / "merge-receipt.yml"
FIXTURE_DIR = ROOT / ".github" / "workflow-fixtures"
WF_DIR = ".github/workflows"


def git_changed(against: str) -> list[str]:
    out = subprocess.check_output(
        ["git", "diff", "--name-only", f"{against}...HEAD", "--", WF_DIR],
        text=True,
    )
    return [line for line in out.splitlines() if line]


def load_receipt() -> dict:
    if not RECEIPT.exists():
        sys.exit("missing .github/merge-receipt.yml")
    data = yaml.safe_load(RECEIPT.read_text()) or {}
    if data.get("schema") != "merge-receipt/v1":
        sys.exit("unsupported merge-receipt schema")
    return data


def job_ids(path: Path) -> set[str]:
    doc = yaml.safe_load(path.read_text()) or {}
    jobs = doc.get("jobs") or {}
    if not isinstance(jobs, dict):
        sys.exit(f"{path}: jobs must be a mapping")
    return set(jobs)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--mode", choices=["pre-push", "ci"], required=True)
    parser.add_argument("--against", default="origin/main")
    args = parser.parse_args()

    changed = git_changed(args.against)
    if not changed:
        print("workflow_merge_guard: no workflow diff")
        return 0

    receipt = load_receipt()
    listed = {row["path"] for row in receipt.get("changed_workflows", [])}
    missing = set(changed) - listed
    if missing:
        sys.exit(f"receipt missing workflows: {sorted(missing)}")

    for row in receipt["changed_workflows"]:
        path = ROOT / row["path"]
        expected = set(row.get("jobs") or [])
        actual = job_ids(path)
        if expected != actual:
            sys.exit(f"{path}: receipt jobs {expected} != yaml jobs {actual}")
        if row.get("privilege") == "elevated" and receipt.get("generated_by") == "agent":
            sys.exit("agent-generated elevated workflows are blocked")

    fixture_files = {ROOT / p for p in receipt.get("fixtures", [])}
    if not fixture_files:
        sys.exit("receipt lists no fixtures")
    for f in fixture_files:
        if not f.exists():
            sys.exit(f"missing fixture {f}")

    dry = receipt.get("dry_run") or {}
    if dry.get("actionlint") != "pass" or dry.get("act") != "pass":
        sys.exit("dry_run is not pass/pass; refuse merge")

    print(f"workflow_merge_guard: {args.mode} ok for {changed}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run it the same way in both places. Drift between local and CI is how people start ignoring the hook.

python3 scripts/workflow_merge_guard.py --mode pre-push --against origin/main
python3 scripts/workflow_merge_guard.py --mode ci --against origin/main
Enter fullscreen mode Exit fullscreen mode

Step 5: Enforce the receipt from a file you rarely touch

Put the remote job in a workflow that is not the one being rewritten this week. Give it a stable name. Mark that name required on the default branch. See GitHub’s branch protection docs for the exact UI; the important part is the check name, not the file name.

# .github/workflows/merge-receipt.yml
name: merge-receipt
on:
  pull_request:
    paths:
      - ".github/workflows/**"
      - ".github/merge-receipt.yml"
      - ".github/workflow-fixtures/**"
      - "scripts/workflow_merge_guard.py"

jobs:
  verify-receipt:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pyyaml
      - run: python3 scripts/workflow_merge_guard.py --mode ci --against origin/${{ github.base_ref }}
Enter fullscreen mode Exit fullscreen mode

If a PR deletes this file, CODEOWNERS plus a required review should stop it. If your org supports required workflows at the organization level, prefer that. A check defined only in a deletable file is not a control.

Where a coding assistant is allowed to help

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The assistant is a stub generator, not a merge authority. You can point a free-tier coding model at a failing actionlint log and ask for a fixture file that names the new jobs. You still run the guard. You still own generated_by.

MonkeyCode is an open-source coding assistant with free model access and a free server option. That combination is useful when you want a scratch box to draft .github/workflow-fixtures/*.yaml or to summarize why act --list rejected a file. It is not useful as a replacement for actionlint, act, CODEOWNERS, or the receipt.

If the model proposes pull_request_target, a new environment:, or a secret that is not in secrets_expected, discard the patch. The receipt’s generated_by: agent field exists so the next reviewer does not have to guess.

Decision table

Diff contains Dry-run Fixtures generated_by Merge?
App code only n/a n/a any Existing app CI decides
Workflow YAML, unprivileged jobs actionlint + act pass One file per new job human or agent Yes, with receipt
Workflow YAML, secrets or environment: pass, and secrets listed Required human Yes, owner required
Workflow YAML, pull_request_target or elevated pass Required human only Yes only with privilege: elevated
Workflow YAML + edits to the guard/schema anything anything anything No — split the PR
Dry-run blocked (missing runner/image) fail present any No

Print this table in the PR template. Reviewers merge faster when the exceptions are written down.

Limitations

actionlint does not execute jobs. act is not GitHub-hosted Actions: container images, service names, and GITHUB_TOKEN permissions differ. A local pass is necessary and still incomplete.

The receipt is a file. Anyone who can commit to the branch can write dry_run: pass. The hook and the required check exist to make that lie expensive, not impossible. If you skip core.hooksPath, you skipped the control.

This path does not classify flaky tests and it does not parse JUnit. It only answers one question: may these workflow files land?

Free model access and a free server do not give you a replica of GitHub’s hosted runners. Do not send workflows that embed production secrets to any hosted assistant. Redact. Then dry-run on a machine you control.

Who should not use this

Skip the receipt if you have no default-branch protection and no one will read CODEOWNERS. A YAML file nobody enforces is documentation.

Skip agent-assisted fixture generation if your workflows are the secret boundary and your policy forbids leaving the building. Generate stubs on paper. Run act on an offline runner.

Skip act as a merge oracle if your jobs are macOS-only, billed runners, or OIDC-to-cloud deploys that cannot be faked locally. Keep actionlint and the privilege table. Replace the act field with a documented staging workflow that is not the default branch.

The merge button is not a linter. Put the dry-run and the receipt in front of .github/workflows, and let the green check mean what it actually means: the current machine ran. Not that the next one is safe.

Top comments (0)