adr-check 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adr_check/__init__.py +1 -0
- adr_check/adr.py +334 -0
- adr_check/ci.py +234 -0
- adr_check/cli.py +87 -0
- adr_check/py.typed +0 -0
- adr_check-0.1.0.dist-info/METADATA +278 -0
- adr_check-0.1.0.dist-info/RECORD +10 -0
- adr_check-0.1.0.dist-info/WHEEL +4 -0
- adr_check-0.1.0.dist-info/entry_points.txt +2 -0
- adr_check-0.1.0.dist-info/licenses/LICENSE +21 -0
adr_check/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Checks architecture decision records (ADRs) against the ADR rules."""
|
adr_check/adr.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Parse ADR files and run the checks that need only the files in front of you."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
STATUSES = ("proposed", "accepted", "rejected", "superseded")
|
|
13
|
+
KEYS = ("status", "date", "supersedes", "superseded_by")
|
|
14
|
+
SECTIONS = ("Context", "Considered options", "Decision", "Consequences")
|
|
15
|
+
|
|
16
|
+
_FILENAME = re.compile(r"^(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$")
|
|
17
|
+
_H1 = re.compile(r"^(\d{4})\. \S")
|
|
18
|
+
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
19
|
+
_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Meta:
|
|
24
|
+
"""Validated frontmatter of one ADR."""
|
|
25
|
+
|
|
26
|
+
status: str
|
|
27
|
+
date: date
|
|
28
|
+
supersedes: tuple[int, ...]
|
|
29
|
+
superseded_by: int | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class AdrFile:
|
|
34
|
+
"""One ADR file, parsed as far as it parses.
|
|
35
|
+
|
|
36
|
+
`number` comes from the filename and is None when the name is not `NNNN-kebab-title.md`.
|
|
37
|
+
`raw` is the frontmatter as written, and `meta` is None when it failed validation.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
path: str
|
|
41
|
+
name: str
|
|
42
|
+
number: int | None
|
|
43
|
+
raw: dict[str, object] | None
|
|
44
|
+
meta: Meta | None
|
|
45
|
+
body: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
Resolver = Callable[[AdrFile, int, str], AdrFile | None]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_candidate(name: str) -> bool:
|
|
52
|
+
"""Return whether a file in the ADR directory is meant to be an ADR.
|
|
53
|
+
|
|
54
|
+
The directory may also hold a README or other non-Markdown files. Every other Markdown file is
|
|
55
|
+
checked, so a badly named ADR fails rather than being ignored.
|
|
56
|
+
"""
|
|
57
|
+
return name.endswith(".md") and name.lower() != "readme.md"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def number_of(name: str) -> int | None:
|
|
61
|
+
"""Return the number in an ADR filename, or None if the name is not well formed."""
|
|
62
|
+
match = _FILENAME.match(name)
|
|
63
|
+
return int(match.group(1)) if match else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def slug_of(name: str) -> str | None:
|
|
67
|
+
"""Return the kebab-case title in an ADR filename, or None if the name is not well formed."""
|
|
68
|
+
match = _FILENAME.match(name)
|
|
69
|
+
return match.group(2) if match else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_document(text: str) -> tuple[str | None, str]:
|
|
73
|
+
"""Split a document into its frontmatter text and body.
|
|
74
|
+
|
|
75
|
+
The frontmatter is None when the file does not open with a closed `---` block.
|
|
76
|
+
"""
|
|
77
|
+
lines = text.replace("\r\n", "\n").split("\n")
|
|
78
|
+
if lines[0].rstrip() != "---":
|
|
79
|
+
return None, "\n".join(lines)
|
|
80
|
+
for index in range(1, len(lines)):
|
|
81
|
+
if lines[index].rstrip() == "---":
|
|
82
|
+
return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
|
|
83
|
+
return None, "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_int(value: object) -> bool:
|
|
87
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_meta(raw: dict[str, object]) -> tuple[Meta | None, list[str]]:
|
|
91
|
+
problems: list[str] = []
|
|
92
|
+
for key in KEYS:
|
|
93
|
+
if key not in raw:
|
|
94
|
+
problems.append(f"frontmatter is missing the key `{key}`")
|
|
95
|
+
problems.extend(
|
|
96
|
+
f"frontmatter has the key `{key}`, which is not one of {', '.join(KEYS)}"
|
|
97
|
+
for key in raw
|
|
98
|
+
if key not in KEYS
|
|
99
|
+
)
|
|
100
|
+
if problems:
|
|
101
|
+
return None, problems
|
|
102
|
+
|
|
103
|
+
status = raw["status"]
|
|
104
|
+
if status not in STATUSES:
|
|
105
|
+
problems.append(f"`status` must be one of {', '.join(STATUSES)}, not {status!r}")
|
|
106
|
+
|
|
107
|
+
raw_date = raw["date"]
|
|
108
|
+
parsed: date | None = None
|
|
109
|
+
if type(raw_date) is date:
|
|
110
|
+
parsed = raw_date
|
|
111
|
+
elif isinstance(raw_date, str) and _ISO_DATE.match(raw_date):
|
|
112
|
+
try:
|
|
113
|
+
parsed = date.fromisoformat(raw_date)
|
|
114
|
+
except ValueError:
|
|
115
|
+
parsed = None
|
|
116
|
+
if parsed is None:
|
|
117
|
+
problems.append(f"`date` must be an ISO date such as 2026-03-12, not {raw_date!r}")
|
|
118
|
+
|
|
119
|
+
raw_supersedes = raw["supersedes"]
|
|
120
|
+
supersedes: tuple[int, ...] = ()
|
|
121
|
+
if raw_supersedes is None:
|
|
122
|
+
pass
|
|
123
|
+
elif isinstance(raw_supersedes, list) and all(_is_int(item) for item in raw_supersedes):
|
|
124
|
+
supersedes = tuple(item for item in raw_supersedes if isinstance(item, int))
|
|
125
|
+
else:
|
|
126
|
+
problems.append("`supersedes` must be a list of ADR numbers such as [7], or empty")
|
|
127
|
+
|
|
128
|
+
superseded_by = raw["superseded_by"]
|
|
129
|
+
if superseded_by is not None and not _is_int(superseded_by):
|
|
130
|
+
problems.append("`superseded_by` must be one ADR number such as 15, or empty")
|
|
131
|
+
|
|
132
|
+
if problems or parsed is None or not isinstance(status, str):
|
|
133
|
+
return None, problems
|
|
134
|
+
by = superseded_by if isinstance(superseded_by, int) else None
|
|
135
|
+
return Meta(status, parsed, supersedes, by), []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
139
|
+
_COMMENT_START = re.compile(r"^ {0,3}<!--")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _headings(body: str) -> list[tuple[int, str]]:
|
|
143
|
+
headings: list[tuple[int, str]] = []
|
|
144
|
+
fence: tuple[str, int] | None = None
|
|
145
|
+
in_comment = False
|
|
146
|
+
for line in body.split("\n"):
|
|
147
|
+
if in_comment:
|
|
148
|
+
# CommonMark: an HTML comment block runs to the first line holding `-->`, or to EOF.
|
|
149
|
+
in_comment = "-->" not in line
|
|
150
|
+
continue
|
|
151
|
+
if fence is None and _COMMENT_START.match(line):
|
|
152
|
+
in_comment = "-->" not in line[line.index("<!--") :]
|
|
153
|
+
continue
|
|
154
|
+
line = _COMMENT.sub("", line)
|
|
155
|
+
match = _FENCE.match(line)
|
|
156
|
+
if match:
|
|
157
|
+
run, rest = match.groups()
|
|
158
|
+
if fence is None:
|
|
159
|
+
# CommonMark: a backtick fence's info string cannot hold a backtick.
|
|
160
|
+
if run[0] != "`" or "`" not in rest:
|
|
161
|
+
fence = (run[0], len(run))
|
|
162
|
+
continue
|
|
163
|
+
if run[0] == fence[0] and len(run) >= fence[1] and not rest.strip():
|
|
164
|
+
fence = None
|
|
165
|
+
continue
|
|
166
|
+
if fence:
|
|
167
|
+
continue
|
|
168
|
+
heading = re.match(r"^(#{1,6}) +(.*?) *#*$", line)
|
|
169
|
+
if heading:
|
|
170
|
+
headings.append((len(heading.group(1)), heading.group(2)))
|
|
171
|
+
return headings
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_text(path: str, text: str) -> tuple[AdrFile, list[str]]:
|
|
175
|
+
"""Parse one ADR and return it with the problems found in that file alone."""
|
|
176
|
+
name = path.rsplit("/", 1)[-1]
|
|
177
|
+
number = number_of(name)
|
|
178
|
+
problems: list[str] = []
|
|
179
|
+
if number is None:
|
|
180
|
+
problems.append("filename must look like NNNN-kebab-title.md, for example 0007-use-x.md")
|
|
181
|
+
|
|
182
|
+
front, body = split_document(text)
|
|
183
|
+
raw: dict[str, object] | None = None
|
|
184
|
+
meta: Meta | None = None
|
|
185
|
+
if front is None:
|
|
186
|
+
problems.append("file must open with a frontmatter block between `---` lines")
|
|
187
|
+
else:
|
|
188
|
+
try:
|
|
189
|
+
loaded: object = yaml.safe_load(front)
|
|
190
|
+
except (yaml.YAMLError, ValueError, OverflowError) as error:
|
|
191
|
+
# PyYAML raises ValueError for an unquoted impossible date such as 2026-02-30.
|
|
192
|
+
loaded = None
|
|
193
|
+
problems.append(f"frontmatter is not valid YAML: {str(error).partition('\n')[0]}")
|
|
194
|
+
else:
|
|
195
|
+
if isinstance(loaded, dict) and all(isinstance(key, str) for key in loaded):
|
|
196
|
+
raw = {str(key): value for key, value in loaded.items()}
|
|
197
|
+
meta, meta_problems = _validate_meta(raw)
|
|
198
|
+
problems.extend(meta_problems)
|
|
199
|
+
else:
|
|
200
|
+
problems.append("frontmatter must be a mapping of the four keys")
|
|
201
|
+
|
|
202
|
+
problems.extend(_check_body(number, body))
|
|
203
|
+
return AdrFile(path, name, number, raw, meta, body), [f"{path}: {p}" for p in problems]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _check_body(number: int | None, body: str) -> list[str]:
|
|
207
|
+
problems: list[str] = []
|
|
208
|
+
headings = _headings(body)
|
|
209
|
+
h1s = [text for level, text in headings if level == 1]
|
|
210
|
+
if not h1s:
|
|
211
|
+
problems.append("missing the H1 title, for example `# 0007. Use managed identities`")
|
|
212
|
+
else:
|
|
213
|
+
match = _H1.match(h1s[0])
|
|
214
|
+
if not match:
|
|
215
|
+
problems.append(f"H1 must look like `# 0007. Title`, not `# {h1s[0]}`")
|
|
216
|
+
elif number is not None and int(match.group(1)) != number:
|
|
217
|
+
problems.append(
|
|
218
|
+
f"H1 number {match.group(1)} does not match filename number {number:04d}"
|
|
219
|
+
)
|
|
220
|
+
present = {text for level, text in headings if level == 2}
|
|
221
|
+
problems.extend(f"missing the `## {s}` section" for s in SECTIONS if s not in present)
|
|
222
|
+
return problems
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_tree(adr_dir: Path, display_dir: str) -> tuple[list[AdrFile], list[str]]:
|
|
226
|
+
"""Read every ADR in `adr_dir` and return them with the per-file problems."""
|
|
227
|
+
files: list[AdrFile] = []
|
|
228
|
+
problems: list[str] = []
|
|
229
|
+
if not adr_dir.is_dir():
|
|
230
|
+
return files, problems
|
|
231
|
+
for entry in sorted(adr_dir.iterdir()):
|
|
232
|
+
path = f"{display_dir}/{entry.name}"
|
|
233
|
+
if entry.is_symlink() and is_candidate(entry.name):
|
|
234
|
+
problems.append(f"{path}: is a symlink. An ADR must be a regular file")
|
|
235
|
+
continue
|
|
236
|
+
if not entry.is_file() or not is_candidate(entry.name):
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
text = entry.read_text(encoding="utf-8")
|
|
240
|
+
except UnicodeDecodeError:
|
|
241
|
+
problems.append(f"{path}: file is not valid UTF-8")
|
|
242
|
+
continue
|
|
243
|
+
adr, found = parse_text(path, text)
|
|
244
|
+
files.append(adr)
|
|
245
|
+
problems.extend(found)
|
|
246
|
+
return files, problems
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def check_tree(files: list[AdrFile]) -> list[str]:
|
|
250
|
+
"""Check numbering and supersession across every ADR in the working tree."""
|
|
251
|
+
problems: list[str] = []
|
|
252
|
+
by_number: dict[int, list[AdrFile]] = defaultdict(list)
|
|
253
|
+
for adr in files:
|
|
254
|
+
if adr.number is not None:
|
|
255
|
+
by_number[adr.number].append(adr)
|
|
256
|
+
duplicated = {n for n, group in by_number.items() if len(group) > 1}
|
|
257
|
+
for number in sorted(duplicated):
|
|
258
|
+
names = ", ".join(adr.name for adr in by_number[number])
|
|
259
|
+
problems.append(f"{number:04d} is used by more than one ADR: {names}")
|
|
260
|
+
|
|
261
|
+
def resolve(adr: AdrFile, target: int, field: str) -> AdrFile | None:
|
|
262
|
+
if target in duplicated:
|
|
263
|
+
problems.append(
|
|
264
|
+
f"{adr.path}: `{field}` names {target:04d}, which is a duplicated number, "
|
|
265
|
+
"so it is ambiguous"
|
|
266
|
+
)
|
|
267
|
+
return None
|
|
268
|
+
if target not in by_number:
|
|
269
|
+
problems.append(f"{adr.path}: `{field}` names {target:04d}, which does not exist")
|
|
270
|
+
return None
|
|
271
|
+
return by_number[target][0]
|
|
272
|
+
|
|
273
|
+
for adr in files:
|
|
274
|
+
meta = adr.meta
|
|
275
|
+
if meta is None or adr.number is None:
|
|
276
|
+
continue
|
|
277
|
+
for target in meta.supersedes:
|
|
278
|
+
_check_predecessor(adr, adr.number, target, resolve, problems)
|
|
279
|
+
_check_successor(adr, adr.number, meta, resolve, problems)
|
|
280
|
+
return problems
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _check_predecessor(
|
|
284
|
+
adr: AdrFile, number: int, target: int, resolve: Resolver, problems: list[str]
|
|
285
|
+
) -> None:
|
|
286
|
+
if target == number:
|
|
287
|
+
problems.append(f"{adr.path}: `supersedes` names the ADR itself")
|
|
288
|
+
return
|
|
289
|
+
old = resolve(adr, target, "supersedes")
|
|
290
|
+
if old is None or old.meta is None:
|
|
291
|
+
return
|
|
292
|
+
if old.meta.status != "superseded" or old.meta.superseded_by != number:
|
|
293
|
+
problems.append(
|
|
294
|
+
f"{adr.path}: supersedes {target:04d}, but {old.name} must have `status: superseded` "
|
|
295
|
+
f"and `superseded_by: {number}`"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _check_successor(
|
|
300
|
+
adr: AdrFile, number: int, meta: Meta, resolve: Resolver, problems: list[str]
|
|
301
|
+
) -> None:
|
|
302
|
+
if meta.status == "superseded" and meta.superseded_by is None:
|
|
303
|
+
problems.append(f"{adr.path}: `status: superseded` needs `superseded_by`")
|
|
304
|
+
if meta.superseded_by is None:
|
|
305
|
+
return
|
|
306
|
+
if meta.status != "superseded":
|
|
307
|
+
problems.append(
|
|
308
|
+
f"{adr.path}: `superseded_by` is set, so `status` must be `superseded`, "
|
|
309
|
+
f"not `{meta.status}`"
|
|
310
|
+
)
|
|
311
|
+
if meta.superseded_by == number:
|
|
312
|
+
problems.append(f"{adr.path}: `superseded_by` names the ADR itself")
|
|
313
|
+
return
|
|
314
|
+
successor = resolve(adr, meta.superseded_by, "superseded_by")
|
|
315
|
+
if successor is None or successor.meta is None:
|
|
316
|
+
return
|
|
317
|
+
if successor.meta.status == "rejected":
|
|
318
|
+
problems.append(
|
|
319
|
+
f"{adr.path}: superseded by {successor.name}, which is rejected. A rejected ADR "
|
|
320
|
+
"leaves the ADR it proposed to replace untouched"
|
|
321
|
+
)
|
|
322
|
+
if number not in successor.meta.supersedes:
|
|
323
|
+
problems.append(
|
|
324
|
+
f"{adr.path}: superseded by {successor.name}, whose `supersedes` must include {number}"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def check_no_proposed(files: list[AdrFile]) -> list[str]:
|
|
329
|
+
"""Fail every ADR still `proposed`, for a run that merges to main."""
|
|
330
|
+
return [
|
|
331
|
+
f"{adr.path}: `status: proposed` cannot reach main. Set accepted, rejected or superseded"
|
|
332
|
+
for adr in files
|
|
333
|
+
if adr.meta is not None and adr.meta.status == "proposed"
|
|
334
|
+
]
|
adr_check/ci.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Checks that compare the working tree with the base branch."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Collection
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from adr_check.adr import (
|
|
10
|
+
AdrFile,
|
|
11
|
+
is_candidate,
|
|
12
|
+
number_of,
|
|
13
|
+
parse_text,
|
|
14
|
+
slug_of,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
CORRECTION_LABEL = "adr-correction"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaseRefError(Exception):
|
|
21
|
+
"""The base ref cannot be used, so the history checks cannot run."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _git(repo: Path, *args: str, errors: str = "strict") -> str:
|
|
25
|
+
result = subprocess.run( # noqa: S603 # fixed git binary, arguments are not shell-parsed
|
|
26
|
+
["git", "-C", str(repo), *args], # noqa: S607 # git is resolved from PATH on purpose
|
|
27
|
+
capture_output=True,
|
|
28
|
+
encoding="utf-8",
|
|
29
|
+
errors=errors,
|
|
30
|
+
check=False,
|
|
31
|
+
)
|
|
32
|
+
if result.returncode != 0:
|
|
33
|
+
raise BaseRefError(result.stderr.strip() or f"git {args[0]} failed")
|
|
34
|
+
return result.stdout
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def repo_root() -> Path:
|
|
38
|
+
"""Return the top of the git repository containing the current directory."""
|
|
39
|
+
try:
|
|
40
|
+
return Path(_git(Path.cwd(), "rev-parse", "--show-toplevel").strip())
|
|
41
|
+
except BaseRefError as error:
|
|
42
|
+
raise BaseRefError("not inside a git repository, which ci mode needs") from error
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _numbers_at(repo: Path, rev: str, rel_dir: str) -> dict[int, list[str]]:
|
|
46
|
+
out = _git(repo, "ls-tree", "--name-only", "-z", rev, f"{rel_dir}/")
|
|
47
|
+
numbers: dict[int, list[str]] = defaultdict(list)
|
|
48
|
+
for path in out.split("\0"):
|
|
49
|
+
name = path.rsplit("/", 1)[-1]
|
|
50
|
+
number = number_of(name)
|
|
51
|
+
if number is not None:
|
|
52
|
+
numbers[number].append(name)
|
|
53
|
+
return numbers
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_top_level(path: str, rel_dir: str) -> bool:
|
|
57
|
+
"""Return whether `path` is directly in the ADR directory, as `load_tree` reads it."""
|
|
58
|
+
return Path(path).parent == Path(rel_dir)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _changes(repo: Path, base_ref: str, rel_dir: str) -> list[tuple[str, str, str]]:
|
|
62
|
+
"""Return (status letter, old path, new path) for each changed ADR candidate."""
|
|
63
|
+
out = _git(repo, "diff", "--name-status", "-M", "-z", f"{base_ref}...HEAD", "--", f"{rel_dir}/")
|
|
64
|
+
tokens = out.split("\0")
|
|
65
|
+
changes: list[tuple[str, str, str]] = []
|
|
66
|
+
index = 0
|
|
67
|
+
while index < len(tokens) and tokens[index]:
|
|
68
|
+
letter = tokens[index][0]
|
|
69
|
+
width = 2 if letter in "RC" else 1
|
|
70
|
+
paths = tokens[index + 1 : index + 1 + width]
|
|
71
|
+
index += 1 + width
|
|
72
|
+
old, new = paths[0], paths[-1]
|
|
73
|
+
in_old, in_new = _is_top_level(old, rel_dir), _is_top_level(new, rel_dir)
|
|
74
|
+
if in_old and not in_new:
|
|
75
|
+
letter, new = "D", old # moved into a subdirectory: the ADR is gone from the list
|
|
76
|
+
elif in_new and not in_old:
|
|
77
|
+
letter, old = "A", new # moved out of a subdirectory: it is a new ADR
|
|
78
|
+
elif not in_old:
|
|
79
|
+
continue
|
|
80
|
+
if is_candidate(old.rsplit("/", 1)[-1]) or is_candidate(new.rsplit("/", 1)[-1]):
|
|
81
|
+
changes.append((letter, old, new))
|
|
82
|
+
return changes
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _show(repo: Path, rev: str, path: str) -> AdrFile:
|
|
86
|
+
# load_tree reports a file that is not UTF-8, so compare a best-effort decoding here.
|
|
87
|
+
adr, _ = parse_text(path, _git(repo, "show", f"{rev}:{path}", errors="replace"))
|
|
88
|
+
return adr
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _as_list(value: object) -> list[object]:
|
|
92
|
+
if value is None:
|
|
93
|
+
return []
|
|
94
|
+
return list(value) if isinstance(value, list) else [value]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_retarget(old: object, new: object, renumbered: dict[int, int]) -> bool:
|
|
98
|
+
"""Return whether `new` only repoints renumbered ADRs in `old` at their new numbers."""
|
|
99
|
+
before, after = _as_list(old), _as_list(new)
|
|
100
|
+
if len(before) != len(after):
|
|
101
|
+
return False
|
|
102
|
+
return all(
|
|
103
|
+
b == a or (isinstance(b, int) and renumbered.get(b) == a)
|
|
104
|
+
for b, a in zip(before, after, strict=True)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _frontmatter_change_allowed(
|
|
109
|
+
old: dict[str, object], new: dict[str, object], renumbered: dict[int, int]
|
|
110
|
+
) -> bool:
|
|
111
|
+
keys = old.keys() | new.keys()
|
|
112
|
+
flip_keys = {"status", "superseded_by"}
|
|
113
|
+
rest_same = all(old.get(k) == new.get(k) for k in keys - flip_keys)
|
|
114
|
+
if (
|
|
115
|
+
rest_same
|
|
116
|
+
and old.get("status") == "accepted"
|
|
117
|
+
and new.get("status") == "superseded"
|
|
118
|
+
and old.get("superseded_by") is None
|
|
119
|
+
and isinstance(new.get("superseded_by"), int)
|
|
120
|
+
):
|
|
121
|
+
return True
|
|
122
|
+
pointer_keys = {"supersedes", "superseded_by"}
|
|
123
|
+
return (
|
|
124
|
+
bool(renumbered)
|
|
125
|
+
and all(old.get(k) == new.get(k) for k in keys - pointer_keys)
|
|
126
|
+
and all(_is_retarget(old.get(k), new.get(k), renumbered) for k in pointer_keys)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _renumber_problems(old: AdrFile, new: AdrFile, mb_numbers: dict[int, list[str]]) -> list[str]:
|
|
131
|
+
"""Return why a rename is not the renumbering repair, or nothing when it is."""
|
|
132
|
+
if old.number is None or new.number is None:
|
|
133
|
+
return [f"{new.path}: renamed from {old.path}, which is not an ADR rename the check allows"]
|
|
134
|
+
where = f"{new.path}: renamed from {old.name}"
|
|
135
|
+
if old.number == new.number or slug_of(old.name) != slug_of(new.name):
|
|
136
|
+
return [f"{where}. Only the number in the filename may change, and ADRs are never renamed"]
|
|
137
|
+
if len(mb_numbers.get(old.number, [])) < 2:
|
|
138
|
+
return [f"{where}, but {old.number:04d} is not a duplicated number on main"]
|
|
139
|
+
if old.raw is None or new.raw is None or old.raw != new.raw:
|
|
140
|
+
return [f"{where}. A renumbering repair changes no frontmatter"]
|
|
141
|
+
renumbered = re.sub(
|
|
142
|
+
rf"^# {old.number:04d}\.", f"# {new.number:04d}.", old.body, count=1, flags=re.MULTILINE
|
|
143
|
+
)
|
|
144
|
+
if renumbered != new.body:
|
|
145
|
+
return [f"{where}. A renumbering repair changes only the H1 number"]
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check_against_base(
|
|
150
|
+
repo: Path,
|
|
151
|
+
rel_dir: str,
|
|
152
|
+
base_ref: str,
|
|
153
|
+
labels: Collection[str],
|
|
154
|
+
) -> list[str]:
|
|
155
|
+
"""Compare the ADRs in HEAD with `base_ref` and return every rule they break.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
BaseRefError: `base_ref` is missing or shares no history with HEAD.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
_git(repo, "rev-parse", "--verify", "--quiet", f"{base_ref}^{{commit}}")
|
|
162
|
+
except BaseRefError as error:
|
|
163
|
+
raise BaseRefError(
|
|
164
|
+
f"base ref {base_ref!r} not found. Check out the caller repo with `fetch-depth: 0` "
|
|
165
|
+
"so the base branch is available"
|
|
166
|
+
) from error
|
|
167
|
+
try:
|
|
168
|
+
merge_base = _git(repo, "merge-base", base_ref, "HEAD").strip()
|
|
169
|
+
except BaseRefError as error:
|
|
170
|
+
raise BaseRefError(
|
|
171
|
+
f"HEAD and {base_ref!r} share no history. Check out with `fetch-depth: 0`"
|
|
172
|
+
) from error
|
|
173
|
+
|
|
174
|
+
tip_numbers = _numbers_at(repo, base_ref, rel_dir)
|
|
175
|
+
mb_numbers = _numbers_at(repo, merge_base, rel_dir)
|
|
176
|
+
changes = _changes(repo, base_ref, rel_dir)
|
|
177
|
+
problems: list[str] = []
|
|
178
|
+
|
|
179
|
+
renumbered: dict[int, int] = {}
|
|
180
|
+
for letter, old_path, new_path in changes:
|
|
181
|
+
if letter != "R":
|
|
182
|
+
continue
|
|
183
|
+
if (repo / new_path).is_symlink():
|
|
184
|
+
continue # load_tree reports it, and reading it would follow the link
|
|
185
|
+
old, new = _show(repo, merge_base, old_path), _read(repo, new_path)
|
|
186
|
+
found = _renumber_problems(old, new, mb_numbers)
|
|
187
|
+
problems.extend(found)
|
|
188
|
+
if not found and old.number is not None and new.number is not None:
|
|
189
|
+
renumbered[old.number] = new.number
|
|
190
|
+
|
|
191
|
+
for letter, old_path, new_path in changes:
|
|
192
|
+
name = new_path.rsplit("/", 1)[-1]
|
|
193
|
+
if letter == "D":
|
|
194
|
+
problems.append(f"{old_path}: ADRs are never deleted")
|
|
195
|
+
elif letter in "AR":
|
|
196
|
+
number = number_of(name)
|
|
197
|
+
if number in tip_numbers:
|
|
198
|
+
problems.append(
|
|
199
|
+
f"{new_path}: number {number:04d} is already used on {base_ref} by "
|
|
200
|
+
f"{', '.join(tip_numbers[number])}. Renumber this ADR"
|
|
201
|
+
)
|
|
202
|
+
elif letter == "M" and not (repo / new_path).is_symlink():
|
|
203
|
+
problems.extend(_modified_problems(repo, merge_base, new_path, labels, renumbered))
|
|
204
|
+
if letter not in "ADRM":
|
|
205
|
+
problems.append(f"{new_path}: unexpected change type {letter!r}")
|
|
206
|
+
return problems
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read(repo: Path, path: str) -> AdrFile:
|
|
210
|
+
return _show(repo, "HEAD", path)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _modified_problems(
|
|
214
|
+
repo: Path, merge_base: str, path: str, labels: Collection[str], renumbered: dict[int, int]
|
|
215
|
+
) -> list[str]:
|
|
216
|
+
old, new = _show(repo, merge_base, path), _read(repo, path)
|
|
217
|
+
if old.raw is not None and old.raw.get("status") == "proposed":
|
|
218
|
+
return []
|
|
219
|
+
if old.raw is None:
|
|
220
|
+
return [f"{path}: the version on main has unreadable frontmatter, so it cannot be compared"]
|
|
221
|
+
problems: list[str] = []
|
|
222
|
+
if old.body != new.body and CORRECTION_LABEL not in labels:
|
|
223
|
+
problems.append(
|
|
224
|
+
f"{path}: the body of an ADR on main never changes. Write a superseding ADR, or for "
|
|
225
|
+
f"a typo or link fix label the PR `{CORRECTION_LABEL}`"
|
|
226
|
+
)
|
|
227
|
+
if new.raw is not None and old.raw != new.raw:
|
|
228
|
+
if not _frontmatter_change_allowed(old.raw, new.raw, renumbered):
|
|
229
|
+
problems.append(
|
|
230
|
+
f"{path}: frontmatter of an ADR on main may change only from `accepted` to "
|
|
231
|
+
"`superseded` with `superseded_by`, or to retarget a renumbered ADR. The "
|
|
232
|
+
f"`{CORRECTION_LABEL}` label does not allow it"
|
|
233
|
+
)
|
|
234
|
+
return problems
|
adr_check/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Command line entry point for adr-check."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from adr_check.adr import check_no_proposed, check_tree, load_tree
|
|
10
|
+
from adr_check.ci import BaseRefError, check_against_base, repo_root
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_REF = "origin/main"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_labels(value: str) -> list[str]:
|
|
16
|
+
"""Read labels from a JSON array, as `toJSON` writes them, or a comma-separated list."""
|
|
17
|
+
text = value.strip()
|
|
18
|
+
if text.startswith("["):
|
|
19
|
+
return [str(item) for item in json.loads(text)]
|
|
20
|
+
return [item.strip() for item in text.split(",") if item.strip()]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
"""Return the argument parser for the adr-check command."""
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="adr-check",
|
|
27
|
+
description="Check architecture decision records. Exits 1 if any rule is broken.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--mode",
|
|
31
|
+
choices=("local", "ci"),
|
|
32
|
+
default="local",
|
|
33
|
+
help="local checks the working tree only; ci also compares with the base branch",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--adr-dir", default="docs/adr", help="ADR directory (default: docs/adr)")
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--base-ref",
|
|
38
|
+
help=f"ci only: ref to compare with (needs full history) (default: {DEFAULT_BASE_REF})",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--labels",
|
|
42
|
+
help="ci only: PR labels, as a JSON array or comma-separated; `adr-correction` allows a "
|
|
43
|
+
"body change",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--merging-to-main",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="this run is a PR into main or a push to main, so `proposed` fails",
|
|
49
|
+
)
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
54
|
+
"""Run the checks, print each problem to stderr and return the process exit code."""
|
|
55
|
+
parser = build_parser()
|
|
56
|
+
args = parser.parse_args(argv)
|
|
57
|
+
mode: str = args.mode
|
|
58
|
+
adr_dir: str = args.adr_dir.rstrip("/")
|
|
59
|
+
base_ref: str | None = args.base_ref
|
|
60
|
+
labels_arg: str | None = args.labels
|
|
61
|
+
merging: bool = args.merging_to_main
|
|
62
|
+
if mode == "local" and (base_ref or labels_arg):
|
|
63
|
+
parser.error("--base-ref and --labels need --mode ci")
|
|
64
|
+
try:
|
|
65
|
+
labels = parse_labels(labels_arg) if labels_arg else []
|
|
66
|
+
except ValueError as error:
|
|
67
|
+
parser.error(f"--labels is not a JSON array or a comma-separated list: {error}")
|
|
68
|
+
|
|
69
|
+
files, problems = load_tree(Path(adr_dir), adr_dir)
|
|
70
|
+
problems.extend(check_tree(files))
|
|
71
|
+
if merging:
|
|
72
|
+
problems.extend(check_no_proposed(files))
|
|
73
|
+
if mode == "ci":
|
|
74
|
+
try:
|
|
75
|
+
root = repo_root()
|
|
76
|
+
rel_dir = Path(adr_dir).resolve().relative_to(root.resolve()).as_posix()
|
|
77
|
+
problems.extend(check_against_base(root, rel_dir, base_ref or DEFAULT_BASE_REF, labels))
|
|
78
|
+
except (BaseRefError, ValueError) as error:
|
|
79
|
+
print(f"adr-check: {error}", file=sys.stderr)
|
|
80
|
+
return 2
|
|
81
|
+
|
|
82
|
+
for problem in problems:
|
|
83
|
+
print(problem, file=sys.stderr)
|
|
84
|
+
if problems:
|
|
85
|
+
print(f"adr-check: {len(problems)} problem(s) found", file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
return 0
|
adr_check/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: adr-check
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Checks architecture decision records (ADRs) in docs/adr
|
|
5
|
+
Project-URL: Homepage, https://github.com/agentic-delivery-au/adr-check
|
|
6
|
+
Project-URL: Repository, https://github.com/agentic-delivery-au/adr-check
|
|
7
|
+
Project-URL: Issues, https://github.com/agentic-delivery-au/adr-check/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/agentic-delivery-au/adr-check/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Stuart Langley
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: adr,architecture-decision-records,markdown,pre-commit
|
|
13
|
+
Requires-Python: >=3.14
|
|
14
|
+
Requires-Dist: pyyaml==6.0.3
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# adr-check
|
|
18
|
+
|
|
19
|
+
Checks the architecture decision records (ADRs) in a repository's `docs/adr/` directory. It keeps
|
|
20
|
+
the records uniform, links superseded decisions to their replacements, and, in CI, stops anyone
|
|
21
|
+
rewriting a decision that has already been merged.
|
|
22
|
+
|
|
23
|
+
An ADR is a short Markdown file that records one significant decision and the reasons for it. The
|
|
24
|
+
format this tool checks is a small variant of [MADR](https://adr.github.io/madr/): Michael Nygard's
|
|
25
|
+
sections plus a list of the options considered.
|
|
26
|
+
|
|
27
|
+
## What it checks
|
|
28
|
+
|
|
29
|
+
Files in the ADR directory that are not ADRs do not fail the check. It skips `README.md` and any
|
|
30
|
+
file that does not end in `.md`, and it ignores subdirectories. Every other Markdown file is
|
|
31
|
+
checked, so a badly named ADR fails instead of disappearing. A symlink in place of an ADR is an
|
|
32
|
+
error, because the check would otherwise validate the file it points at.
|
|
33
|
+
|
|
34
|
+
### In the working tree
|
|
35
|
+
|
|
36
|
+
These checks need only the files in front of you. They run in both modes.
|
|
37
|
+
|
|
38
|
+
- **Filename.** `NNNN-kebab-title.md`: four digits, then lowercase words joined by hyphens, for
|
|
39
|
+
example `0007-use-managed-identities.md`.
|
|
40
|
+
- **Frontmatter.** A YAML block between `---` lines holding exactly four keys, and no others:
|
|
41
|
+
- `status`: one of `proposed`, `accepted`, `rejected` or `superseded`.
|
|
42
|
+
- `date`: an ISO date such as `2026-03-12`.
|
|
43
|
+
- `supersedes`: a list of ADR numbers, such as `[7]`, or empty.
|
|
44
|
+
- `superseded_by`: one ADR number, or empty.
|
|
45
|
+
- **Title.** An H1 of the form `# 0007. Title`, where the number matches the filename.
|
|
46
|
+
- **Sections.** Four H2 headings: `## Context`, `## Considered options`, `## Decision` and
|
|
47
|
+
`## Consequences`. Headings inside code fences and HTML comments do not count.
|
|
48
|
+
- **Supersession links.** They must agree in both directions. If `0009` supersedes `0007`, then
|
|
49
|
+
`0007` has `status: superseded` and `superseded_by: 9`. An ADR with `superseded_by` must have
|
|
50
|
+
`status: superseded`, and a `superseded` ADR must name its successor. A link to a missing ADR, to
|
|
51
|
+
the ADR itself, or to a rejected successor fails.
|
|
52
|
+
- **Duplicate numbers.** Two files may not share a number. A `supersedes` or `superseded_by` that
|
|
53
|
+
names a number two ADRs share also fails, because it is ambiguous.
|
|
54
|
+
|
|
55
|
+
### Against the base branch
|
|
56
|
+
|
|
57
|
+
`--mode ci` adds checks that compare your branch with the branch it merges into. Merged decisions
|
|
58
|
+
are a record of what the team knew at the time, so they stay put.
|
|
59
|
+
|
|
60
|
+
- **Merged ADRs are immutable.** The body of an ADR on the base branch may not change, and the
|
|
61
|
+
frontmatter may change only from `accepted` to `superseded` with `superseded_by` set. A pull
|
|
62
|
+
request labelled `adr-correction` may fix a typo or a link in the body. The label does not allow a
|
|
63
|
+
frontmatter change.
|
|
64
|
+
- **`proposed` ADRs may change.** A `proposed` ADR on the base branch is not settled, so these rules
|
|
65
|
+
do not protect it.
|
|
66
|
+
- **No deletions or renames.** ADRs are never deleted, and never renamed. The one exception is the
|
|
67
|
+
renumbering repair. If two branches took the same number, one ADR may change its filename number
|
|
68
|
+
and its H1 number, and nothing else. ADRs that point at it may then update their `supersedes` or
|
|
69
|
+
`superseded_by` to the new number.
|
|
70
|
+
- **No reused numbers.** A new ADR may not take a number the base branch already uses, including one
|
|
71
|
+
the base took after you branched.
|
|
72
|
+
- **Symlinks on main need an override.** A symlinked ADR already on the base branch cannot be
|
|
73
|
+
repaired through this check. Replacing it with a regular file reports a type change, and deleting
|
|
74
|
+
it reports a deletion. Neither is allowed, and `adr-correction` lifts neither, so a person must
|
|
75
|
+
override the required check for that pull request.
|
|
76
|
+
- **No `proposed` on the main branch.** With `--merging-to-main`, any ADR with `status: proposed`
|
|
77
|
+
fails. `proposed` exists only on a branch.
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
Pick the form that suits the repository. All three run the same checks. Replace the placeholder
|
|
82
|
+
versions with a current release.
|
|
83
|
+
|
|
84
|
+
### Pre-commit hook
|
|
85
|
+
|
|
86
|
+
The repository ships a [pre-commit](https://pre-commit.com) hook. Add it to
|
|
87
|
+
`.pre-commit-config.yaml`:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
repos:
|
|
91
|
+
- repo: https://github.com/agentic-delivery-au/adr-check
|
|
92
|
+
rev: vX.Y.Z
|
|
93
|
+
hooks:
|
|
94
|
+
- id: adr-check
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The hook runs when a file under `docs/adr/` changes. It passes no filenames, because the rules look
|
|
98
|
+
across the whole directory. It sets `language_version: python3.14`, because the package needs Python
|
|
99
|
+
3.14 or newer and pre-commit would otherwise build it on its own interpreter. The machine needs a
|
|
100
|
+
`python3.14` executable on `PATH`.
|
|
101
|
+
|
|
102
|
+
If you would rather install a released package than clone this repository, use a local hook:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
repos:
|
|
106
|
+
- repo: local
|
|
107
|
+
hooks:
|
|
108
|
+
- id: adr-check
|
|
109
|
+
name: adr-check
|
|
110
|
+
entry: adr-check
|
|
111
|
+
language: python
|
|
112
|
+
language_version: python3.14
|
|
113
|
+
additional_dependencies: [adr-check==X.Y.Z]
|
|
114
|
+
files: ^docs/adr/
|
|
115
|
+
pass_filenames: false
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The hook checks the working tree only. It cannot see the base branch, so it cannot catch an edit to
|
|
119
|
+
a merged ADR, a deleted file or a number that the base branch already holds. CI catches those.
|
|
120
|
+
|
|
121
|
+
### In CI
|
|
122
|
+
|
|
123
|
+
Run the package from PyPI with [uv](https://docs.astral.sh/uv/). The checkout must hold the full
|
|
124
|
+
history, because the check compares your branch with its base:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
uvx adr-check==X.Y.Z --mode ci --base-ref origin/main --labels "$LABELS" --merging-to-main
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
On GitHub, the composite action wraps that command and fills in its inputs from the event:
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
name: adr-check
|
|
134
|
+
|
|
135
|
+
on:
|
|
136
|
+
pull_request:
|
|
137
|
+
types: [opened, reopened, synchronize, edited, labeled, unlabeled]
|
|
138
|
+
|
|
139
|
+
permissions:
|
|
140
|
+
contents: read
|
|
141
|
+
|
|
142
|
+
jobs:
|
|
143
|
+
adr-check:
|
|
144
|
+
runs-on: ubuntu-latest
|
|
145
|
+
steps:
|
|
146
|
+
- uses: actions/checkout@v7
|
|
147
|
+
with:
|
|
148
|
+
fetch-depth: 0
|
|
149
|
+
- uses: agentic-delivery-au/adr-check@<sha> # vX.Y.Z
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Pin the action to a full commit SHA, as you would any third-party action. Four details matter:
|
|
153
|
+
|
|
154
|
+
- **`fetch-depth: 0`.** A shallow checkout has no base branch to compare with, and the action fails
|
|
155
|
+
with a message that says so.
|
|
156
|
+
- **`labeled` and `unlabeled`.** Without them, adding `adr-correction` does not re-run the check,
|
|
157
|
+
and the pull request stays red until the next push.
|
|
158
|
+
- **No `paths:` filter.** A filtered workflow does not report on a pull request that touches no ADR,
|
|
159
|
+
so a required check never arrives.
|
|
160
|
+
- **Avoid triggering on `push`.** A push carries no pull request labels, so a history comparison
|
|
161
|
+
would fail every merged `adr-correction`. If a push does trigger the action, it runs only the
|
|
162
|
+
checks on the files and the `proposed` check.
|
|
163
|
+
|
|
164
|
+
The action installs the tool from its own checkout (the files the runner fetched for `uses:`), so
|
|
165
|
+
the code that runs is the code at the commit you pinned. It uses
|
|
166
|
+
[astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) to provide uv.
|
|
167
|
+
|
|
168
|
+
#### Action inputs
|
|
169
|
+
|
|
170
|
+
Every input has a default taken from the `github` context. You normally set none of them.
|
|
171
|
+
|
|
172
|
+
| Input | Default | Purpose |
|
|
173
|
+
| ----------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
174
|
+
| `adr-dir` | `docs/adr` | Directory that holds the ADRs |
|
|
175
|
+
| `base-ref` | `origin/<base branch>` on a pull request, empty on a push | Ref to compare with. A push ignores it |
|
|
176
|
+
| `merging-to-main` | `true` for a pull request into the default branch or a push to it, else false | Whether `status: proposed` fails the run |
|
|
177
|
+
| `labels` | The pull request's label names, as JSON | Labels on the PR. `adr-correction` allows a body change |
|
|
178
|
+
|
|
179
|
+
Set `merging-to-main` yourself only to override the default, for example in a repository whose
|
|
180
|
+
release branch is not the default branch.
|
|
181
|
+
|
|
182
|
+
### Command line
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
adr-check [--mode {local,ci}] [--adr-dir DIR] [--base-ref REF] [--labels LABELS] [--merging-to-main]
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
| Flag | Default | Purpose |
|
|
189
|
+
| ------------------- | ------------- | --------------------------------------------------------------------- |
|
|
190
|
+
| `--mode` | `local` | `local` checks the working tree. `ci` also compares with the base ref |
|
|
191
|
+
| `--adr-dir` | `docs/adr` | ADR directory |
|
|
192
|
+
| `--base-ref` | `origin/main` | `ci` only. Ref to compare with. It needs full history |
|
|
193
|
+
| `--labels` | none | `ci` only. PR labels as a JSON array or a comma-separated list |
|
|
194
|
+
| `--merging-to-main` | off | Fail any ADR with `status: proposed` |
|
|
195
|
+
|
|
196
|
+
The command prints one line per problem to stderr. It exits 0 when every rule holds, 1 when a rule
|
|
197
|
+
is broken and 2 when the base ref is missing or the directory is not in a git repository.
|
|
198
|
+
|
|
199
|
+
In `ci` mode, the no-reused-numbers check compares with the tip of the base ref. The other history
|
|
200
|
+
checks compare with the merge base of the base ref and `HEAD`. Git finds renames with
|
|
201
|
+
`git diff --name-status -M`, which detects a rename only when the file is mostly unchanged. A
|
|
202
|
+
renumbering repair always is.
|
|
203
|
+
|
|
204
|
+
## Development
|
|
205
|
+
|
|
206
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
uv sync # create .venv and install the package with its dev dependencies
|
|
210
|
+
pre-commit install # one-time, sets up the pre-commit and commit-msg hooks
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`uv sync` also installs an interpreter if no suitable one is present. `requires-python` is a lower
|
|
214
|
+
bound rather than a pin, so 3.14 is the floor and uv may select something newer. A project that
|
|
215
|
+
needs an exact interpreter pins it in a `.python-version` file, which uv reads.
|
|
216
|
+
|
|
217
|
+
| Task | Command |
|
|
218
|
+
| -------------------------- | ---------------------------- |
|
|
219
|
+
| Run the tests | `uv run pytest` |
|
|
220
|
+
| Lint | `uv run ruff check .` |
|
|
221
|
+
| Format | `uv run ruff format .` |
|
|
222
|
+
| Type check | `uv run pyrefly check src` |
|
|
223
|
+
| Run the tool | `uv run adr-check --help` |
|
|
224
|
+
| Everything the hooks check | `pre-commit run --all-files` |
|
|
225
|
+
|
|
226
|
+
### Tests and coverage
|
|
227
|
+
|
|
228
|
+
`uv run pytest` runs the suite with coverage already on, so the command above and the one
|
|
229
|
+
`tests.yml` runs in CI are the same run. Coverage is measured over `src/` and reported to
|
|
230
|
+
[Codecov](https://about.codecov.io). `pytest` never fails on a coverage number. The targets live in
|
|
231
|
+
`codecov.yml`: the project must not regress by more than 1%, and a pull request's changed lines need
|
|
232
|
+
80% coverage.
|
|
233
|
+
|
|
234
|
+
Type checking names the path, `pyrefly check src`, so it skips `tests/` (deliberately unannotated)
|
|
235
|
+
and `scripts/` (repository tooling that the template owns and Ruff also skips).
|
|
236
|
+
|
|
237
|
+
### Repository settings
|
|
238
|
+
|
|
239
|
+
`scripts/bootstrap_repo.py` applies the branch protection, merge settings and Actions permissions
|
|
240
|
+
that release-please needs. It prints a plan and changes nothing until you pass `--apply`. Read the
|
|
241
|
+
plan first, because it replaces an existing ruleset wholesale.
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
python3 scripts/bootstrap_repo.py # what it would change
|
|
245
|
+
python3 scripts/bootstrap_repo.py --apply # change it
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`release-please.yml` authenticates as a GitHub App, because a pull request opened with
|
|
249
|
+
`GITHUB_TOKEN` triggers no workflows and the required checks would never report on the release PR.
|
|
250
|
+
Create an App with Contents, Issues and Pull requests write permissions, install it on the
|
|
251
|
+
repository, then store its client ID as the variable `RELEASE_BOT_CLIENT_ID` and its private key as
|
|
252
|
+
the secret `RELEASE_BOT_PRIVATE_KEY`. There is deliberately no fallback to `GITHUB_TOKEN`.
|
|
253
|
+
|
|
254
|
+
## Releases
|
|
255
|
+
|
|
256
|
+
[release-please](https://github.com/googleapis/release-please) reads the Conventional Commits landed
|
|
257
|
+
on `main` and keeps an open release PR that bumps the version and updates `CHANGELOG.md`. Merging
|
|
258
|
+
that PR tags the version and publishes the GitHub release. The `publish` job in `release-please.yml`
|
|
259
|
+
then builds the package and uploads it to PyPI.
|
|
260
|
+
|
|
261
|
+
The release type is `python`, so release-please bumps the version in `pyproject.toml`, and an
|
|
262
|
+
`extra-files` entry keeps the project's own version in `uv.lock` in step. Without it the release PR
|
|
263
|
+
would fail `uv sync --locked`. The filter matches the package by name (`adr-check`), so rename the
|
|
264
|
+
package and it has to change in the same commit.
|
|
265
|
+
|
|
266
|
+
Tags have no component prefix (`v0.2.0`, not `adr-check-v0.2.0`), because `include-component-in-tag`
|
|
267
|
+
is `false`.
|
|
268
|
+
|
|
269
|
+
### Publishing
|
|
270
|
+
|
|
271
|
+
The `publish` job uses [trusted publishing](https://docs.pypi.org/trusted-publishers/), so no PyPI
|
|
272
|
+
token is stored. Register a trusted publisher on PyPI for this repository with the workflow name
|
|
273
|
+
`release-please.yml` and the environment `pypi`, and create a `pypi` environment on GitHub. Renaming
|
|
274
|
+
either side breaks the match.
|
|
275
|
+
|
|
276
|
+
## Licence
|
|
277
|
+
|
|
278
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
adr_check/__init__.py,sha256=2psc3BBitujve3D73P90W8djdeRHJ2pmOC6BcxTvlAQ,73
|
|
2
|
+
adr_check/adr.py,sha256=Si2l87y4ZDvvsrdNzQocFbTDj573oB1e4RMwMSjDnv8,12568
|
|
3
|
+
adr_check/ci.py,sha256=2YEvn1DgvukHHs-fvfduULDrKxB0ru8UxjnFKZDqbaI,9286
|
|
4
|
+
adr_check/cli.py,sha256=qrqR39iZRGsTf8Qra8RIFZZrSX323jxEH29lzwIeJSU,3167
|
|
5
|
+
adr_check/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
adr_check-0.1.0.dist-info/METADATA,sha256=V4RQ6S_iCch5T_nt68sakmWsfs9TKYhXVKFOjSn6Mug,13841
|
|
7
|
+
adr_check-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
adr_check-0.1.0.dist-info/entry_points.txt,sha256=AqE_6yAv_ljEATfYorePyaYt8fJHg1ncBBfaTDUxGOs,49
|
|
9
|
+
adr_check-0.1.0.dist-info/licenses/LICENSE,sha256=iW0aDoFzBy2RiDWWAAvk1_tZuUCR9eOOfOKk60G6pIA,1071
|
|
10
|
+
adr_check-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stuart Langley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|