Fail a pull request that breaks an ADR
Add a GitHub Actions workflow that runs mneme check on every added or modified text file in the pull request, each with its real path. A FAIL exits 2 and fails the check; mark the check as required in branch protection and that blocks the merge. The workflow also re-imports your ADRs, so a change to an accepted ADR that nobody re-imported fails the check too.
You’ll end up with: a pull request check that fails when a change adds a direct database import under app/handlers/, plus a JSON verdict for each changed file.
Goal
Teams that aren't ready to put a hook in front of every agent can still catch a violation at merge. The workflow below runs on every pull request into main. It checks that the committed memory file is up to date with edited ADRs, then runs mneme check on each added or modified text file. If any file breaks a decision, the job fails and the pull request shows a failed check.
Prerequisites
- A GitHub repository with ADR-012 in
docs/adr/, and.mneme/project_memory.jsoncommitted. Guide 1 creates both. - Permission to add workflows under
.github/workflows/. To block merging, mark the check as required in your branch protection rules.
Configure
Save this as .github/workflows/mneme-decisions.yml:
name: Architecture decisions
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
mneme:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Mneme
run: python -m pip install "mneme-hq==0.9.2"
- name: Check the memory file matches the ADRs
run: |
mneme adr import docs/adr --memory .mneme/project_memory.json --apply --update-existing
git diff --exit-code -- .mneme/project_memory.json
- name: Check changed files against decisions
shell: bash
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -uo pipefail
# Added and modified files, one per line as "added<TAB>deleted<TAB>path".
# Binary files show "-" counts; mneme check reads text only.
changes=$(git diff --numstat --no-renames --diff-filter=AM "origin/$BASE_REF...HEAD") || exit 2
worst=0
mkdir -p mneme-results
while IFS=$'\t' read -r added _ f; do
[ -n "$f" ] || continue
if [ "$added" = "-" ]; then echo "skip $f (binary)"; continue; fi
out="mneme-results/$(printf '%s' "$f" | tr '/' '_').json"
mneme check --memory .mneme/project_memory.json \
--input "$f" --target-path "$f" \
--query "PR change: $f" --json > "$out"
code=$?
echo "exit=$code $f"
if [ "$code" -gt "$worst" ]; then worst=$code; fi
done <<< "$changes"
exit "$worst"
- name: Keep the verdicts
if: always()
uses: actions/upload-artifact@v4
with:
name: mneme-verdicts
path: mneme-results/
fetch-depth: 0fetches the history the workflow needs to compare the pull request againstmain.- The second step re-imports the ADRs and fails if that changes the memory file, which means someone edited an ADR without re-importing it. Ignore the conflicts note the import prints; the step passes when
git diffexits 0. - That step catches edited ADRs only. A deprecated, superseded or deleted ADR leaves its decision in the memory file until you remove it.
--target-path "$f"tells Mneme where each file lives. Path-scoped rules like ADR-012 depend on it.- Binary files (images, archives) are skipped, because
mneme checkreads text only. Renamed files count as added. - The loop keeps the worst exit code, so a passing file checked later can't hide an earlier failure. If
git diffitself fails, the step exits 2 instead of passing with nothing checked. - The last step uploads every JSON verdict, even when the check fails.
Run
Open a pull request that adds three files: a handler with the forbidden import, app/handlers/invoices.py; a repository file that is allowed to import the client, app/repositories/invoices.py; and an image, app/static/logo.png. The handler:
from app.db.client import get_connection
def list_invoices(customer_id: str):
conn = get_connection()
return conn.execute("SELECT * FROM invoices WHERE customer_id = ?", (customer_id,))
The workflow starts on its own. We tested it by running the workflow's exact run: scripts against a local copy of that pull request, not on GitHub's runners.
Expected result
The memory check passes. The decision check prints one line per changed file and the job exits with code 2:
exit=2 app/handlers/invoices.py exit=0 app/repositories/invoices.py skip app/static/logo.png (binary)
The handler fails. The repository file passes, because ADR-012 applies only under app/handlers/. The image is skipped.
Evidence
Download the mneme-verdicts artifact from the workflow run. It holds one JSON verdict per changed file. The one for the handler names the decision, the matched text and the path selector:
{
"schema": "mneme.check/v1",
"verdict": "FAIL",
"mode": "strict",
"evaluation_complete": true,
"applicability": [
{
"decision_id": "ADR-012",
"rule_type": "FORBID_LITERAL",
"rule_value": "from app.db.client import",
"rule_index": 0,
"path_scoped": true,
"input_path": "app/handlers/invoices.py",
"outcome": "APPLIED",
"selector": "app/handlers/**",
"reason": "an include selector matched"
}
],
"violations": [
{
"decision_id": "ADR-012",
"decision_text": "Request handlers do not import the database client",
"severity": "FAIL",
"rule": "from app.db.client import",
"trigger": "from app.db.client import",
"kind": "typed_rule",
"rule_type": "FORBID_LITERAL",
"input_path": "app/handlers/invoices.py",
"selector": "app/handlers/**"
}
],
"freshness": []
}
Trust a verdict only when evaluation_complete is true. An evaluation that can't complete reports INCOMPLETE and exits 2, so it fails the job instead of passing it.
Troubleshooting
The memory check fails after an ADR edit
The ADR changed but the committed memory file didn't, so git diff --exit-code exits 1. Re-import locally and commit the result. If it fails on every pull request, check the ADRs’ line endings: the memory file records a hash of each ADR’s bytes, so add docs/adr/*.md text eol=lf to .gitattributes.
mneme adr import docs/adr --memory .mneme/project_memory.json --apply --update-existing
The check step fails before checking any file
The step exits 2 when git diff can't compare against origin/$BASE_REF, usually because the checkout is shallow. Keep fetch-depth: 0 on the checkout step.
Every file passes, even one with the import
The --target-path flag is missing. Without it, Mneme can't tell which path-scoped rules apply. Guide 1 shows the output.
You want to see findings without failing pull requests yet
Add --mode warn to the mneme check line. Mneme still prints every verdict, but exits 0:
mneme check --memory .mneme/project_memory.json \ --input app/handlers/invoices.py --target-path app/handlers/invoices.py \ --query "PR change: app/handlers/invoices.py" --mode warn
FAIL [ADR-012] FORBID_LITERAL "from app.db.client import" -- trigger: from app.db.client import
Request handlers do not import the database client
path: app/handlers/invoices.py via app/handlers/**
PATH APPLIED [ADR-012] app/handlers/invoices.py via app/handlers/** -- an include selector matched
Result: FAIL
Remove the flag once the findings look right.
Limits
- The check reads each changed file whole. A file that already broke ADR-012 before the pull request fails as soon as someone touches it.
- Deleted files aren't checked, because there is nothing left to read.
- The rule matches exact text. See Guide 1 for the import forms it misses.
- The workflow syntax is GitHub-only. The same loop can run in GitLab CI with
$CI_MERGE_REQUEST_DIFF_BASE_SHAas the base, but we haven't tested that.