DEV Community

Emery Lin
Emery Lin

Posted on

Assert a Clean Worktree After Tests, Then Require That Job

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}'
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
#!/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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: $?"
Enter fullscreen mode Exit fullscreen mode

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)