declapract-typescript-ehmpathy 0.49.9 → 0.49.10

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.
Files changed (25) hide show
  1. package/dist/__snapshots__/actionPins.declapract.integration.test.ts.snap +70 -0
  2. package/dist/actionPins.declapract.integration.test.ts +785 -0
  3. package/dist/practices/cicd-app-react-native-expo/best-practice/.github/workflows/.deploy-expo.yml +10 -10
  4. package/dist/practices/cicd-app-react-native-expo/best-practice/.github/workflows/deploy.yml +4 -0
  5. package/dist/practices/cicd-app-react-native-expo/best-practice/.github/workflows/test.yml +4 -0
  6. package/dist/practices/cicd-common/.declapract.integration.test.ts +572 -0
  7. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/.github/workflows/.test.yml +19 -0
  8. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/.github/workflows/consumer-owned.yml +21 -0
  9. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/.github/workflows/review.yml +26 -0
  10. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/declapract.use.yml +6 -0
  11. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/package.json +4 -0
  12. package/dist/practices/cicd-common/__snapshots__/.declapract.integration.test.ts.snap +550 -0
  13. package/dist/practices/cicd-common/best-practice/.declapract.readme.md +5 -0
  14. package/dist/practices/cicd-common/best-practice/.github/workflows/.declastruct.yml +12 -12
  15. package/dist/practices/cicd-common/best-practice/.github/workflows/.install.yml +5 -5
  16. package/dist/practices/cicd-common/best-practice/.github/workflows/.test.yml +25 -25
  17. package/dist/practices/cicd-common/best-practice/.github/workflows/release.yml +2 -2
  18. package/dist/practices/cicd-common/best-practice/.github/workflows/review.yml +1 -1
  19. package/dist/practices/cicd-package/best-practice/.github/workflows/.publish-npm.yml +3 -3
  20. package/dist/practices/cicd-package/best-practice/.github/workflows/provision.yml +4 -0
  21. package/dist/practices/cicd-service/best-practice/.github/workflows/.deploy-sls.yml +13 -13
  22. package/dist/practices/cicd-service/best-practice/.github/workflows/.sql-schema-control.yml +8 -8
  23. package/dist/practices/cicd-service/best-practice/.github/workflows/.terraform.yml +6 -6
  24. package/dist/practices/cicd-service/best-practice/.github/workflows/provision.yml +4 -0
  25. package/package.json +4 -4
@@ -0,0 +1,785 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { basename, join, relative } from 'node:path';
3
+
4
+ import { given, then, useBeforeAll, when } from 'test-fns';
5
+
6
+ /**
7
+ * .what = holds every third-party action ref on BOTH of this repo's github surfaces to a pinned
8
+ * shape — `uses: <owner>/<repo>@<40-hex sha> # <tag>`:
9
+ * 1. `src/practices/**` — the templates a consumer receives (the deliverable)
10
+ * 2. `.github/**` — the workflows this repo itself runs
11
+ * .why = the ehmpathy org sets `sha_pinning_required`, which github enforces at action lookup —
12
+ * before a workflow's first step runs. a tag ref does not fail a test; it stops CI from
13
+ * the outset, in whichever consumer repo received the template
14
+ * .note = surface 2 is covered because it is a DELIVERY precondition, not for symmetry's sake:
15
+ * `publish.yml` declares `needs: [test]`, and `test` calls `./.github/workflows/.test.yml`.
16
+ * a tag ref there kills the release, so the templates never reach a consumer at all
17
+ *
18
+ * .note = this is an INTEGRATION test, and the filename is the reason. it walks the filesystem —
19
+ * `readdirSync` over both github surfaces, `readFileSync` on every yaml it finds — and
20
+ * `rule.forbid.unit.remote-boundaries` puts any test that crosses that boundary in the
21
+ * integration suite, with the rename as its stated remedy. it needs no credential and no
22
+ * network; the boundary alone is what classifies it.
23
+ * the bound, so a later author does not widen it: both roots are anchored on `__dirname`
24
+ * and land inside this repo's own committed tree. a check that wants a value from upstream
25
+ * github, or from a consumer repo, is a different kind of reach and needs its own thought
26
+ *
27
+ * .note = to derive a sha when a pin is added or bumped, read `.object.type` first, every time,
28
+ * and dereference it when it reads `tag`:
29
+ *
30
+ * gh api -X GET repos/<owner>/<repo>/git/ref/tags/<tag> --jq .object
31
+ * # -> { "sha": "<x>", "type": "commit" } ..... <x> is the pin
32
+ * # -> { "sha": "<y>", "type": "tag" } ..... <y> is not; dereference it:
33
+ * gh api -X GET repos/<owner>/<repo>/git/tags/<y> --jq .object.sha
34
+ *
35
+ * the shortcut that skips the type check yields a tag-object sha for an annotated tag —
36
+ * 40 hex characters that name no commit. 2 of the 12 action repos pinned here are
37
+ * annotated, so it is a live trap rather than a hypothetical one, and no assertion below
38
+ * detects it: a tag-object sha is 40-hex, carries a tag tail, and stays self-consistent
39
+ * across templates, so all three checks pass. github rejects it, in a consumer's repo.
40
+ * this note plus its reader are the guard, which is why it sits beside the clamp rather
41
+ * than in the route that produced it
42
+ *
43
+ * .note = to APPLY a derived sha, move every site of that repo in one edit. one action can hold
44
+ * up to 25 sites in a single file and ~34 across both surfaces, so a hand-edit is where a
45
+ * partial bump comes from — and a partial bump is what the drift assertion below reports.
46
+ * that assertion is a safety net, not a procedure; this is the procedure:
47
+ *
48
+ * rhx sedreplace --old '@<sha.old> # <tag.old>' \
49
+ * --new '@<sha.new> # <tag.new>' \
50
+ * --glob 'src/practices/**' --mode apply
51
+ * rhx sedreplace --old '@<sha.old> # <tag.old>' \
52
+ * --new '@<sha.new> # <tag.new>' \
53
+ * --glob '.github/**' --mode apply
54
+ *
55
+ * BOTH globs, always — the two surfaces are pinned in lockstep and the drift assertion
56
+ * reads their union, so one glob alone reports a repo pinned to two shas. run without
57
+ * `--mode apply` first to read the diff. match on the WHOLE `@<sha> # <tag>` string rather
58
+ * than the sha alone, so the tail moves with the pin and cannot go stale against it
59
+ *
60
+ * the anchor is the `@<sha>` rather than `<owner>/<repo>@<sha>`, because a subaction sits
61
+ * at a longer path. `actions/cache` is the case: its sites are all
62
+ * `actions/cache/restore@…` and `actions/cache/save@…`, so the `<owner>/<repo>@` form
63
+ * matches none of them and reports `no files contain pattern` for a repo the pin table
64
+ * counts as 19 sites. the shorter anchor cannot over-reach — a sha names one upstream repo
65
+ *
66
+ * .note = what those globs reach, stated so it is not a surprise mid-bump. `src/practices/**` is
67
+ * wider than the templates: it also holds this behavior's test fixtures and the
68
+ * `cicd-common/__snapshots__` file, and a bump of `actions/checkout` rewrites 31 sites
69
+ * across 11 files rather than the 22 template sites alone. that is CORRECT — the fixture
70
+ * literal and the settled-file snapshot must move with the template or `[case2]` reddens
71
+ * on data that is merely stale. read the plan diff before `--mode apply` so the extra
72
+ * files are a choice rather than a discovery.
73
+ *
74
+ * `.github/**` reaches 12 sites across 5 files for that same bump, so the two runs sum to
75
+ * 43 rewritten sites across 16 files. the second glob is not a formality: skip it and the
76
+ * 22 template sites move while this repo's own 12 stay put, which is exactly the
77
+ * two-shas-for-one-repo state `[case3]` reports — measured, and it reads
78
+ * `actions/checkout — <sha.new> vs <sha.old>`. a red there after a one-glob bump is the
79
+ * drift assertion correct, not a false alarm.
80
+ *
81
+ * two files the globs deliberately do NOT reach, and each is a signal rather than a gap:
82
+ * - `src/__snapshots__/actionPins.declapract.integration.test.ts.snap` — the pin tables.
83
+ * they go red after a bump, and the red is the point: re-record with
84
+ * `rhx git.repo.test --what integration --scope 'path://actionPins' --mode apply --resnap`
85
+ * and READ the diff, since a site count that moved is a bump that reached more or
86
+ * fewer sites than intended. note the suite — this file walks the filesystem, so it
87
+ * lives in `integration`, not `unit`
88
+ * - `src/practices/cicd-common/best-practice/.github/workflows/.test.yml.declapract.test.ts`
89
+ * — its `@v4` strings are synthetic props, not a pin, and must stay unpinned
90
+ */
91
+
92
+ /**
93
+ * .what = this repo's root, the base every reported path is relativized against
94
+ * .why = a failure names a ref site, and a name a maintainer can act on is a repo path — not
95
+ * one that carries the home directory of whichever machine ran the suite
96
+ * (`rule.require.errors-name-the-fix`)
97
+ */
98
+ const REPO_ROOT = join(__dirname, '..');
99
+
100
+ /**
101
+ * .what = the root under which every declared workflow template lives
102
+ * .why = a template is copied verbatim to a consumer, so its pin must be a literal here.
103
+ * a template cannot import, so no shared constant can hold the sha for it
104
+ * .note = anchored on `__dirname` rather than cwd, so the walk holds wherever jest is
105
+ * invoked from — the same guarantee `git/.declapract.integration.test.ts` states
106
+ */
107
+ const PRACTICES_DIR = join(__dirname, 'practices');
108
+
109
+ /**
110
+ * .what = the root under which this repo's OWN workflows live
111
+ * .why = this repo consumes its own practices, so it meets the same org control its templates
112
+ * teach — and it meets it first, since `publish` needs `test` and `test` dies at action
113
+ * lookup on a tag ref. `..` from `src/` is the repo root
114
+ */
115
+ const REPO_GITHUB_DIR = join(__dirname, '..', '.github');
116
+
117
+ /**
118
+ * .what = the two .github subtrees github reads workflow and action definitions from
119
+ * .why = a ref anywhere else in a practice is not a workflow ref, so it is out of the
120
+ * org control's reach and out of this clamp's
121
+ */
122
+ const GITHUB_SUBTREES = ['.github/workflows', '.github/actions'];
123
+
124
+ /**
125
+ * .what = the directory names that hold test fixtures, for BOTH conventions this repo uses
126
+ * .why = a fixture is an INPUT to a test, not a template a consumer receives — and several hold
127
+ * an unpinned ref on purpose, so a walk that reached one would go red on a non-defect
128
+ * .note = the two conventions, and why the list must carry both:
129
+ * `__snapshots__` — jest's own dir; `.test.yml.declapract.test.ts.snap` holds a
130
+ * synthetic `actions/checkout@v4` as hand-written fixture data
131
+ * `.test` — this repo's demo-repo convention, `<practice>/.test/assets/<repo>/`.
132
+ * a demo repo that proves an apply must carry the STALE, unpinned
133
+ * file as its before-state — see `cicd-common/.declapract.integration.test.ts`
134
+ * .note = only `__snapshots__` was declared at first, and the omission was not theoretical: the
135
+ * demo repo above went red on 4 assertions the moment it landed, naming a fixture that is
136
+ * SUPPOSED to hold `@v5`. a fixture and a defect are indistinguishable to every check
137
+ * below, so the walk is the only layer that can tell them apart
138
+ */
139
+ const FIXTURE_DIRS = ['__snapshots__', '.test'];
140
+
141
+ /**
142
+ * .what = a `uses:` ref site, with enough context to name it in a failure
143
+ */
144
+ interface ActionRef {
145
+ path: string;
146
+ line: number;
147
+ ref: string;
148
+ comment: string;
149
+ }
150
+
151
+ /**
152
+ * .what = every file path under a directory, walked with node's own readdir
153
+ * .why = most of the workflow templates are dot-prefixed (`.test.yml`, `.install.yml`).
154
+ * a glob library that omits dotfiles by default would silently walk a third of
155
+ * the tree and pass vacuously — readdir has no such blind spot
156
+ * .note = the fixtures skip stays here, in the walk, because it is a TRAVERSAL decision — do not
157
+ * descend — rather than a selection one. every selection lives in the transformers below
158
+ */
159
+ const getAllPathsUnderDir = (input: { dir: string; skip: string[] }): string[] =>
160
+ readdirSync(input.dir, { withFileTypes: true }).flatMap((entry) => {
161
+ const path = `${input.dir}/${entry.name}`;
162
+ if (!entry.isDirectory()) return [path];
163
+ return input.skip.includes(entry.name)
164
+ ? []
165
+ : getAllPathsUnderDir({ dir: path, skip: input.skip });
166
+ });
167
+
168
+ /**
169
+ * .what = the yaml among a set of paths
170
+ * .why = kept out of the walk so the walk performs i/o only, and this stays pure. a workflow is
171
+ * yaml; the `.declapract.ts` declarations that sit in the same directories are not
172
+ */
173
+ const getAllYamlPaths = (input: { paths: string[] }): string[] =>
174
+ input.paths.filter((path) => /\.ya?ml$/.test(basename(path)));
175
+
176
+ /**
177
+ * .what = the paths that live inside one `.github` subtree
178
+ * .why = named once and called by both the walk and the walk's own blind-spot assertion, so the
179
+ * assertion cannot test a rule the walk does not use
180
+ */
181
+ const getAllPathsInSubtree = (input: { paths: string[]; subtree: string }): string[] =>
182
+ input.paths.filter((path) => path.includes(`/${input.subtree}/`));
183
+
184
+ /**
185
+ * .what = the paths that live inside a fixture directory, of either convention
186
+ * .why = named once and called by the walk's own blind-spot assertion, so the assertion reads the
187
+ * same list the walk skips on — a third convention added to `FIXTURE_DIRS` is guarded the
188
+ * moment it is declared, with no second edit to remember
189
+ */
190
+ const getAllPathsInFixtureDirs = (input: { paths: string[] }): string[] =>
191
+ input.paths.filter((path) =>
192
+ FIXTURE_DIRS.some((dir) => path.includes(`/${dir}/`)),
193
+ );
194
+
195
+ /**
196
+ * .what = every workflow or action definition under a root, whatever else that root holds
197
+ * .why = both surfaces are walked by the same code on purpose. a laxer walk over this repo's
198
+ * own workflows is exactly the defect this file exists to catch, and two walks are two
199
+ * places for that laxness to creep in
200
+ * .note = sorted, so a failure report reads in a stable order
201
+ */
202
+ const getAllGithubYamlPathsUnderRoot = (input: { root: string }): string[] => {
203
+ const paths = getAllYamlPaths({
204
+ paths: getAllPathsUnderDir({ dir: input.root, skip: FIXTURE_DIRS }),
205
+ });
206
+ return GITHUB_SUBTREES.flatMap((subtree) => getAllPathsInSubtree({ paths, subtree })).sort();
207
+ };
208
+
209
+ /**
210
+ * .what = the workflow yaml the walk above deliberately SKIPS — the fixtures
211
+ * .why = to prove the skip guards a real file. the exclusion assertion is a `toEqual([])`, which
212
+ * a trivially satisfied predicate also passes; if every fixture were pinned, moved, or
213
+ * deleted, it would stay green while it guarded not one file, and the next fixture to land
214
+ * would reopen the hole in silence
215
+ * .note = `skip: []` is the whole difference from the walk above. it is expressed as an ARGUMENT
216
+ * rather than a second walk, so the two cannot drift apart except on the skip itself —
217
+ * the same one-source discipline `GITHUB_SUBTREES` gets
218
+ * .note = generic on purpose. an earlier form named one practice's fixture path outright, which
219
+ * put knowledge of `cicd-common`'s private test layout inside a cross-practice invariant.
220
+ * this form extends to every fixture any practice adds, with no edit here
221
+ */
222
+ const getAllGithubYamlPathsInFixtures = (input: { root: string }): string[] => {
223
+ const paths = getAllYamlPaths({
224
+ paths: getAllPathsUnderDir({ dir: input.root, skip: [] }),
225
+ });
226
+ const inSubtrees = GITHUB_SUBTREES.flatMap((subtree) =>
227
+ getAllPathsInSubtree({ paths, subtree }),
228
+ );
229
+ return getAllPathsInFixtureDirs({ paths: inSubtrees }).sort();
230
+ };
231
+
232
+ /**
233
+ * .what = the lines of a text, whatever line endings it was saved with
234
+ * .why = a `\r` left on a line breaks the ref pattern below — js regex `.` excludes line
235
+ * terminators, so `(.*)$` cannot consume it, the match fails, and the ref site is dropped
236
+ * with no signal. the drop is selective: a line with a `# <tag>` tail is lost while an
237
+ * untailed one survives, so a crlf file sheds exactly the pinned refs and leaves every
238
+ * check below green over what remains. clamped by `[case4]`
239
+ */
240
+ const asLinesFromText = (input: { text: string }): string[] => input.text.split(/\r?\n/);
241
+
242
+ /**
243
+ * .what = the lines of one file
244
+ * .why = kept as its own communicator so the split and the parse beside it stay pure and can be
245
+ * exercised against a literal text, with no file on disk
246
+ */
247
+ const getAllLinesInPath = (input: { path: string }): string[] =>
248
+ asLinesFromText({ text: readFileSync(input.path, 'utf-8') });
249
+
250
+ /**
251
+ * .what = the `uses:` ref sites among a file's lines
252
+ * .why = the pin lives in a line of yaml, not in a parsed document — so a line read is both
253
+ * sufficient and the only way to keep the line number a failure must name
254
+ * .note = the ref appears in two yaml shapes — `uses:` under a named step, and `- uses:`
255
+ * as the list item itself. a pattern anchored on `uses:` alone misses the second
256
+ * .note = `path` is carried through rather than read here, so this stays pure: it labels each
257
+ * ref with the file it came from without any knowledge of how that file was obtained
258
+ */
259
+ const asActionRefsFromLines = (input: { path: string; lines: string[] }): ActionRef[] =>
260
+ input.lines.flatMap((text, index) => {
261
+ const [, ref, comment] = /^\s*(?:-\s+)?uses:\s*(\S+)\s*(.*)$/.exec(text) ?? [];
262
+ if (!ref) return [];
263
+ return [{ path: input.path, line: index + 1, ref, comment: (comment ?? '').trim() }];
264
+ });
265
+
266
+ /**
267
+ * .what = every `uses:` ref site declared in one template
268
+ * .why = the one place the read and the parse meet — each line names what happens, never how
269
+ */
270
+ const getAllActionRefsInPath = (input: { path: string }): ActionRef[] =>
271
+ asActionRefsFromLines({ path: input.path, lines: getAllLinesInPath(input) });
272
+
273
+ /**
274
+ * .what = whether the ref points at a file in the consumer's own repo
275
+ * .why = a local ref carries no supply chain, so github's pin control exempts it
276
+ */
277
+ const isActionRefLocal = (input: { ref: ActionRef }): boolean => input.ref.ref.startsWith('./');
278
+
279
+ /**
280
+ * .what = every third-party ref site under a root, with the local ones dropped
281
+ * .why = every assertion below reads this same set, and a local `./` ref carries no supply
282
+ * chain — github's control exempts it, so a clamp that flagged one would be wrong
283
+ */
284
+ const getAllRemoteActionRefsUnderRoot = (input: { root: string }): ActionRef[] =>
285
+ getAllGithubYamlPathsUnderRoot(input)
286
+ .flatMap((path) => getAllActionRefsInPath({ path }))
287
+ .filter((ref) => !isActionRefLocal({ ref }));
288
+
289
+ /**
290
+ * .what = the two halves of a ref — what it points at, and which commit it is fixed to
291
+ * .why = both halves are read by two assertions apiece, and an index into a `split('@')`
292
+ * result reads as decode-friction wherever it appears
293
+ */
294
+ const asActionRefParts = (input: { ref: ActionRef }): { path: string; version: string } => {
295
+ const [path = '', version = ''] = input.ref.ref.split('@');
296
+ return { path, version };
297
+ };
298
+
299
+ /**
300
+ * .what = the action repo a ref names, without the subpath
301
+ * .why = `actions/cache/restore` and `actions/cache/save` are two paths into ONE repo, so
302
+ * they must map to one sha. a key on the full path would let them drift apart
303
+ */
304
+ const asActionRepo = (input: { ref: ActionRef }): string =>
305
+ asActionRefParts(input).path.split('/').slice(0, 2).join('/');
306
+
307
+ /**
308
+ * .what = the tag the sha was derived from, read off the comment beside it
309
+ * .why = the tag records provenance, not a version — which mutable tag this immutable sha
310
+ * was derived from, on the day it was derived. github never reads it; a human does
311
+ * .note = the tag is a PREFIX of the comment, not the whole of it — one ref site carries a
312
+ * marketplace url after its tag
313
+ */
314
+ const asActionRefTag = (input: { ref: ActionRef }): string | null =>
315
+ /^#\s*(\S+)/.exec(input.ref.comment)?.[1] ?? null;
316
+
317
+ /**
318
+ * .what = whether the ref names a full-length commit sha rather than a tag
319
+ * .why = both surfaces are held to ONE predicate, not to two that read alike. a check that
320
+ * drifted laxer on this repo's own workflows would break the release with a green suite
321
+ */
322
+ const isActionRefPinned = (input: { ref: ActionRef }): boolean =>
323
+ /^[\w.-]+\/[\w.-]+(?:\/[\w.-]+)*@[0-9a-f]{40}$/.test(input.ref.ref);
324
+
325
+ /**
326
+ * .what = whether the ref records which tag its sha came from
327
+ * .why = the sha alone is unreadable; the tail is what lets a human map it back to a version
328
+ * .note = a sha repeated as its own tag records no provenance, so it reads as untailed
329
+ */
330
+ const isActionRefTailed = (input: { ref: ActionRef }): boolean => {
331
+ const tag = asActionRefTag(input);
332
+ return !!tag && !/^[0-9a-f]{40}$/.test(tag);
333
+ };
334
+
335
+ /**
336
+ * .what = a ref site named as `<path>:<line> — <ref>`, path relative to the repo root
337
+ * .why = a failure prints a list of these, so each entry must be enough to open the file at
338
+ * the exact line without a search (`rule.require.errors-name-the-fix`)
339
+ * .note = the path is RELATIVIZED, and that is not cosmetic. an absolute path names the machine
340
+ * that ran the suite, so the same defect reads differently for every maintainer and for
341
+ * ci, and the prefix that carries no information is the widest part of the line. a
342
+ * reviewer who reads a ci log wants `src/practices/…/review.yml:18`, which is also what
343
+ * an editor and a `git` path accept verbatim
344
+ */
345
+ const asRefLabel = (input: { ref: ActionRef }): string =>
346
+ `${relative(REPO_ROOT, input.ref.path)}:${input.ref.line} — ${input.ref.ref}`;
347
+
348
+ /**
349
+ * .what = each action repo named once, however many ref sites point at it
350
+ * .why = the one-sha-per-repo check iterates repos, not refs — 85 ref sites collapse to 12 repos,
351
+ * and a repo named twice would compare its shas against themselves
352
+ */
353
+ const getAllActionRepos = (input: { refs: ActionRef[] }): string[] => [
354
+ ...new Set(input.refs.map((ref) => asActionRepo({ ref }))),
355
+ ];
356
+
357
+ /**
358
+ * .what = every ref site that points at one action repo
359
+ * .why = two readers need this set — the sha check and the site count — and a predicate copied
360
+ * into both is a predicate that can drift between them
361
+ */
362
+ const getAllRefsForActionRepo = (input: { refs: ActionRef[]; repo: string }): ActionRef[] =>
363
+ input.refs.filter((ref) => asActionRepo({ ref }) === input.repo);
364
+
365
+ /**
366
+ * .what = every distinct sha that one action repo is pinned to, sorted
367
+ * .why = a repo pinned consistently yields exactly one; two or more is the drift. sorted, so a
368
+ * failure reports the pair in a stable order rather than in file-walk order
369
+ */
370
+ const getAllShasForActionRepo = (input: { refs: ActionRef[]; repo: string }): string[] =>
371
+ [
372
+ ...new Set(
373
+ getAllRefsForActionRepo(input).map((ref) => asActionRefParts({ ref }).version),
374
+ ),
375
+ ].sort();
376
+
377
+ /**
378
+ * .what = one line per action repo — `<repo>@<sha> — N sites`
379
+ * .why = the six other assertions are booleans over derived sets, so a reviewer sees that they
380
+ * passed and never sees WHAT they passed over. this is the artifact a human reads: a bump
381
+ * shows as a moved sha and a site count, rather than as an opaque green tick
382
+ * .note = the site count is a second, differently-derived witness to the ref total — it is summed
383
+ * per repo here, and counted per file by the walk
384
+ */
385
+ const asPinTable = (input: { refs: ActionRef[] }): string[] =>
386
+ getAllActionRepos(input)
387
+ .sort()
388
+ .map((repo) => {
389
+ const shas = getAllShasForActionRepo({ refs: input.refs, repo });
390
+ const sites = getAllRefsForActionRepo({ refs: input.refs, repo }).length;
391
+ return `${repo}@${shas.join(' + ')} — ${sites} site${sites === 1 ? '' : 's'}`;
392
+ });
393
+
394
+ /**
395
+ * .what = the pinned templates whose delivery runs through a HAND-WRITTEN fix, rather than
396
+ * declapract's built-in `EQUALS` default
397
+ * .why = a template with no `.declapract.ts` companion is delivered by declapract's own code, and
398
+ * one integration proof covers the whole class. a template WITH one is delivered by code
399
+ * this repo owns, so it can regress on its own and needs its own proof
400
+ * .note = detected from the filesystem rather than listed, so the question is asked of the tree
401
+ * as it stands. the alternative — a hand-kept list — would answer for the tree as it stood
402
+ * when someone last remembered to edit it
403
+ */
404
+ const getAllTemplatesWithCustomFix = (input: { paths: string[] }): string[] =>
405
+ input.paths.filter((path) => existsSync(`${path}.declapract.ts`));
406
+
407
+ /**
408
+ * .what = every action repo pinned to more than one sha, named with the shas that disagree
409
+ * .why = the drift that actually happens is a bump that reaches some ref sites and misses others
410
+ * .note = this runs over the UNION of both surfaces, so a template bumped without this repo's own
411
+ * workflow (or the reverse) is caught — which is the drift a per-surface check cannot see
412
+ * .note = only PINNED refs are compared, so this reports one cause and names one remedy. an
413
+ * unpinned `@v4` is a different defect with a different repair, and each surface already
414
+ * has an assertion built for it that names the ref site — so to compare tags here would
415
+ * re-report a caught defect under a remedy that does not apply to it ("move every site of
416
+ * that repo together" when no bump ever ran). measured: a new template with two tag refs
417
+ * goes red on the unpinned assertions, and the shape of that red says `pin them`, which is
418
+ * the true move. no coverage is lost — a repo with zero pinned sites cannot drift, and a
419
+ * repo with some sites pinned and some not is caught by the unpinned assertion, at its
420
+ * exact line
421
+ */
422
+ const getAllDriftedActionRepos = (input: { refs: ActionRef[] }): string[] => {
423
+ const refsPinned = input.refs.filter((ref) => isActionRefPinned({ ref }));
424
+ return getAllActionRepos({ refs: refsPinned })
425
+ .map((repo) => ({ repo, shas: getAllShasForActionRepo({ refs: refsPinned, repo }) }))
426
+ .filter(({ shas }) => shas.length > 1)
427
+ .map(({ repo, shas }) => `${repo} — ${shas.join(' vs ')}`);
428
+ };
429
+
430
+ /**
431
+ * .note = the test NAMES below carry the remedy, deliberately — the same discipline
432
+ * `git/.declapract.integration.test.ts` states for its own assertions. jest prints
433
+ * the full `given > when > then` path on failure, so the one string a reader is
434
+ * guaranteed to see must say WHAT TO DO, not only what broke
435
+ * (`rule.require.errors-name-the-fix`). the diff names WHICH ref site; the test
436
+ * name names the move.
437
+ */
438
+ describe('action pins', () => {
439
+ given('[case1] every yaml template declared under a practice .github tree', () => {
440
+ const scene = useBeforeAll(() => ({
441
+ paths: getAllGithubYamlPathsUnderRoot({ root: PRACTICES_DIR }),
442
+ refsRemote: getAllRemoteActionRefsUnderRoot({ root: PRACTICES_DIR }),
443
+ }));
444
+
445
+ when('[t0] the walk itself is checked for blind spots', () => {
446
+ then(
447
+ 'it reaches the dot-prefixed templates -- if red, the walk went dot-blind; use readdir, not a glob library',
448
+ () => {
449
+ const names = scene.paths.map((path) => basename(path));
450
+ expect(names).toContain('.test.yml');
451
+ expect(names).toContain('.install.yml');
452
+ expect(names).toContain('.declastruct.yml');
453
+ },
454
+ );
455
+
456
+ then(
457
+ 'it reaches the composite action definitions -- if red, `.github/actions` fell out of the subtree list; a `uses:` added there would go unguarded',
458
+ () => {
459
+ // those definitions carry zero refs today, so every OTHER assertion passes over them
460
+ // vacuously. this one fails loudly the moment the subtree drops out of the walk, which
461
+ // is the only moment that vacuity could turn into a hole. `GITHUB_SUBTREES` is shared
462
+ // by both surfaces, so one assertion guards the constant for both
463
+ const actionPaths = getAllPathsInSubtree({
464
+ paths: scene.paths,
465
+ subtree: '.github/actions',
466
+ });
467
+ expect(actionPaths).not.toEqual([]);
468
+ },
469
+ );
470
+
471
+ then(
472
+ 'it excludes the test fixtures of BOTH conventions -- if red, the walk reached a fixture; those hold unpinned refs on purpose',
473
+ () => {
474
+ expect(getAllPathsInFixtureDirs({ paths: scene.paths })).toEqual([]);
475
+ },
476
+ );
477
+
478
+ /**
479
+ * .what = the fixtures the walk skips really do carry an unpinned ref
480
+ * .why = the exclusion above is a `toEqual([])`, which a trivially satisfied predicate also
481
+ * passes. if every fixture were pinned — or deleted, or moved — that assertion would
482
+ * stay green while it guarded not one file, and the NEXT fixture to land would reopen
483
+ * the hole with no signal. this states the precondition the exclusion exists for, so
484
+ * the pair cannot go quietly vacuous
485
+ * .note = the claim is "at least one", not "none pinned". a future fixture may legitimately
486
+ * hold a pinned ref (an after-state, say), and this must not forbid that — what it
487
+ * demands is that the skip has real work to do
488
+ */
489
+ then(
490
+ 'the fixtures it excludes are genuinely unpinned -- if red, the exclusion above now guards an empty set',
491
+ () => {
492
+ const refsInFixtures = getAllGithubYamlPathsInFixtures({
493
+ root: PRACTICES_DIR,
494
+ })
495
+ .flatMap((path) => getAllActionRefsInPath({ path }))
496
+ .filter((ref) => !isActionRefLocal({ ref }));
497
+
498
+ expect(
499
+ refsInFixtures.filter((ref) => !isActionRefPinned({ ref })).length,
500
+ ).toBeGreaterThan(0);
501
+ },
502
+ );
503
+
504
+ then('it finds third-party refs to check -- if red, the walk reaches no template at all', () => {
505
+ expect(scene.refsRemote.length).toBeGreaterThan(0);
506
+ });
507
+
508
+ /**
509
+ * .what = the CLASS guard on delivery coverage — every pinned template that ships through a
510
+ * hand-written fix is one an integration test actually exercises
511
+ * .why = twice now, a delivery gap has been found one file at a time: i004 proved
512
+ * `review.yml`, and i005 found the identical gap at `.test.yml`. a third custom-fix
513
+ * template would reopen it a third time, and the review round that catches it may
514
+ * not come. this asserts the SET rather than its members, so a new one fails here —
515
+ * at author time — rather than in a consumer's dead CI
516
+ * .why = the allowlist is short because the property is rare, and that rarity is the point:
517
+ * 9 of the 10 pinned templates take declapract's default path and are covered as a
518
+ * class by any one proof of it. only a hand-written fix escapes that
519
+ * .note = the paths are relativized so a failure reads as a repo path rather than as this
520
+ * machine's home directory (`rule.require.errors-name-the-fix` — a name a reader can
521
+ * act on)
522
+ */
523
+ then(
524
+ 'every pinned template with a custom fix has integration coverage -- if red, add a case to that practice\'s .declapract.integration.test.ts, then add it here',
525
+ () => {
526
+ const pathsPinned = [
527
+ ...new Set(scene.refsRemote.map((ref) => ref.path)),
528
+ ];
529
+ const withCustomFix = getAllTemplatesWithCustomFix({
530
+ paths: pathsPinned,
531
+ })
532
+ .map((path) => relative(REPO_ROOT, path))
533
+ .sort();
534
+
535
+ expect(withCustomFix).toEqual([
536
+ // covered by `[case2]` of src/practices/cicd-common/.declapract.integration.test.ts
537
+ 'src/practices/cicd-common/best-practice/.github/workflows/.test.yml',
538
+ ]);
539
+ },
540
+ );
541
+ });
542
+
543
+ when('[t1] each third-party ref is read on its own', () => {
544
+ then(
545
+ 'it names a 40-hex commit sha -- if red, derive it per the `.how` note atop this file; an annotated tag needs a deref',
546
+ () => {
547
+ const unpinned = scene.refsRemote.filter((ref) => !isActionRefPinned({ ref }));
548
+ expect(unpinned.map((ref) => asRefLabel({ ref }))).toEqual([]);
549
+ },
550
+ );
551
+
552
+ then(
553
+ 'it carries the tag the sha was derived from -- if red, append `# <tag>` beside the sha, so the pin stays legible',
554
+ () => {
555
+ const untailed = scene.refsRemote.filter((ref) => !isActionRefTailed({ ref }));
556
+ expect(untailed.map((ref) => asRefLabel({ ref }))).toEqual([]);
557
+ },
558
+ );
559
+ });
560
+
561
+ when('[t2] the refs are read together', () => {
562
+ then(
563
+ 'the pin table reads as declared -- if the diff surprises you, a bump reached more or fewer sites than intended',
564
+ () => {
565
+ expect(asPinTable({ refs: scene.refsRemote })).toMatchSnapshot();
566
+ },
567
+ );
568
+ });
569
+ });
570
+
571
+ given("[case2] every yaml under this repo's own .github tree", () => {
572
+ const scene = useBeforeAll(() => ({
573
+ paths: getAllGithubYamlPathsUnderRoot({ root: REPO_GITHUB_DIR }),
574
+ refsRemote: getAllRemoteActionRefsUnderRoot({ root: REPO_GITHUB_DIR }),
575
+ }));
576
+
577
+ when('[t0] the walk itself is checked for blind spots', () => {
578
+ then(
579
+ "it reaches this repo's release path -- if red, the walk missed the workflows that ship the package",
580
+ () => {
581
+ const names = scene.paths.map((path) => basename(path));
582
+ expect(names).toContain('publish.yml');
583
+ expect(names).toContain('.test.yml');
584
+ },
585
+ );
586
+
587
+ then('it finds third-party refs to check -- if red, the walk reaches no workflow at all', () => {
588
+ expect(scene.refsRemote.length).toBeGreaterThan(0);
589
+ });
590
+ });
591
+
592
+ when('[t1] each third-party ref is read on its own', () => {
593
+ then(
594
+ 'it names a 40-hex commit sha -- if red, this repo cannot release; `publish` needs `test`, and `test` dies at action lookup',
595
+ () => {
596
+ const unpinned = scene.refsRemote.filter((ref) => !isActionRefPinned({ ref }));
597
+ expect(unpinned.map((ref) => asRefLabel({ ref }))).toEqual([]);
598
+ },
599
+ );
600
+
601
+ then(
602
+ 'it carries the tag the sha was derived from -- if red, append `# <tag>` beside the sha, so the pin stays legible',
603
+ () => {
604
+ const untailed = scene.refsRemote.filter((ref) => !isActionRefTailed({ ref }));
605
+ expect(untailed.map((ref) => asRefLabel({ ref }))).toEqual([]);
606
+ },
607
+ );
608
+ });
609
+
610
+ when('[t2] the refs are read together', () => {
611
+ then(
612
+ 'the pin table reads as declared -- if the diff surprises you, a bump reached more or fewer sites than intended',
613
+ () => {
614
+ expect(asPinTable({ refs: scene.refsRemote })).toMatchSnapshot();
615
+ },
616
+ );
617
+ });
618
+ });
619
+
620
+ given('[case3] both surfaces read as one corpus', () => {
621
+ const scene = useBeforeAll(() => ({
622
+ refsRemote: [
623
+ ...getAllRemoteActionRefsUnderRoot({ root: PRACTICES_DIR }),
624
+ ...getAllRemoteActionRefsUnderRoot({ root: REPO_GITHUB_DIR }),
625
+ ],
626
+ }));
627
+
628
+ when('[t0] every ref site is compared against every other', () => {
629
+ then(
630
+ 'each action repo maps to exactly one sha -- if red, a bump reached some ref sites and missed others; move every site of that repo together',
631
+ () => {
632
+ expect(getAllDriftedActionRepos({ refs: scene.refsRemote })).toEqual([]);
633
+ },
634
+ );
635
+ });
636
+ });
637
+
638
+ given('[case4] a workflow file saved with crlf endings', () => {
639
+ // exercised through the same two operations the walk composes -- text -> lines -> refs -- so
640
+ // this holds the pipeline a real crlf file would take, not one layer of it in isolation.
641
+ // no `.gitattributes` rule in this repo enforces lf for `*.yml`, so a file can arrive so
642
+ const text = [
643
+ ' - name: checkout',
644
+ ' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4',
645
+ ' uses: ./.github/workflows/.test.yml',
646
+ ].join('\r\n');
647
+
648
+ when('[t0] the text is read as refs', () => {
649
+ const refs = asActionRefsFromLines({ path: 'demo.yml', lines: asLinesFromText({ text }) });
650
+
651
+ then(
652
+ 'every ref site is still found -- if red, a crlf file drops its tailed refs in silence and every check above passes vacuously over them',
653
+ () => {
654
+ expect(refs.map((ref) => ref.ref)).toEqual([
655
+ 'actions/checkout@11d5960a326750d5838078e36cf38b85af677262',
656
+ './.github/workflows/.test.yml',
657
+ ]);
658
+ },
659
+ );
660
+
661
+ then(
662
+ 'the tag tail is read without the carriage return -- if red, the tail check would reject a legitimately pinned ref',
663
+ () => {
664
+ const [refTailed] = refs;
665
+ expect(refTailed && asActionRefTag({ ref: refTailed })).toEqual('v4');
666
+ },
667
+ );
668
+
669
+ then('the line numbers a failure would name are still right', () => {
670
+ expect(refs.map((ref) => ref.line)).toEqual([2, 3]);
671
+ });
672
+ });
673
+ });
674
+
675
+ given('[case5] a ref site whose comment carries more than the tag', () => {
676
+ // .why = the tag is a PREFIX of the comment, not the whole of it. exactly one real ref site
677
+ // is shaped so -- `.deploy-sls.yml:110`, which kept its marketplace link after the tag
678
+ // -- and `[case1][t1]` walks straight past it, because a parse that swallowed the
679
+ // whole comment would still read as tailed (a url is not a 40-hex sha). so the corpus
680
+ // exercises this shape without ever judgment on it. this case judges it.
681
+ const text = [
682
+ ' - name: alert pagerduty',
683
+ ' uses: Entle/action-pagerduty-alert@e6ca54cd88948b8a50f5bb1071dfa5bdb149ce7e # 0.2.0 — https://github.com/marketplace/actions/pagerduty-alert',
684
+ ].join('\n');
685
+
686
+ when('[t0] the tag is read off the comment', () => {
687
+ const refs = asActionRefsFromLines({ path: 'demo.yml', lines: asLinesFromText({ text }) });
688
+
689
+ then(
690
+ 'the tag is the prefix alone -- if red, the whole comment is read as the tag and a bump would match on a url',
691
+ () => {
692
+ const [ref] = refs;
693
+ expect(ref && asActionRefTag({ ref })).toEqual('0.2.0');
694
+ },
695
+ );
696
+
697
+ then('and the ref still reads as tailed', () => {
698
+ const [ref] = refs;
699
+ expect(ref && isActionRefTailed({ ref })).toEqual(true);
700
+ });
701
+ });
702
+ });
703
+
704
+ given('[case6] a corpus that is defective on purpose, one line per defect', () => {
705
+ // .why = every assertion above proves the GREEN state — the list is empty, the table reads as
706
+ // declared. not one of them proves what a maintainer READS when it goes red, and that
707
+ // text is the entire ergonomic surface of this clamp: it is what a ci log carries at
708
+ // the moment someone has to act on it. so this case builds the red state deliberately
709
+ // and snapshots the failure output itself, per `rule.require.contract-snapshot-exhaustiveness`.
710
+ // .note = the shas below are SYNTHETIC and unmistakably so (`deadbeef…`, `cafebabe…`). they
711
+ // are chosen to be greppable, so a reader who meets one in a diff can confirm in one
712
+ // search that no template carries it. `11d5960a…` is the one real sha here, present so
713
+ // the drift pair reads as a real bump half-applied rather than as two fakes.
714
+ // .note = the path is a REAL path under this repo's tree, and that is the point. `asRefLabel`
715
+ // relativizes against the repo root, so a snapshot that stays byte-stable across
716
+ // machines IS the proof of that relativization. before it was relativized, this
717
+ // snapshot would have carried whoever last ran the suite — their home directory,
718
+ // committed, and re-recorded by the next maintainer as a phantom diff
719
+ const path = join(PRACTICES_DIR, 'cicd-demo/best-practice/.github/workflows/deploy.yml');
720
+ const text = [
721
+ ' - uses: actions/checkout@v4',
722
+ ' - uses: actions/setup-node@cafebabecafebabecafebabecafebabecafebabe',
723
+ ' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4',
724
+ ' - uses: actions/checkout@deadbeefdeadbeefdeadbeefdeadbeefdeadbeef # v4',
725
+ ' - uses: pnpm/action-setup@b906aff # v4',
726
+ ' - uses: ./.github/actions/bundle',
727
+ ].join('\n');
728
+
729
+ when('[t0] the defective corpus is read the way the walk reads a real one', () => {
730
+ const refs = asActionRefsFromLines({ path, lines: asLinesFromText({ text }) }).filter(
731
+ (ref) => !isActionRefLocal({ ref }),
732
+ );
733
+
734
+ then(
735
+ 'the unpinned report names each offender with a path an editor accepts -- if this snapshot ever carries an absolute path, the label named the machine that ran the suite',
736
+ () => {
737
+ expect(
738
+ refs.filter((ref) => !isActionRefPinned({ ref })).map((ref) => asRefLabel({ ref })),
739
+ ).toMatchSnapshot('unpinned -- the report a tag ref and a short sha produce');
740
+ },
741
+ );
742
+
743
+ then('the untailed report names each ref whose sha records no provenance', () => {
744
+ expect(
745
+ refs.filter((ref) => !isActionRefTailed({ ref })).map((ref) => asRefLabel({ ref })),
746
+ ).toMatchSnapshot('untailed -- the report a bare sha produces');
747
+ });
748
+
749
+ then(
750
+ 'the drift report names the repo and both shas -- which is what tells a maintainer the bump reached some sites and missed others',
751
+ () => {
752
+ expect(getAllDriftedActionRepos({ refs })).toMatchSnapshot(
753
+ 'drifted -- the report a half-applied bump produces',
754
+ );
755
+ },
756
+ );
757
+
758
+ then('and the pin table still reads, defects and all', () => {
759
+ expect(asPinTable({ refs })).toMatchSnapshot('the pin table over a defective corpus');
760
+ });
761
+ });
762
+
763
+ when('[t1] the corpus is empty', () => {
764
+ // .why = the edge a fresh practice with no workflows hits, and the one shape under which
765
+ // every check above passes over a set of zero. it is snapped rather than asserted
766
+ // empty so the four reports are shown to be EMPTY, not ABSENT — a report that
767
+ // vanished on an empty corpus would read as the same green as one that found no
768
+ // offender at all
769
+ const refs: ActionRef[] = [];
770
+
771
+ then('every report reads as empty rather than absent', () => {
772
+ expect({
773
+ unpinned: refs
774
+ .filter((ref) => !isActionRefPinned({ ref }))
775
+ .map((ref) => asRefLabel({ ref })),
776
+ untailed: refs
777
+ .filter((ref) => !isActionRefTailed({ ref }))
778
+ .map((ref) => asRefLabel({ ref })),
779
+ drifted: getAllDriftedActionRepos({ refs }),
780
+ table: asPinTable({ refs }),
781
+ }).toMatchSnapshot('the reports over a corpus of zero refs');
782
+ });
783
+ });
784
+ });
785
+ });