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:
- A path lock:
.github/workflows/**is CODEOWNERS-protected. - A dry-run: lint plus a local execution attempt for every changed workflow.
- Fixtures: a matrix file that names the jobs you added and the inputs they expect.
- 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
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
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
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
Rules you can enforce in code:
- Every job id in a changed workflow must appear in
.github/workflow-fixtures/. -
secrets_expectedmust be a subset of secrets referenced by that job. -
local_commandmust be the command the guard actually ran. - A job that lists secrets cannot use
pull_request_targetwithout an explicitprivilege: elevatedflag 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"
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())
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
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 }}
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)