-
Notifications
You must be signed in to change notification settings - Fork 366
Expand file tree
/
Copy pathcheck_freshness.ts
More file actions
172 lines (148 loc) · 5.49 KB
/
Copy pathcheck_freshness.ts
File metadata and controls
172 lines (148 loc) · 5.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
// Freshness check: when a docs page that tracks a `last_modified` field is
// changed in a pull request, that field must be bumped in the same PR.
//
// The check compares the PR against its merge-base with the target branch.
// Pages that don't have a `last_modified` field at all are left alone, so
// this only enforces freshness where we already opted in to tracking it.
//
// Run locally: deno task check:freshness
// Run in CI: BASE_SHA=<base sha> deno task check:freshness
import { extract } from "@std/front-matter/yaml";
const DOC_EXTENSIONS = [".md", ".mdx"];
async function git(...args: string[]): Promise<string> {
const { code, stdout, stderr } = await new Deno.Command("git", {
args,
stdout: "piped",
stderr: "piped",
}).output();
if (code !== 0) {
throw new Error(
`git ${args.join(" ")} failed:\n${new TextDecoder().decode(stderr)}`,
);
}
return new TextDecoder().decode(stdout);
}
// Returns the blob at `ref:path`, or null if the path doesn't exist there.
async function showFile(ref: string, path: string): Promise<string | null> {
const { code, stdout } = await new Deno.Command("git", {
args: ["show", `${ref}:${path}`],
stdout: "piped",
stderr: "null",
}).output();
if (code !== 0) return null;
return new TextDecoder().decode(stdout);
}
// Extracts and normalizes the `last_modified` field as a YYYY-MM-DD string,
// or null if the file has no frontmatter or no `last_modified` field.
function lastModified(content: string | null): string | null {
if (!content || !content.startsWith("---")) return null;
let attrs: { last_modified?: unknown };
try {
({ attrs } = extract<{ last_modified?: unknown }>(content));
} catch {
return null;
}
const value = attrs.last_modified;
if (value == null) return null;
// YAML parses an unquoted date into a Date; normalize both forms to a string.
if (value instanceof Date) return value.toISOString().slice(0, 10);
return String(value).trim();
}
function isDoc(path: string): boolean {
return DOC_EXTENSIONS.some((ext) => path.endsWith(ext));
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
async function resolveBase(): Promise<string> {
const fromEnv = Deno.env.get("BASE_SHA") ?? Deno.env.get("BASE_REF");
if (fromEnv) return fromEnv.trim();
// Local fallback: diff against the default branch.
for (const ref of ["origin/main", "main"]) {
const { code } = await new Deno.Command("git", {
args: ["rev-parse", "--verify", "--quiet", ref],
stdout: "null",
stderr: "null",
}).output();
if (code === 0) return ref;
}
throw new Error(
"Could not determine a base ref. Set BASE_SHA or ensure origin/main exists.",
);
}
async function main() {
const base = await resolveBase();
const mergeBase = (await git("merge-base", base, "HEAD")).trim();
const today = new Date().toISOString().slice(0, 10);
const violations: string[] = [];
// -M detects renames; status is e.g. "M", "A", "D", "R095".
const nameStatus = await git(
"diff",
"--name-status",
"-M",
`${mergeBase}..HEAD`,
);
for (const line of nameStatus.split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t");
const status = parts[0];
if (status === "D") continue; // deletions need no bump
const isRename = status.startsWith("R");
const oldPath = parts[1];
const newPath = isRename ? parts[2] : parts[1];
if (!isDoc(newPath)) continue;
const headContent = await showFile("HEAD", newPath);
const baseContent = status === "A"
? null
: await showFile(mergeBase, oldPath);
// No real content change (e.g. a pure rename) needs no bump.
if (baseContent !== null && baseContent === headContent) continue;
const baseLM = lastModified(baseContent);
const headLM = lastModified(headContent);
// Page doesn't track last_modified on either side: out of scope.
if (baseLM === null && headLM === null) continue;
if (headLM === null) {
violations.push(
`${newPath}: the 'last_modified' field was removed; keep it and set it to ${today}.`,
);
continue;
}
if (!DATE_RE.test(headLM)) {
violations.push(
`${newPath}: 'last_modified' must be a YYYY-MM-DD date, got "${headLM}".`,
);
continue;
}
if (headLM > today) {
violations.push(
`${newPath}: 'last_modified' is in the future ("${headLM}"); today is ${today}.`,
);
continue;
}
// The core freshness rule: content changed but the date didn't move.
// A page already dated today is exempt: when the base version was itself
// last modified today (for example a second edit landing the same day, or a
// PR that touches a page already bumped on main today), the date cannot move
// without being set into the future, and "today" is already as fresh as it
// gets.
if (baseLM !== null && headLM === baseLM && headLM !== today) {
violations.push(
`${newPath}: page changed but 'last_modified' was not updated (still ${headLM}); set it to ${today}.`,
);
}
}
if (violations.length > 0) {
console.error(
`\nFreshness check failed for ${violations.length} page(s):\n`,
);
for (const v of violations) console.error(` - ${v}`);
console.error(
`\nWhen you change a page that tracks 'last_modified', bump that field to the date of the change.\n`,
);
Deno.exit(1);
}
console.log(
"Freshness check passed: all changed pages have a fresh 'last_modified'.",
);
}
if (import.meta.main) {
await main();
}