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.
- package/dist/__snapshots__/actionPins.declapract.integration.test.ts.snap +70 -0
- package/dist/actionPins.declapract.integration.test.ts +785 -0
- package/dist/practices/cicd-app-react-native-expo/best-practice/.github/workflows/.deploy-expo.yml +10 -10
- package/dist/practices/cicd-app-react-native-expo/best-practice/.github/workflows/deploy.yml +4 -0
- package/dist/practices/cicd-app-react-native-expo/best-practice/.github/workflows/test.yml +4 -0
- package/dist/practices/cicd-common/.declapract.integration.test.ts +572 -0
- package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/.github/workflows/.test.yml +19 -0
- package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/.github/workflows/consumer-owned.yml +21 -0
- package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/.github/workflows/review.yml +26 -0
- package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/declapract.use.yml +6 -0
- package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/package.json +4 -0
- package/dist/practices/cicd-common/__snapshots__/.declapract.integration.test.ts.snap +550 -0
- package/dist/practices/cicd-common/best-practice/.declapract.readme.md +5 -0
- package/dist/practices/cicd-common/best-practice/.github/workflows/.declastruct.yml +12 -12
- package/dist/practices/cicd-common/best-practice/.github/workflows/.install.yml +5 -5
- package/dist/practices/cicd-common/best-practice/.github/workflows/.test.yml +25 -25
- package/dist/practices/cicd-common/best-practice/.github/workflows/release.yml +2 -2
- package/dist/practices/cicd-common/best-practice/.github/workflows/review.yml +1 -1
- package/dist/practices/cicd-package/best-practice/.github/workflows/.publish-npm.yml +3 -3
- package/dist/practices/cicd-package/best-practice/.github/workflows/provision.yml +4 -0
- package/dist/practices/cicd-service/best-practice/.github/workflows/.deploy-sls.yml +13 -13
- package/dist/practices/cicd-service/best-practice/.github/workflows/.sql-schema-control.yml +8 -8
- package/dist/practices/cicd-service/best-practice/.github/workflows/.terraform.yml +6 -6
- package/dist/practices/cicd-service/best-practice/.github/workflows/provision.yml +4 -0
- 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
|
+
});
|