How-to guide

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.

Tested with mneme-hq 0.9.2 · GitHub Actions

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.json committed. 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: 0 fetches the history the workflow needs to compare the pull request against main.
  • 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 diff exits 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 check reads 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 diff itself 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_SHA as the base, but we haven't tested that.

Next