Skip to content

Commit 10df2e3

Browse files
committed
Release check: the word list comes from an organisation secret [skip publish]
The check that runs before every release keeps no word list in the repository any more. It reads the list from the DN_NAME_CHECK_WORDS secret at run time and matches it case-insensitively on word boundaries in the files to publish and in the commit messages since the last tag. It fails when the secret is missing, skips only the word list on pull requests from forks, and prints the file and line of a hit, never the matched text. The path, attribution and README booster checks are unchanged. CI tests the check with invented words.
1 parent caf9a89 commit 10df2e3

4 files changed

Lines changed: 537 additions & 88 deletions

File tree

‎.github/scripts/check_names.py‎

Lines changed: 233 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,266 @@
1-
"""Check the files a release is about to publish.
1+
#!/usr/bin/env python3
2+
"""Check the files a release is about to publish, and the commit messages it ships.
23
3-
Usage: python3 check_names.py <file or directory>...
4+
check_names.py [--tracked] [--commits] [--unserved-everywhere] [--names-optional] [PATH...]
45
5-
Reads every file given, every file under a directory, and every member of an archive
6-
(.whl, .zip, .tar, .tar.gz, .tgz, .gem, nested archives and .gz members included), and
7-
fails when one holds an internal reference or a word that must not ship, or when a
8-
README or the package's description names a booster production does not serve yet. On
9-
a hit it prints the file and line, never the word.
6+
Reads every file given, every file under a directory, every file git tracks (--tracked),
7+
and every member of an archive (zip, jar, whl, nupkg, tar, tar.gz, tgz, crate, gem, with
8+
nested archives and .gz members opened in turn). A file fails when it holds:
9+
10+
- a word or phrase of the internal-name list, matched case-insensitively where no
11+
letter touches it on either side (a digit, an underscore or a camelCase hump is a
12+
boundary, and a phrase's words may be split by spaces, underscores or hyphens), in
13+
its text, in its UTF-16 strings and inside base64 runs; a file whose path holds one
14+
fails too;
15+
- a path into internal documents, a local home directory or an attribution line;
16+
- a booster production does not serve yet, when the file is a README or carries the
17+
package's description (in every file with --unserved-everywhere).
18+
19+
--commits also checks the message of every commit since the last v* tag before HEAD
20+
(every commit, while there is no such tag) for the internal names.
21+
22+
The internal-name list is read only from the DN_NAME_CHECK_WORDS environment variable,
23+
one lowercase word or phrase a line. Without it the check fails, except on a pull
24+
request from a fork or from Dependabot, which gets no secrets: there the internal
25+
names are skipped with a notice and every other check still runs. --names-optional,
26+
for ordinary CI, skips them as well whenever the list is missing; the release
27+
check never passes it. A hit prints the
28+
file and line, or the commit, and never the matched text; a path that itself holds an
29+
internal name is not printed either.
1030
"""
1131

1232
from __future__ import annotations
1333

34+
import base64
35+
import binascii
1436
import gzip
15-
import hashlib
1637
import io
38+
import json
39+
import os
1740
import re
41+
import subprocess
1842
import sys
1943
import tarfile
2044
import zipfile
21-
from pathlib import Path
22-
from typing import Iterator, List, Tuple
23-
24-
FORBIDDEN = re.compile(
25-
r"docs/[0-9]{2}|backend/[0-9]{2}|products/|/Users/|co-authored-by|generated with",
26-
re.IGNORECASE,
27-
)
28-
# Words that must not ship, matched case-insensitively and kept as SHA-256 digests so
29-
# this repository does not spell them: four found anywhere in a word, one only as a
30-
# whole word.
31-
INSIDE_WORDS = {
32-
"9e9b2efac0b62e4cf6f08cc6b7095e6ad7e18c447458c5849accb698f7149ef3",
33-
"934a11e602682e862b4b4d1f881ae8da1e097e3f6964bd780e5bb1fb798a6fdd",
34-
"b7933ed6180d9e2895a9d2df885e2a8b92e998a3c16297581f5b8698daacf492",
35-
"31d1b3d540ecf85228db7797efbe7d198ec5406abd3f6e64f27f684e9a312d29",
36-
}
37-
INSIDE_LENGTHS = (6, 7)
38-
WHOLE_WORDS = {"2e0b8d61fa2a6959d254b6ff5d0fb512249329097336a35568089933b49abdde"}
39-
ARCHIVE = re.compile(r"\.(whl|zip|tar|tar\.gz|tgz|gem)$")
40-
# Boosters a README may name only once production serves them, and the files that carry
41-
# a README or the package's description: a README itself, a wheel's METADATA or an sdist's
45+
from typing import Iterator, List, Optional, Pattern, Tuple
46+
47+
ENV = "DN_NAME_CHECK_WORDS"
48+
49+
# Written so that none of them matches its own source text.
50+
PLAIN = [
51+
("a path into internal documents", re.compile(r"docs/[0-9]{2}|backend/[0-9]{2}|product[s]/", re.I)),
52+
("a local home directory", re.compile(r"/user[s]/", re.I)),
53+
("an attribution line", re.compile(r"co-authored-b[y]|generated[ ]with", re.I)),
54+
]
55+
# Boosters a README or the package's description may name only once production serves
56+
# them, and the files that carry one: a README, a wheel's METADATA or an sdist's
4257
# PKG-INFO, an npm package.json, a gem's metadata.
43-
UNSERVED = re.compile(r"face_detection|wanted_person|face_recognition")
44-
DESCRIBES = re.compile(r"(^|/)(README[^/]*|PKG-INFO|METADATA|package\.json|metadata)$", re.IGNORECASE)
58+
UNSERVED = re.compile(r"face_detectio[n]|face_recognitio[n]|wanted_perso[n]", re.I)
59+
DESCRIBES = re.compile(r"(^|/)(README[^/]*|PKG-INFO|METADATA|package\.json|metadata)$", re.I)
60+
61+
TAR = re.compile(r"\.(tar|tar\.gz|tgz|crate|gem)$", re.I)
62+
B64 = re.compile(r"[A-Za-z0-9+/]{8,}={0,2}")
63+
HUMP = re.compile(r"([a-z0-9])([A-Z])")
64+
FOUND = "internal name found"
65+
66+
67+
def compile_names(raw: str) -> Optional[Pattern[str]]:
68+
"""One pattern for the whole list; None when the list is empty."""
69+
entries = set()
70+
for line in raw.splitlines():
71+
parts = [p for p in re.split(r"[\s_-]+", line.strip().lower()) if p]
72+
if parts:
73+
entries.add(r"[\s_-]+".join(re.escape(p) for p in parts))
74+
if not entries:
75+
return None
76+
alternatives = "|".join(sorted(entries, key=len, reverse=True))
77+
return re.compile(r"(?<![a-z])(?:%s)(?![a-z])" % alternatives)
78+
79+
80+
def withheld() -> bool:
81+
"""A pull request run that GitHub gives no secrets: from a fork, or from Dependabot."""
82+
if os.environ.get("GITHUB_EVENT_NAME") != "pull_request":
83+
return False
84+
if os.environ.get("GITHUB_ACTOR") == "dependabot[bot]":
85+
return True
86+
try:
87+
with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as f:
88+
event = json.load(f)
89+
except (KeyError, OSError, ValueError):
90+
return False
91+
head = ((event.get("pull_request") or {}).get("head") or {}).get("repo")
92+
base = (event.get("repository") or {}).get("full_name")
93+
return not head or head.get("full_name") != base
4594

4695

47-
def digest(text: str) -> str:
48-
return hashlib.sha256(text.encode("utf-8")).hexdigest()
96+
def offsets(names: Pattern[str], text: str) -> Iterator[int]:
97+
"""Where a name starts, as is and with camelCase humps split; no newline is added,
98+
so a line number counted in either view is the line in the text."""
99+
for view in (text.lower(), HUMP.sub(r"\1 \2", text).lower()):
100+
for m in names.finditer(view):
101+
yield view.count("\n", 0, m.start()) + 1
49102

50103

51104
def members(name: str, data: bytes) -> Iterator[Tuple[str, bytes]]:
52105
"""The files inside an archive, nested archives opened in turn; a plain file as is."""
53-
if name.endswith((".whl", ".zip")):
54-
with zipfile.ZipFile(io.BytesIO(data)) as z:
55-
for info in z.infolist():
56-
if not info.is_dir():
57-
yield from members("%s!%s" % (name, info.filename), z.read(info))
58-
elif ARCHIVE.search(name):
59-
with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as t:
60-
for m in t.getmembers():
61-
if m.isfile():
62-
f = t.extractfile(m)
106+
try:
107+
if data[:4] == b"PK\x03\x04":
108+
with zipfile.ZipFile(io.BytesIO(data)) as z:
109+
inner = [(i.filename, z.read(i)) for i in z.infolist() if not i.is_dir()]
110+
elif TAR.search(name):
111+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as t:
112+
inner = []
113+
for m in t.getmembers():
114+
f = t.extractfile(m) if m.isfile() else None
63115
if f is not None:
64-
yield from members("%s!%s" % (name, m.name), f.read())
65-
elif name.endswith(".gz"):
66-
yield from members(name[: -len(".gz")], gzip.decompress(data))
67-
else:
116+
inner.append((m.name, f.read()))
117+
elif data[:2] == b"\x1f\x8b":
118+
yield from members(re.sub(r"\.gz$", "", name), gzip.decompress(data))
119+
return
120+
else:
121+
yield name, data
122+
return
123+
except (zipfile.BadZipFile, tarfile.TarError, OSError, EOFError):
68124
yield name, data
125+
return
126+
for inner_name, inner_data in inner:
127+
yield from members("%s!%s" % (name, inner_name), inner_data)
69128

70129

71-
def scan(name: str, data: bytes) -> List[str]:
72-
text = data.decode("utf-8", errors="replace")
73-
errors: List[str] = []
130+
class Check:
131+
def __init__(self, names: Optional[Pattern[str]], unserved_everywhere: bool) -> None:
132+
self.names = names
133+
self.unserved_everywhere = unserved_everywhere
134+
self.findings: List[str] = []
135+
self.files = 0
136+
self.commits = 0
74137

75-
def found(start: int, what: str) -> None:
76-
errors.append("%s line %d contains %s" % (name, text.count("\n", 0, start) + 1, what))
138+
def named(self, text: str) -> bool:
139+
return self.names is not None and any(True for _ in offsets(self.names, text))
77140

78-
for m in FORBIDDEN.finditer(text):
79-
found(m.start(), repr(m.group(0)))
80-
if DESCRIBES.search(name.split("!")[-1]):
81-
for m in UNSERVED.finditer(text):
82-
found(m.start(), "%r, a booster production does not serve yet" % m.group(0))
83-
lower = text.lower()
84-
for m in re.finditer(r"[a-z]{%d,}" % min(INSIDE_LENGTHS), lower):
85-
run = m.group(0)
86-
for size in INSIDE_LENGTHS:
87-
for i in range(len(run) - size + 1):
88-
if digest(run[i : i + size]) in INSIDE_WORDS:
89-
found(m.start() + i, "a word that must not ship")
90-
for m in re.finditer(r"\w+", lower):
91-
if digest(m.group(0)) in WHOLE_WORDS:
92-
found(m.start(), "a word that must not ship")
93-
return errors
141+
def label(self, top: int, path: str) -> str:
142+
"""The path to print: as is, unless it holds an internal name itself."""
143+
if not self.named(path):
144+
return path
145+
parts = path.split("!")
146+
if self.named(parts[0]):
147+
return "<argument %d>" % top
148+
shown = parts[0]
149+
for depth, part in enumerate(parts[1:], 1):
150+
shown += "!" + ("<member at depth %d>" % depth if self.named(part) else part)
151+
return shown
152+
153+
def hit(self, where: str, what: str) -> None:
154+
self.findings.append("%s: %s" % (where, what))
155+
156+
def scan(self, top: int, path: str, data: bytes) -> None:
157+
self.files += 1
158+
shown = self.label(top, path)
159+
if shown != path:
160+
self.hit(shown, "a file name holds an internal name")
161+
text = data.decode("utf-8", errors="replace")
162+
lines = set()
163+
if self.names is not None:
164+
lines.update(offsets(self.names, text))
165+
for m in B64.finditer(text):
166+
token = m.group(0)
167+
try:
168+
decoded = base64.b64decode(token + "=" * (-len(token) % 4), validate=True)
169+
except (binascii.Error, ValueError):
170+
continue
171+
if self.named(decoded.decode("utf-8", errors="replace")):
172+
lines.add(text.count("\n", 0, m.start()) + 1)
173+
if b"\0" in data and not lines:
174+
# The strings of a compiled assembly, at either byte alignment.
175+
for view in (data, data[1:]):
176+
if self.named(view.decode("utf-16-le", errors="ignore")):
177+
self.hit(shown, FOUND)
178+
break
179+
for line in sorted(lines):
180+
self.hit("%s:%d" % (shown, line), FOUND)
181+
for kind, pattern in PLAIN:
182+
for m in pattern.finditer(text):
183+
self.hit("%s:%d" % (shown, text.count("\n", 0, m.start()) + 1), kind)
184+
if self.unserved_everywhere or DESCRIBES.search(path.split("!")[-1]):
185+
for m in UNSERVED.finditer(text):
186+
where = "%s:%d" % (shown, text.count("\n", 0, m.start()) + 1)
187+
self.hit(where, "a booster production does not serve yet")
188+
189+
def scan_commits(self) -> bool:
190+
def git(*args: str) -> subprocess.CompletedProcess:
191+
return subprocess.run(["git", *args], capture_output=True, text=True)
192+
193+
if git("rev-parse", "--is-shallow-repository").stdout.strip() != "false":
194+
print("::error::--commits needs the full history (actions/checkout with fetch-depth: 0)")
195+
return False
196+
since = git("describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD~1")
197+
span = "%s..HEAD" % since.stdout.strip() if since.returncode == 0 else "HEAD"
198+
log = git("log", "--format=%H%x00%B%x1e", span)
199+
if log.returncode != 0:
200+
print("::error::git log failed for the commits to check")
201+
return False
202+
for record in log.stdout.split("\x1e"):
203+
if "\x00" not in record:
204+
continue
205+
sha, message = record.strip("\n").split("\x00", 1)
206+
self.commits += 1
207+
if self.named(message):
208+
self.hit("commit %s" % sha[:12], FOUND)
209+
return True
210+
211+
212+
def tracked() -> List[str]:
213+
out = subprocess.run(["git", "ls-files", "-z"], capture_output=True, check=True).stdout
214+
return [p for p in out.decode("utf-8", errors="surrogateescape").split("\0") if p]
94215

95216

96217
def main(argv: List[str]) -> int:
97-
if not argv:
98-
print("usage: check_names.py <file or directory>...", file=sys.stderr)
218+
flags = {"--tracked", "--commits", "--unserved-everywhere", "--names-optional"}
219+
given = [a for a in argv if a in flags]
220+
args = [a for a in argv if a not in flags]
221+
if any(a.startswith("--") for a in args) or not (args or given):
222+
print(
223+
"usage: check_names.py [--tracked] [--commits] [--unserved-everywhere] [--names-optional] [PATH...]",
224+
file=sys.stderr,
225+
)
99226
return 2
100-
paths: List[Path] = []
101-
for arg in argv:
102-
p = Path(arg)
103-
if not p.exists():
104-
print("::error::%s does not exist" % arg)
227+
228+
names = compile_names(os.environ.get(ENV, ""))
229+
if names is None and withheld():
230+
print("::notice::%s is withheld from this pull request run: internal names not checked" % ENV)
231+
elif names is None and "--names-optional" in given:
232+
print("::notice::%s is not set here: internal names not checked (the release check needs it)" % ENV)
233+
elif names is None:
234+
print("::error::%s is empty or not set: the internal-name check cannot run" % ENV)
235+
return 1
236+
237+
check = Check(names, "--unserved-everywhere" in given)
238+
paths: List[str] = tracked() if "--tracked" in given else []
239+
for top, arg in enumerate(args, 1):
240+
if not os.path.exists(arg):
241+
print("::error::%s does not exist" % check.label(top, arg))
105242
return 1
106-
paths.extend(sorted(q for q in p.rglob("*") if q.is_file()) if p.is_dir() else [p])
107-
errors: List[str] = []
108-
scanned = 0
109-
for p in paths:
110-
for name, data in members(str(p), p.read_bytes()):
111-
scanned += 1
112-
errors.extend(scan(name, data))
113-
for e in errors:
114-
print("::error::%s" % e)
115-
print("%d files scanned, %d findings" % (scanned, len(errors)))
116-
return 1 if errors else 0
243+
if os.path.isdir(arg):
244+
for root, dirs, files in os.walk(arg):
245+
dirs.sort()
246+
paths.extend(os.path.join(root, f) for f in sorted(files))
247+
else:
248+
paths.append(arg)
249+
if (args or "--tracked" in given) and not paths:
250+
print("::error::no file to check")
251+
return 1
252+
for top, path in enumerate(paths, 1):
253+
if os.path.isfile(path):
254+
with open(path, "rb") as f:
255+
for name, data in members(path, f.read()):
256+
check.scan(top, name, data)
257+
if "--commits" in given and names is not None and not check.scan_commits():
258+
return 1
259+
260+
for finding in check.findings:
261+
print("::error::%s" % finding)
262+
print("%d files and %d commit messages checked, %d findings" % (check.files, check.commits, len(check.findings)))
263+
return 1 if check.findings else 0
117264

118265

119266
if __name__ == "__main__":

0 commit comments

Comments
 (0)