A passing exit code is not a clean tree. If the suite writes files and the next job still looks green, you are merging a leftover, not a result. Fail the job when git status --porcelain is not empty after tests, unless every leftover path is on an allowlist you reviewed by hand.
Put that assertion on the merge path. A review comment will not stop an unprotected green check. The workflow that runs the suite should also run the status hook, and that job should be required on the branch you merge to.
The failure you are blocking
Reused workspaces fail quietly. A test drops a recording, rewrites a fixture, or leaves a dotenv file in the tree. The process exits zero. The following run sees those files and skips setup, so the badge stays green for a stale reason.
You do not fix that with a longer retry. You fix it with a post-suite hook that reads git status. The hook does not judge test quality. It only refuses success when the worktree changed outside a short allowlist.
Every snippet below is an unexecuted example. Copy it onto a branch and run it yourself before you protect the branch. Nothing here is a measured production result.
Step 1: Hash fixtures before the suite starts
Record a digest of the fixture directory before tests run. You want that line in the log when a later step reports a dirty tree. A one-line digest is enough. You do not need a database.
#!/usr/bin/env bash
# scripts/ci/hash-fixtures.sh
# Unexecuted example. Confirm the root before you depend on it.
set -euo pipefail
root="${1:-tests/fixtures}"
if [[ ! -d "$root" ]]; then
echo "fixture root missing: $root" >&2
exit 2
fi
find "$root" -type f -print0 \
| sort -z \
| xargs -0 sha256sum \
| sha256sum \
| awk '{print $1}'
Print it before the suite, then keep the root narrow. Hashing the whole repository will move for reasons that are not fixture drift, and the log line stops being useful.
echo "fixture_sha=$(bash scripts/ci/hash-fixtures.sh tests/fixtures)"
Step 2: Check out a wiped workspace
A runner label is not an isolation boundary. You still wipe the checkout, and you do not restore a cache into tracked paths on this job. Shared caches are a separate policy. Leave them out until you have one.
# .github/workflows/clean-tree.yml
# Unexecuted example. Action versions are placeholders, not a run report.
name: clean-tree
on:
pull_request:
concurrency:
group: clean-tree-${{ github.ref }}
cancel-in-progress: true
jobs:
tests-then-status:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
clean: true
fetch-depth: 1
- name: Hash fixtures before the suite
run: echo "fixture_sha=$(bash scripts/ci/hash-fixtures.sh tests/fixtures)"
- name: Run the suite
run: bash scripts/ci/run-suite.sh
- name: Assert a clean tree
run: bash scripts/ci/assert-clean-tree.sh ci/clean-allowlist.txt
ubuntu-latest only names an image channel. It does not promise hardware, region, or how long the job may run. If your organization already pins runner images, keep that pin. The concurrency block cancels a superseded push on the same ref so two runs do not tell two workspace stories for one branch.
Step 3: Fail closed on porcelain
The allowlist is for reporter files you will never commit. It is not a pardon for new fixtures. Keep it short, and review every line as if it were a production ignore rule.
# ci/clean-allowlist.txt
coverage/cobertura.xml
test-results/junit.xml
#!/usr/bin/env bash
# scripts/ci/assert-clean-tree.sh
# Unexecuted example.
set -euo pipefail
allowlist="${1:-ci/clean-allowlist.txt}"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
git status --porcelain=v1 --untracked-files=all > "$tmp"
if [[ ! -s "$tmp" ]]; then
echo "clean tree"
exit 0
fi
fail=0
while IFS= read -r line; do
path="${line:3}"
if [[ "$path" == *" -> "* ]]; then
path="${path##* -> }"
fi
if [[ -f "$allowlist" ]] && grep -Fxq -- "$path" "$allowlist"; then
echo "allowed: $path"
continue
fi
echo "dirty: $line" >&2
fail=1
done < "$tmp"
if [[ "$fail" -ne 0 ]]; then
echo "tree dirty after tests; refusing success" >&2
exit 1
fi
echo "only allowlisted leftovers"
The suite must not be able to rewrite the gate and then pass. Hash the script and the allowlist before tests, and compare after. If the compare fails, the job stops before the status hook can trust a rewritten file.
before="$(sha256sum ci/clean-allowlist.txt scripts/ci/assert-clean-tree.sh | sha256sum | awk '{print $1}')"
bash scripts/ci/run-suite.sh
after="$(sha256sum ci/clean-allowlist.txt scripts/ci/assert-clean-tree.sh | sha256sum | awk '{print $1}')"
test "$before" = "$after"
bash scripts/ci/assert-clean-tree.sh ci/clean-allowlist.txt
That compare is part of this job, not a second system. A generated edit to the allowlist cannot bless itself.
Step 4: Classify leftovers and do not commit them
A dirty line needs a decision you can apply without debate in the workflow file. Do not add a step that commits leftovers back to the pull request. Upload the porcelain log if a human needs it later. That upload is not a commit, and it must not flip the job to success.
| Leftover | Likely cause | What you do |
|---|---|---|
| Untracked recording or snapshot | The suite stored a new answer | Fail. Review the file in the pull request. |
Modified file under tests/fixtures
|
A test mutated shared state | Fail. Restore the file and fix the test. |
| Dotenv file, token file, or key material | Setup wrote a secret into the tree | Fail. Scrub the log and rotate anything that printed. |
| Coverage or JUnit path on the allowlist | Reporter output | Keep it gitignored. Upload an artifact if you need it. |
| Edit to the gate script or the allowlist | The gate moved during the run | Fail. Those paths are not test output. |
Step 5: Require the job, including when the gate itself changes
An optional workflow is a note to yourself. Mark tests-then-status required on the protected branch. A faster lint job is not a substitute, because lint does not see a mutated fixture.
Path filters are the easy way to skip this by accident. If you use them, still run the workflow when scripts/ci/** or ci/clean-allowlist.txt changes. A pull request that only edits the hook has to execute the hook. Otherwise the branch rule trusts a check that never ran.
Require the check on the commit you intend to merge. A green run of an older head is a different tree. The protection rule should look at the latest push, not at a stale badge on a previous one.
Retries are the wrong flake control here. A second attempt can pass because the first attempt left files behind. Wipe the checkout, cancel superseded runs, and fail on leftovers. Do not wrap the status step in a retry.
Drafting the hook is not the same as trusting it
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Free model access can draft the shell script and a first allowlist. The free server option can run the example job once if you do not already have a runner. That is the fit. You were not given quotas, model names, hardware, duration, or a promise that the free option stays available, so do not encode any of those in the workflow or in branch protection.
Read the draft before the check becomes required. An allowlist entry of tests/fixtures/ hides the mutation you are trying to see. After one example run, keep branch protection in your own repository.
Who should skip this pattern
Do not use it if the required job is meant to generate files and commit them under its own signed-commit rule. Do not use it if the CI checkout is not a git worktree. Do not use it yet if a reporter writes a new unlisted path on every honest run.
Widen the allowlist only for paths you would gitignore forever. Never set the step to continue on error just to go green. A skipped failure is how a dirty tree becomes the next false pass.
A clean tree does not mean the assertions were right. Clock-dependent tests, order-dependent tests, and tests that only write under /tmp can still be wrong. Keep the suite you already have. This hook sits beside it.
clean: true covers the workspace attached to the checkout. It does not list every cache your organization restores. Until a cache policy exists, do not restore caches into this job.
Prove the script on your machine first
You can verify the example in a temporary repository. The first run should pass. The second should fail because of one untracked file. If both calls exit zero, stop and fix the script before you touch branch protection.
dir="$(mktemp -d)"
cd "$dir"
git init -q
git config user.email "dev@example.com"
git config user.name "dev"
mkdir -p tests/fixtures scripts/ci ci
echo "seed" > tests/fixtures/sample.txt
printf '%s\n' "coverage/cobertura.xml" > ci/clean-allowlist.txt
# Copy assert-clean-tree.sh to scripts/ci/ before this commit.
git add .
git commit -qm "seed"
bash scripts/ci/assert-clean-tree.sh ci/clean-allowlist.txt
echo "leak" > tests/fixtures/leak.txt
bash scripts/ci/assert-clean-tree.sh ci/clean-allowlist.txt || echo "expected failure: $?"
Expect clean tree on the first call. Expect a dirty: line and a non-zero status on the second. If that pair does not behave this way, the allowlist is too wide or you ran a different file.
The local pair is the artifact. The workflow only wraps it. After you require the job, green means the suite exited zero and the worktree was empty enough to merge.
Top comments (0)