Skip to content

ci: add ADR governance compliance - #19

Open
dirvine wants to merge 2 commits into
mainfrom
ci/adr-governance-compliance
Open

ci: add ADR governance compliance#19
dirvine wants to merge 2 commits into
mainfrom
ci/adr-governance-compliance

Conversation

@dirvine

@dirvine dirvine commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds repository-local ADR governance and team-standard ADR tooling:

  • docs/adr/TEMPLATE.md with a structured ADR format
  • docs/adr/TOOLING.md with adrs, adr-kit, Codex/Claude Code/OpenCode/pi harness guidance
  • .adr-kit.yaml policy config
  • scripts/adr-governance.py validation for ADR format, required sections, status values, duplicate numbers, and immutable Accepted ADRs
  • .github/workflows/adr-governance.yml CI gate

For repos without existing ADRs, this also bootstraps ADR-0001: Adopt Architecture Decision Records.

Why

ADRs are part of our engineering safety system, especially now we use AI coding assistance. They preserve the reasoning, rejected alternatives, consequences, and validation behind architectural decisions. Accepted ADRs must not be rewritten; if a decision changes, we create a superseding ADR so the audit trail remains intact.

Review focus

  • Check the ADR template is suitable for this repository.
  • Check the immutable Accepted enforcement is strict enough.
  • Check the harness guidance matches how contributors use Codex, Claude Code, OpenCode, and other agents.

Test plan

  • Ran python3 scripts/adr-governance.py locally on the branch.

Greptile Summary

This PR introduces an ADR governance system: a Python validation script, a GitHub Actions CI gate, a policy config, an ADR template, and AI harness guidance. The central goal is to enforce that Accepted ADRs are immutable, required sections are present, and only humans can accept an ADR.

  • scripts/adr-governance.py validates filenames, required sections, status values, and duplicate numbers, and blocks edits to previously-Accepted ADRs on both PR and push events.
  • The push-event code path uses git rev-parse HEAD^@, which returns multiple lines for merge commits and silently empties the changed-file list, bypassing the immutability check on that path.
  • The ai_may_accept: false policy in .adr-kit.yaml is not enforced by the CI script — a new ADR can be created already marked Accepted without being blocked.

Confidence Score: 3/5

The governance intent is solid but the push-event immutability check has a real bypass for merge commits, and the ai_may_accept policy is declared but not enforced at the CI level.

The HEAD^@ bug means that anyone who lands a merge commit on main would have the immutability enforcement silently skipped. This is the most critical path the script is supposed to protect, and it fails on that path.

scripts/adr-governance.py — the base_ref() function and the immutability enforcement loop need attention before this gate can be trusted for push events.

Important Files Changed

Filename Overview
scripts/adr-governance.py Core governance script; contains a bug where HEAD^@ returns multiple SHAs for merge commits, silently bypassing the immutability check, plus an overly-broad STATUS_RE and a missing enforcement path for ai_may_accept: false.
.github/workflows/adr-governance.yml CI workflow triggers correctly on PR and push events with full history fetch; straightforward invocation of the governance script.
.adr-kit.yaml Policy config declaring allowed statuses, required sections, and immutability rules; consistent with the governance script's constants.
docs/adr/TEMPLATE.md ADR template with all required sections and AI-guidance notes; no ADR-0001 bootstrap file is present despite the PR description claiming one would be added.
docs/adr/TOOLING.md Tooling and AI harness guidance documentation; clear and accurate instructions for adrs, adr-kit, and AI coding agents.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[CI Trigger] --> B{GITHUB_BASE_REF set?}
    B -- Yes PR --> C[base = origin/BASE_REF]
    B -- No push --> D[git rev-parse HEAD^@]
    D -- single parent --> E[base = parent SHA]
    D -- merge commit --> F[multi-line SHA - git diff fails]
    C --> G[git diff name-only]
    E --> G
    F -.->|immutability check skipped| Z([CI passes incorrectly])
    G --> H[changed ADR files]
    H --> L[Immutability check]
    L -- old status Accepted --> M([CI fails])
    L -- not Accepted --> N([CI passes])
Loading
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
scripts/adr-governance.py:41
`HEAD^@` expands to *all* parents of HEAD. On a merge commit pushed directly to `main` this yields two or more SHAs separated by newlines, making `git diff --name-only "sha1
sha2...HEAD"` an invalid command. Both fallback branches in `changed_files_against_base` will raise and return `[]`, so `changed = []` and the immutability loop never runs — any edit to an Accepted ADR in a merge commit silently passes. Use `HEAD^` (first-parent only) to keep the intent in the comment and handle merge commits correctly.

```suggestion
        return run(["git", "rev-parse", "HEAD^"])
```

### Issue 2 of 4
scripts/adr-governance.py:23
The `STATUS_RE` pattern uses a loose `.*?Status.*?:` match, so it will trigger on any line in the ADR body that happens to contain "Status" — e.g., a Consequences bullet like `- Deployment Status: unchanged` or `- System status: degraded`. Since `re.search()` returns the first match, the correct frontmatter line wins when the header is near the top, but any ADR that discusses status in its body before the frontmatter would resolve to the wrong value. Anchoring the pattern more tightly to the frontmatter convention removes the ambiguity.

```suggestion
STATUS_RE = re.compile(r"(?im)^\s*[-*]\s*\*{0,2}Status\*{0,2}\s*:\s*(.+?)\s*$")
```

### Issue 3 of 4
scripts/adr-governance.py:99-110
**`ai_may_accept: false` not enforced by CI**

`.adr-kit.yaml` declares `human_acceptance_required: true` / `ai_may_accept: false`, but `adr-governance.py` only blocks *edits* to already-Accepted ADRs — it has no check that prevents a PR from changing an ADR's status from `Proposed` directly to `Accepted`. A contributor (or AI agent) can mark a brand-new ADR `Accepted` in the same PR that creates it, and this gate will pass.

### Issue 4 of 4
docs/adr/TEMPLATE.md:1-9
**ADR-0001 mentioned in PR description but not present**

The PR description states "For repos without existing ADRs, this also bootstraps `ADR-0001: Adopt Architecture Decision Records`", but no `docs/adr/ADR-0001-*.md` file is included in this PR. The governance script will run and report `ADR governance passed (0 ADR file(s) checked)` — a green CI gate with zero ADRs in the repository.

Reviews (1): Last reviewed commit: "ci: add ADR governance compliance" | Re-trigger Greptile

Greptile also left 4 inline comments on this PR.

@claude

claude Bot commented Jun 1, 2026

Copy link
Copy Markdown

Code Review: ci/adr-governance-compliance

Overall: A well-motivated PR that adds real governance infrastructure. The template and TOOLING docs are clear and the CI gate is a solid idea. There are a few correctness and robustness issues in adr-governance.py worth addressing before merge.


Overview

This PR adds:

  • A structured ADR template and tooling guide
  • .adr-kit.yaml policy config
  • scripts/adr-governance.py — validates filenames, required sections, status values, and immutability of Accepted ADRs
  • .github/workflows/adr-governance.yml — CI gate on docs/adr/** changes

The design intent is sound. ADR immutability enforcement via CI is exactly the right place for it.


Bugs / Correctness

1. base_ref() returns a multi-line string on merge commits (scripts/adr-governance.py:37–43)

return run(["git", "rev-parse", "HEAD^@"])

On a merge commit, HEAD^@ expands to all parents — the output is multiple SHAs separated by newlines. run() strips but doesn't split, so base becomes "sha1\nsha2". This produces a malformed argument to git diff --name-only "sha1\nsha2"...HEAD, both branches of changed_files_against_base then throw, and the function returns []. The immutability check silently passes with nothing to check.

Fix: use HEAD^ (first parent only) to get the last merge base, or parse just the first line:

try:
    sha = run(["git", "rev-parse", "HEAD^"])
    return sha
except Exception:
    return None

2. STATUS_RE can match "Status" in document body, not just the header (scripts/adr-governance.py:24)

STATUS_RE = re.compile(r"(?im)^\s*(?:[-*]\s*)?.*?Status.*?:\s*(.+?)\s*$")

The .*?Status.*?: pattern matches any line containing "Status:" — including prose in the Context or Consequences sections (e.g., "The current Status of the TLS session:"). The first match wins, so a body mention before the metadata field could silently capture the wrong value.

Tighten to match the expected metadata line format specifically:

STATUS_RE = re.compile(r"(?m)^\s*[-*]?\s*\*{0,2}[Ss]tatus\*{0,2}:\s*\*{0,2}(.+?)\*{0,2}\s*$")

3. Silent failure when git diff fails on push events

In changed_files_against_base, if both git diff forms raise (e.g., because base is malformed as described above), the function returns []. This makes files_to_validate an empty list, meaning no section or status checks run. The script then prints "ADR governance passed" — a false green. At minimum the script should warn when it falls back to no-op mode:

print("WARNING: could not determine changed files; skipping per-file validation.", file=sys.stderr)

Missing from PR Description

4. ADR-0001 is not in the diff

The PR body says:

For repos without existing ADRs, this also bootstraps ADR-0001: Adopt Architecture Decision Records.

No such file appears in the diff. Either the description is stale or the file was dropped. If bootstrapping ADR-0001 is still the intent, it should be included and must itself pass the governance script. If not, the PR description should be corrected.


Minor Issues

5. PEP 8: missing blank lines before if __name__ == "__main__" block

The main() function ends and if __name__ == "__main__": follows with only one blank line. PEP 8 requires two blank lines between top-level definitions and the module-level guard. Easy fix.

6. adrs crate availability

TOOLING.md recommends cargo install adrs. This is not a well-known published crate for ADR management — contributors searching crates.io may not find what is expected. Consider verifying the exact crate name or linking to the repository, or replacing with a known tool (e.g., adr-tools, log4brains).

7. CI workflow pins no Python version, but that's fine

The script uses only stdlib + from __future__ import annotations, so it's compatible with Python 3.7+ and the default python3 on ubuntu-latest is sufficient. No action needed, just noting this is safe as-is.


What's Good

  • fetch-depth: 0 in the workflow is correct — the immutability check needs full history.
  • The human_acceptance_required: true / ai_may_accept: false fields in .adr-kit.yaml are a nice explicit statement of policy.
  • Using subprocess.check_output with a list (not a shell string) avoids injection risk.
  • "Grandfather" logic (files_to_validate scoped to changed files when a base is available) is the right approach for adopting governance incrementally.
  • The TEMPLATE.md "Notes for AI-assisted work" section is a valuable addition for this project.

Summary of Requested Changes

Priority Issue
High Fix base_ref() to not return multi-line SHA on merge commits
High Narrow STATUS_RE to avoid false matches in document body
High Emit a warning (not silent pass) when changed-files detection falls back to empty
Medium Reconcile ADR-0001 mention in PR description with the actual diff
Low Fix PEP 8 blank lines before __main__ guard
Low Verify or replace the adrs crate reference in TOOLING.md

🤖 Generated with Claude Code

Comment thread scripts/adr-governance.py Outdated
return f"origin/{ref}"
# On push, compare against first parent where available.
try:
return run(["git", "rev-parse", "HEAD^@"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 HEAD^@ expands to all parents of HEAD. On a merge commit pushed directly to main this yields two or more SHAs separated by newlines, making git diff --name-only "sha1 sha2...HEAD" an invalid command. Both fallback branches in changed_files_against_base will raise and return [], so changed = [] and the immutability loop never runs — any edit to an Accepted ADR in a merge commit silently passes. Use HEAD^ (first-parent only) to keep the intent in the comment and handle merge commits correctly.

Suggested change
return run(["git", "rev-parse", "HEAD^@"])
return run(["git", "rev-parse", "HEAD^"])
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/adr-governance.py
Line: 41

Comment:
`HEAD^@` expands to *all* parents of HEAD. On a merge commit pushed directly to `main` this yields two or more SHAs separated by newlines, making `git diff --name-only "sha1
sha2...HEAD"` an invalid command. Both fallback branches in `changed_files_against_base` will raise and return `[]`, so `changed = []` and the immutability loop never runs — any edit to an Accepted ADR in a merge commit silently passes. Use `HEAD^` (first-parent only) to keep the intent in the comment and handle merge commits correctly.

```suggestion
        return run(["git", "rev-parse", "HEAD^"])
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread scripts/adr-governance.py
ALLOWED_STATUSES = {"Proposed", "Accepted", "Superseded", "Deprecated", "Rejected"}
REQUIRED_SECTIONS = ["Context", "Decision", "Consequences", "Validation"]
FILENAME_RE = re.compile(r"^ADR-\d{4}-[a-z0-9][a-z0-9-]*\.md$")
STATUS_RE = re.compile(r"(?im)^\s*(?:[-*]\s*)?.*?Status.*?:\s*(.+?)\s*$")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The STATUS_RE pattern uses a loose .*?Status.*?: match, so it will trigger on any line in the ADR body that happens to contain "Status" — e.g., a Consequences bullet like - Deployment Status: unchanged or - System status: degraded. Since re.search() returns the first match, the correct frontmatter line wins when the header is near the top, but any ADR that discusses status in its body before the frontmatter would resolve to the wrong value. Anchoring the pattern more tightly to the frontmatter convention removes the ambiguity.

Suggested change
STATUS_RE = re.compile(r"(?im)^\s*(?:[-*]\s*)?.*?Status.*?:\s*(.+?)\s*$")
STATUS_RE = re.compile(r"(?im)^\s*[-*]\s*\*{0,2}Status\*{0,2}\s*:\s*(.+?)\s*$")
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/adr-governance.py
Line: 23

Comment:
The `STATUS_RE` pattern uses a loose `.*?Status.*?:` match, so it will trigger on any line in the ADR body that happens to contain "Status" — e.g., a Consequences bullet like `- Deployment Status: unchanged` or `- System status: degraded`. Since `re.search()` returns the first match, the correct frontmatter line wins when the header is near the top, but any ADR that discusses status in its body before the frontmatter would resolve to the wrong value. Anchoring the pattern more tightly to the frontmatter convention removes the ambiguity.

```suggestion
STATUS_RE = re.compile(r"(?im)^\s*[-*]\s*\*{0,2}Status\*{0,2}\s*:\s*(.+?)\s*$")
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread scripts/adr-governance.py
Comment on lines +99 to +110
if base:
for name in changed:
if not (name.startswith("docs/adr/ADR-") and name.endswith(".md")):
continue
old = file_at(base, name)
if old is None:
continue
old_status = status_of(old)
if old_status == "Accepted":
errors.append(
f"{name}: Accepted ADRs are immutable. Create a new superseding ADR instead of editing this file."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 ai_may_accept: false not enforced by CI

.adr-kit.yaml declares human_acceptance_required: true / ai_may_accept: false, but adr-governance.py only blocks edits to already-Accepted ADRs — it has no check that prevents a PR from changing an ADR's status from Proposed directly to Accepted. A contributor (or AI agent) can mark a brand-new ADR Accepted in the same PR that creates it, and this gate will pass.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/adr-governance.py
Line: 99-110

Comment:
**`ai_may_accept: false` not enforced by CI**

`.adr-kit.yaml` declares `human_acceptance_required: true` / `ai_may_accept: false`, but `adr-governance.py` only blocks *edits* to already-Accepted ADRs — it has no check that prevents a PR from changing an ADR's status from `Proposed` directly to `Accepted`. A contributor (or AI agent) can mark a brand-new ADR `Accepted` in the same PR that creates it, and this gate will pass.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread docs/adr/TEMPLATE.md
Comment on lines +1 to +9
# ADR-NNNN: <Decision Title>

- **Status:** Proposed
- **Date:** YYYY-MM-DD
- **Decision owners:** <names/handles>
- **Reviewers:** <names/handles>
- **Supersedes:** <ADR-NNNN or none>
- **Superseded by:** <ADR-NNNN or none>
- **Related:** <issues/PRs/specs>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 ADR-0001 mentioned in PR description but not present

The PR description states "For repos without existing ADRs, this also bootstraps ADR-0001: Adopt Architecture Decision Records", but no docs/adr/ADR-0001-*.md file is included in this PR. The governance script will run and report ADR governance passed (0 ADR file(s) checked) — a green CI gate with zero ADRs in the repository.

Prompt To Fix With AI
This is a comment left during a code review.
Path: docs/adr/TEMPLATE.md
Line: 1-9

Comment:
**ADR-0001 mentioned in PR description but not present**

The PR description states "For repos without existing ADRs, this also bootstraps `ADR-0001: Adopt Architecture Decision Records`", but no `docs/adr/ADR-0001-*.md` file is included in this PR. The governance script will run and report `ADR governance passed (0 ADR file(s) checked)` — a green CI gate with zero ADRs in the repository.

How can I resolve this? If you propose a fix, please make it concise.

@claude

claude Bot commented Jun 1, 2026

Copy link
Copy Markdown

Code Review — ci: add ADR governance compliance

Good initiative — preserving architectural reasoning as an auditable, immutable record is especially important in an AI-assisted codebase. The overall structure is clean and the intent is solid. A few issues need attention before this is trustworthy as a CI gate.


Critical Issues

1. STATUS_RE is too permissive — will produce false positives

scripts/adr-governance.py:23

STATUS_RE = re.compile(r"(?im)^\s*(?:[-*]\s*)?.*?Status.*?:\s*(.+?)\s*$")

The .*?Status.*?: pattern matches any line containing "Status:" in the document body — e.g. a Consequences bullet like - Deployment Status: unchanged or - HTTP Status: 200. Since re.search() returns the first match, an ADR that discusses deployment status before its frontmatter header will resolve to the wrong value, bypassing status validation silently.

Fix: Require Status to be the leading key on the line (frontmatter-style list item only):

STATUS_RE = re.compile(r"(?im)^\s*[-*]\s*\*{0,2}Status\*{0,2}\s*:\s*(.+?)\s*$")

2. ai_may_accept: false is declared but never enforced

.adr-kit.yaml:17-18 / scripts/adr-governance.py

The policy config declares:

human_acceptance_required: true
ai_may_accept: false

But the governance script never reads this file or enforces these constraints. A contributor (or AI agent) can create a brand-new ADR with Status: Accepted in the same PR that introduces it, and this gate will pass. The policy is documentation, not enforcement.

Fix: Add a check in the immutability loop for newly-created ADR files: if old is None (new file), reject the file if its status is already Accepted. This covers the case where an ADR is introduced pre-accepted:

old = file_at(base, name)
if old is None:
    # New ADR — must not start life as Accepted
    new_text = Path(name).read_text(encoding="utf-8") if Path(name).exists() else ""
    new_status = status_of(new_text)
    if new_status == "Accepted":
        errors.append(
            f"{name}: new ADRs must not be marked Accepted directly; use Proposed and go through human review."
        )
    continue

3. ADR-0001 missing despite PR description

The PR description states: "For repos without existing ADRs, this also bootstraps ADR-0001: Adopt Architecture Decision Records."

No docs/adr/ADR-0001-*.md file is in the diff. With zero ADRs on disk the script exits successfully with "ADR governance passed (0 ADR file(s) checked)" — a green gate that has validated nothing. Either add the bootstrap file or remove the claim from the description.


Moderate Issues

4. Python version not pinned in CI

.github/workflows/adr-governance.yml:28

run: python3 scripts/adr-governance.py

No actions/setup-python step — the workflow uses whatever python3 ships with ubuntu-latest. This can silently break if the runner image changes its default Python version. Pin it:

- uses: actions/setup-python@v5
  with:
    python-version: "3.12"

5. Triple-dot diff vs. double-dot diff may not do what's intended on push

scripts/adr-governance.py:45

return run(["git", "diff", "--name-only", f"{base}...HEAD"])

Triple-dot (A...B) uses the merge base, which is correct for PR events (where base = origin/TARGET). On push events however, base = HEAD^1 (a commit SHA), and SHA...HEAD is equivalent to SHA..HEAD for a linear history, but on a fast-forward merge HEAD^1 is the merge base anyway — so it works. Just worth a comment in the code for the next maintainer, since the semantics are non-obvious.


6. No version pinned for adrs in TOOLING.md

docs/adr/TOOLING.md:10

cargo install adrs

No version is pinned. If the adrs crate makes breaking changes this silently breaks new contributor setups. Consider:

cargo install adrs --version "=0.x.y"

Or document the tested version alongside this command.


Minor / Nits

  • Filename-only check vs. full path: changed_files_against_base returns full repo-relative paths like docs/adr/ADR-0001-foo.md. The filter name.startswith("docs/adr/ADR-") is correct, but if the ADR_DIR constant ever changes this silently breaks the diff scope. Consider deriving the filter prefix from ADR_DIR.

  • TOOLING.md missing from CLAUDE.md: The AI harness guidance in TOOLING.md is excellent. Consider adding a reference to it in the top-level CLAUDE.md so AI agents (including Claude Code) actually load and follow it.

  • No --fail-fast or structured exit in the workflow: If python3 scripts/adr-governance.py exits with a non-zero code the step fails correctly, but there's no annotation (e.g. ::error::) to surface errors inline in the PR check view. Low priority but nice-to-have.


What's Good

  • Immutability enforcement for Accepted ADRs is the right model — editing an accepted decision destroys the audit trail.
  • fetch-depth: 0 in the workflow is correct; without it git diff against the base would fail.
  • The file_at(ref, path) approach (reading the old file from git history rather than trusting the working tree) is the right way to detect status changes.
  • HEAD^1 (not HEAD^@) in base_ref() correctly handles merge commits by picking the first parent.
  • The template covers all required sections including Validation, which is often missing in ADR templates.
  • The AI harness guidance in TOOLING.md is clear and practical.

Summary

Severity Count
Critical 3 (STATUS_RE false positives, ai_may_accept unenforced, missing ADR-0001)
Moderate 3 (Python version, diff semantics comment, adrs version pin)
Minor/Nit 2

The structural intent of this PR is solid. Fix the STATUS_RE pattern and add enforcement for the ai_may_accept: false policy before merge — those two gaps undermine the guarantees the system is supposed to provide.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant