declapract-typescript-ehmpathy 0.49.8 → 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 (38) 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/.declapract.readme.md +27 -0
  4. package/dist/practices/cicd-app-react-native-expo/best-practice/.github/workflows/.deploy-expo.yml +10 -10
  5. package/dist/practices/cicd-app-react-native-expo/best-practice/.github/workflows/deploy.yml +4 -0
  6. package/dist/practices/cicd-app-react-native-expo/best-practice/.github/workflows/test.yml +4 -0
  7. package/dist/practices/cicd-app-react-native-expo/best-practice/.gitignore.declapract.ts +18 -11
  8. package/dist/practices/cicd-common/.declapract.integration.test.ts +572 -0
  9. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/.github/workflows/.test.yml +19 -0
  10. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/.github/workflows/consumer-owned.yml +21 -0
  11. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/.github/workflows/review.yml +26 -0
  12. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/declapract.use.yml +6 -0
  13. package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/package.json +4 -0
  14. package/dist/practices/cicd-common/__snapshots__/.declapract.integration.test.ts.snap +550 -0
  15. package/dist/practices/cicd-common/best-practice/.declapract.readme.md +5 -0
  16. package/dist/practices/cicd-common/best-practice/.github/workflows/.declastruct.yml +12 -12
  17. package/dist/practices/cicd-common/best-practice/.github/workflows/.install.yml +5 -5
  18. package/dist/practices/cicd-common/best-practice/.github/workflows/.test.yml +25 -25
  19. package/dist/practices/cicd-common/best-practice/.github/workflows/release.yml +2 -2
  20. package/dist/practices/cicd-common/best-practice/.github/workflows/review.yml +1 -1
  21. package/dist/practices/cicd-package/best-practice/.github/workflows/.publish-npm.yml +3 -3
  22. package/dist/practices/cicd-package/best-practice/.github/workflows/provision.yml +4 -0
  23. package/dist/practices/cicd-service/best-practice/.github/workflows/.deploy-sls.yml +13 -13
  24. package/dist/practices/cicd-service/best-practice/.github/workflows/.sql-schema-control.yml +8 -8
  25. package/dist/practices/cicd-service/best-practice/.github/workflows/.terraform.yml +6 -6
  26. package/dist/practices/cicd-service/best-practice/.github/workflows/provision.yml +4 -0
  27. package/dist/practices/git/.declapract.integration.test.ts +950 -0
  28. package/dist/practices/git/.test/assets/repo-with-rhachet-only/declapract.use.yml +6 -0
  29. package/dist/practices/git/.test/assets/repo-with-rhachet-only/package.json +4 -0
  30. package/dist/practices/git/.test/assets/repo-without-cache-ignores/declapract.use.yml +9 -0
  31. package/dist/practices/git/.test/assets/repo-without-cache-ignores/package.json +4 -0
  32. package/dist/practices/git/__snapshots__/.declapract.integration.test.ts.snap +51 -0
  33. package/dist/practices/git/best-practice/.declapract.readme.md +23 -0
  34. package/dist/practices/git/best-practice/.gitignore.declapract.ts +59 -33
  35. package/dist/practices/rhachet/best-practice/.declapract.readme.md +32 -0
  36. package/dist/practices/rhachet/best-practice/.gitignore.declapract.ts +64 -0
  37. package/dist/utils/defineExpectedGitignoreContents.ts +84 -0
  38. package/package.json +7 -7
@@ -0,0 +1,572 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { executeApply } from 'declapract';
5
+ import { genTempDir, given, then, useBeforeAll, useThen, when } from 'test-fns';
6
+
7
+ // executeApply is slow (full practice evaluation before the file filter narrows it)
8
+ // .note = this suite scopes each apply with `file: '.github/workflows/review.yml'`, which
9
+ // narrows the work enough that a run settles in seconds. the cap matches the
10
+ // single-apply suites rather than a multiple of them, so a hang surfaces promptly
11
+ // instead of after a budget sized for a slowness we do not have.
12
+ jest.setTimeout(300_000); // 5 minutes
13
+
14
+ /**
15
+ * .what = the template a consumer is held to, read from the practice as it ships
16
+ * .why = read rather than transcribed. a hand-copied expectation is a SECOND source of truth
17
+ * that drifts the moment the template is bumped, and this repo already carries a live
18
+ * instance of exactly that decay: `.test.yml.declapract.test.ts` hand-writes a
19
+ * `templateContent` string that still says `actions/checkout@v4` while the real
20
+ * template says `@11d5960…`. it passes, because it only ever compares its copy to
21
+ * itself. read the file and the drift is unrepresentable
22
+ */
23
+ const PATH_OF_TEMPLATE = path.join(
24
+ __dirname,
25
+ 'best-practice/.github/workflows/review.yml',
26
+ );
27
+
28
+ const PATH_IN_CONSUMER = '.github/workflows/review.yml';
29
+
30
+ /**
31
+ * .what = the one pinned template whose delivery runs through a HAND-WRITTEN fix
32
+ * .why = `[case2]` exists for this file alone. see its docblock for why the tenth template
33
+ * cannot inherit the ninth's proof
34
+ */
35
+ const PATH_OF_TEST_TEMPLATE = path.join(
36
+ __dirname,
37
+ 'best-practice/.github/workflows/.test.yml',
38
+ );
39
+
40
+ const PATH_OF_TEST_IN_CONSUMER = '.github/workflows/.test.yml';
41
+
42
+ /**
43
+ * .what = a workflow in the consumer's repo that NO practice declares
44
+ * .why = `[case3]` proves the practice leaves it alone. it carries tag refs on purpose
45
+ */
46
+ const PATH_OF_CONSUMER_OWNED = '.github/workflows/consumer-owned.yml';
47
+
48
+ /**
49
+ * .what = the lines of a workflow that name a third-party action, in either yaml form —
50
+ * `uses: <owner>/<repo>@<ref>` and its `- uses:` sequence-item variant
51
+ * .why = a count of these is how a TRUNCATED delivery is caught. the `@` is what bounds the
52
+ * set to third-party refs: a local ref (`uses: ./.github/workflows/.install.yml`)
53
+ * names a path rather than a version, so it carries no `@` and is never counted
54
+ */
55
+ const getAllActionRefLines = (input: { contents: string }): string[] =>
56
+ input.contents
57
+ .split('\n')
58
+ .filter((line) => /^\s*(?:-\s+)?uses:\s*\S+\/\S+@/.test(line));
59
+
60
+ /**
61
+ * .what = the wish's second acceptance criterion, proven by an executed pipeline
62
+ * .why = "a repo that runs the upgrade against this template comes away with its
63
+ * template-owned refs pinned, WITHOUT A HAND-EDIT". every other clamp on the pin is
64
+ * static — `src/actionPins.declapract.integration.test.ts` proves the templates in THIS repo hold
65
+ * pinned refs, and that is a claim about files at rest. it has no word on whether
66
+ * declapract delivers them
67
+ * .why = and the delivery was, until this suite, an INFERENCE: the vision verified it by a
68
+ * read of declapract's source (the `EQUALS` default plus its whole-file fix), never by
69
+ * a run against these templates. a source read proves what the code does; it does not
70
+ * prove this practice is wired to that path. the two l3 reviewers named the gap
71
+ * independently, and they were right — a reasoned delivery is not a delivered delivery
72
+ * .note = `review.yml` is the subject because it is the smallest template that carries a
73
+ * third-party ref (exactly 1, of 85). the mechanism under test is declapract's file
74
+ * delivery, which does not vary with a file's size — so the cheapest template proves
75
+ * it as well as the largest, and keeps the apply quick
76
+ */
77
+ describe('cicd-common workflow templates', () => {
78
+ given('[case1] a consumer repo whose workflow still carries a tag ref', () => {
79
+ const tempDir = genTempDir({
80
+ slug: 'cicd-common-action-pins',
81
+ clone:
82
+ './src/practices/cicd-common/.test/assets/repo-with-unpinned-workflow',
83
+ symlink: [
84
+ { at: 'declarations', to: './.test/assets/cicd-common/declarations' },
85
+ { at: 'node_modules', to: 'node_modules' },
86
+ ],
87
+ });
88
+
89
+ when('[t0] before any apply', () => {
90
+ /**
91
+ * .why = a diff is only legible against a before, per
92
+ * rule.require.declapract-integration-tests. this one carries a second duty: it
93
+ * states that the fixture really is unpinned, so the after-assertion below is a
94
+ * measured CHANGE rather than a file that was already correct
95
+ */
96
+ then('the input file matches snapshot', async () => {
97
+ const contents = await fs.readFile(
98
+ path.join(tempDir, PATH_IN_CONSUMER),
99
+ 'utf-8',
100
+ );
101
+
102
+ expect(contents).toMatchSnapshot('review.yml -- before');
103
+ });
104
+
105
+ then('the consumer carries an unpinned tag ref -- the state the org control kills', async () => {
106
+ const contents = await fs.readFile(
107
+ path.join(tempDir, PATH_IN_CONSUMER),
108
+ 'utf-8',
109
+ );
110
+
111
+ expect(contents).toContain('amannn/action-semantic-pull-request@v5');
112
+ });
113
+ });
114
+
115
+ when('[t1] the cicd-common practice is applied', () => {
116
+ useBeforeAll(async () => {
117
+ await executeApply({
118
+ config: path.join(tempDir, 'declapract.use.yml'),
119
+ practice: 'cicd-common',
120
+ file: PATH_IN_CONSUMER,
121
+ });
122
+ }, 120_000);
123
+
124
+ // .note = one read, shared across the assertions below, per
125
+ // rule.prefer.usethen-and-usewhen-for-shared-results. the value is wrapped in an
126
+ // object because useThen hands back a proxy, and a bare string proxy compares as
127
+ // a char-indexed object.
128
+ const fileAfter = useThen('the apply settles', async () => ({
129
+ contents: await fs.readFile(
130
+ path.join(tempDir, PATH_IN_CONSUMER),
131
+ 'utf-8',
132
+ ),
133
+ }));
134
+
135
+ /**
136
+ * .what = THE acceptance criterion — the consumer's file now equals the template's
137
+ * .why = a `toContain` on the sha would prove the pin arrived and stay silent on the rest
138
+ * of the file. byte-equality is the actual promise declapract's `EQUALS` default
139
+ * makes, and it is what lets this repo be the single place that has to be right:
140
+ * whatever the template says, the consumer receives
141
+ * .note = the template is read from disk on BOTH sides, so a future bump moves the
142
+ * expectation with the artifact and this assertion never needs a hand-edit —
143
+ * which is the same property the criterion claims for the consumer
144
+ */
145
+ then(
146
+ "the consumer's file is byte-identical to the template -- if red, declapract did not deliver the template verbatim",
147
+ async () => {
148
+ const expected = await fs.readFile(PATH_OF_TEMPLATE, 'utf-8');
149
+
150
+ expect(fileAfter.contents).toEqual(expected);
151
+ },
152
+ );
153
+
154
+ /**
155
+ * .why = stated in its own right, rather than left implied by byte-equality. the wish is
156
+ * about the PIN, and a failure that names the pin sends a reader to the right
157
+ * place; a byte diff alone reads as "the file changed somehow"
158
+ * (`rule.require.errors-name-the-fix`)
159
+ * .note = it is also the only assertion here that can see an unpinned TEMPLATE, and that
160
+ * asymmetry was measured rather than assumed. the two injections:
161
+ * apply scoped away from this file -> 4 red, byte-equality among them
162
+ * template reverted to `@v5` -> 2 red, byte-equality stays GREEN
163
+ * byte-equality reads the template on BOTH sides, so it proves DELIVERY and is
164
+ * blind to what was delivered. this line and the snapshot carry the other half.
165
+ * the primary guard on pinnedness is `src/actionPins.declapract.integration.test.ts`, over
166
+ * all 85 template refs; this is the one that catches it here, in the pipeline
167
+ */
168
+ then('the tag ref is gone, and a pinned ref stands where it was', () => {
169
+ expect(fileAfter.contents).not.toContain(
170
+ 'amannn/action-semantic-pull-request@v5\n',
171
+ );
172
+ expect(fileAfter.contents).toContain(
173
+ 'amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5',
174
+ );
175
+ });
176
+
177
+ then('no declapract template syntax leaked into the output', () => {
178
+ expect(fileAfter.contents).not.toContain('@declapract{');
179
+ });
180
+
181
+ then('the settled file matches snapshot', () => {
182
+ expect(fileAfter.contents).toMatchSnapshot('review.yml -- after');
183
+ });
184
+ });
185
+
186
+ /**
187
+ * .what = the fixed point, per rule.require.idempotent-fixes
188
+ * .why = a consumer runs the upgrade on every bump, not once. a fix that lands correctly
189
+ * and then rewrites the file on each later run would churn a diff into every
190
+ * consumer's repo forever, and the wish's "without a hand-edit" quietly becomes
191
+ * "with a hand-revert"
192
+ */
193
+ when('[t2] the practice is applied a second time', () => {
194
+ const fileBefore = useBeforeAll(async () => ({
195
+ contents: await fs.readFile(
196
+ path.join(tempDir, PATH_IN_CONSUMER),
197
+ 'utf-8',
198
+ ),
199
+ }));
200
+
201
+ useBeforeAll(async () => {
202
+ await executeApply({
203
+ config: path.join(tempDir, 'declapract.use.yml'),
204
+ practice: 'cicd-common',
205
+ file: PATH_IN_CONSUMER,
206
+ });
207
+ }, 120_000);
208
+
209
+ then('the file is byte-identical -- the apply is a fixed point', async () => {
210
+ const contentsAfterRerun = await fs.readFile(
211
+ path.join(tempDir, PATH_IN_CONSUMER),
212
+ 'utf-8',
213
+ );
214
+
215
+ expect(contentsAfterRerun).toEqual(fileBefore.contents);
216
+ });
217
+ });
218
+ });
219
+
220
+ /**
221
+ * .what = the same delivery proof, for the ONE template that does not take declapract's
222
+ * default path
223
+ * .why = `[case1]` justified its choice of `review.yml` on the ground that "the mechanism does
224
+ * not vary with a file's size". that holds for 9 of the 10 pinned templates — the ones
225
+ * with no `.declapract.ts` companion, which all reach delivery through declapract's
226
+ * built-in `EQUALS` default. it does NOT hold for `.test.yml`, the tenth: it carries an
227
+ * explicit, HAND-WRITTEN `fix`, so its delivery runs through code this repo owns rather
228
+ * than code declapract owns. a source read of declapract cannot vouch for that at all
229
+ * .why = and it is the file that matters most, not an edge case:
230
+ * - 25 of the 85 template pin sites live here — 29%, the largest concentration
231
+ * - it is the workflow the wish's `.why` is ABOUT: `publish` needs `test`, so a tag
232
+ * ref here kills the release before any template reaches a consumer
233
+ * - it is the only template whose fix could regress independently of declapract
234
+ * .note = the custom fix returns `context.declaredFileContents ?? ''`. that `?? ''` writes an
235
+ * EMPTY FILE rather than fail, if the declared contents were ever absent — a fail-hide
236
+ * no unit test with a mocked context can see, because the mock supplies the value. this
237
+ * case runs the real pipeline, so an empty delivery goes red on byte-equality
238
+ */
239
+ given('[case2] a consumer whose .test.yml is stale, on the custom-fix path', () => {
240
+ const tempDir = genTempDir({
241
+ slug: 'cicd-common-action-pins-test-yml',
242
+ clone:
243
+ './src/practices/cicd-common/.test/assets/repo-with-unpinned-workflow',
244
+ symlink: [
245
+ { at: 'declarations', to: './.test/assets/cicd-common/declarations' },
246
+ { at: 'node_modules', to: 'node_modules' },
247
+ ],
248
+ });
249
+
250
+ when('[t0] before any apply', () => {
251
+ then('the input file matches snapshot', async () => {
252
+ const contents = await fs.readFile(
253
+ path.join(tempDir, PATH_OF_TEST_IN_CONSUMER),
254
+ 'utf-8',
255
+ );
256
+
257
+ expect(contents).toMatchSnapshot('.test.yml -- before');
258
+ });
259
+
260
+ then('the consumer carries unpinned tag refs -- the state the org control kills', async () => {
261
+ const contents = await fs.readFile(
262
+ path.join(tempDir, PATH_OF_TEST_IN_CONSUMER),
263
+ 'utf-8',
264
+ );
265
+
266
+ expect(contents).toContain('actions/checkout@v4');
267
+ expect(contents).toContain('actions/setup-node@v4');
268
+ });
269
+ });
270
+
271
+ when('[t1] the cicd-common practice is applied', () => {
272
+ useBeforeAll(async () => {
273
+ await executeApply({
274
+ config: path.join(tempDir, 'declapract.use.yml'),
275
+ practice: 'cicd-common',
276
+ file: PATH_OF_TEST_IN_CONSUMER,
277
+ });
278
+ }, 120_000);
279
+
280
+ const fileAfter = useThen('the apply settles', async () => ({
281
+ contents: await fs.readFile(
282
+ path.join(tempDir, PATH_OF_TEST_IN_CONSUMER),
283
+ 'utf-8',
284
+ ),
285
+ }));
286
+
287
+ /**
288
+ * .why = the same byte-equality claim as `[case1]`, but here it clears a genuinely
289
+ * different mechanism: the hand-written fix, not declapract's default. it is also
290
+ * the assertion that catches the `?? ''` empty-file path, which would otherwise
291
+ * deliver a zero-byte workflow to a consumer with every unit test still green
292
+ */
293
+ then(
294
+ "the consumer's file is byte-identical to the template -- if red, the custom fix did not deliver the template verbatim",
295
+ async () => {
296
+ const expected = await fs.readFile(PATH_OF_TEST_TEMPLATE, 'utf-8');
297
+
298
+ expect(fileAfter.contents).toEqual(expected);
299
+ },
300
+ );
301
+
302
+ /**
303
+ * .why = stated of the pins in their own right, and over MORE than one action — this file
304
+ * carries 25 ref sites across 6 action repos, so a partial delivery is a shape a
305
+ * one-ref assertion could not see
306
+ */
307
+ then('every tag ref is gone, and pins stand where they were', () => {
308
+ expect(fileAfter.contents).not.toContain('actions/checkout@v4');
309
+ expect(fileAfter.contents).not.toContain('actions/setup-node@v4');
310
+ expect(fileAfter.contents).toContain(
311
+ 'actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4',
312
+ );
313
+ expect(fileAfter.contents).toContain(
314
+ 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4',
315
+ );
316
+ });
317
+
318
+ /**
319
+ * .why = the delivery is only worth as much as its completeness. a fix that shipped a
320
+ * TRUNCATED template would satisfy a `toContain` on the two refs above while it
321
+ * silently dropped the other 23 — and `?? ''` proves this fix has a path that
322
+ * returns less than the whole file
323
+ */
324
+ then('all 25 ref sites arrived -- if red, the fix delivered a partial file', () => {
325
+ const sites = getAllActionRefLines({ contents: fileAfter.contents });
326
+
327
+ expect(sites.length).toEqual(25);
328
+ });
329
+
330
+ then('no declapract template syntax leaked into the output', () => {
331
+ expect(fileAfter.contents).not.toContain('@declapract{');
332
+ });
333
+
334
+ /**
335
+ * .why = the wish's third acceptance criterion — "the pin is LEGIBLE: a reader can tell
336
+ * WHICH version each SHA corresponds to" — is the one criterion no assertion can
337
+ * settle, because legibility is a property a human reads rather than a machine
338
+ * checks. a snapshot is what puts the `@<sha> # <tag>` shape in front of a
339
+ * reviewer, per `rule.require.snapshots`
340
+ * .note = and this file is the only place that criterion can be eyeballed at scale. the
341
+ * committed pin table in `src/actionPins.declapract.integration.test.ts` deliberately records
342
+ * `<repo>@<sha> — N sites` WITHOUT the tag tail, so it cannot show a tail arrive.
343
+ * 25 sites across 6 action repos land here, in the workflow that gates `publish`
344
+ * — the file the wish's `.why` is about
345
+ */
346
+ then('the settled file matches snapshot', () => {
347
+ expect(fileAfter.contents).toMatchSnapshot('.test.yml -- after');
348
+ });
349
+ });
350
+
351
+ when('[t2] the practice is applied a second time', () => {
352
+ const fileBefore = useBeforeAll(async () => ({
353
+ contents: await fs.readFile(
354
+ path.join(tempDir, PATH_OF_TEST_IN_CONSUMER),
355
+ 'utf-8',
356
+ ),
357
+ }));
358
+
359
+ useBeforeAll(async () => {
360
+ await executeApply({
361
+ config: path.join(tempDir, 'declapract.use.yml'),
362
+ practice: 'cicd-common',
363
+ file: PATH_OF_TEST_IN_CONSUMER,
364
+ });
365
+ }, 120_000);
366
+
367
+ then('the file is byte-identical -- the custom fix is a fixed point', async () => {
368
+ const contentsAfterRerun = await fs.readFile(
369
+ path.join(tempDir, PATH_OF_TEST_IN_CONSUMER),
370
+ 'utf-8',
371
+ );
372
+
373
+ expect(contentsAfterRerun).toEqual(fileBefore.contents);
374
+ });
375
+ });
376
+ });
377
+
378
+ /**
379
+ * .what = the wish's second CONSTRAINT, which is a bound on the fix rather than a promise
380
+ * of it: "the template's reach ends at the refs it owns. do not silently rewrite
381
+ * what the practice does not own"
382
+ * .why = every other case here proves the pin ARRIVES. this one proves it STOPS. a fix that
383
+ * pinned every tag ref it could find would satisfy all of them and still be wrong —
384
+ * it would rewrite a consumer's own workflows, which the wish forbids outright
385
+ * .note = the apply here is deliberately UNSCOPED — no `file:` filter. a scoped apply cannot
386
+ * prove this: it would leave the consumer's file alone because it was told to, not
387
+ * because the practice declines to own it. the whole practice must run for the
388
+ * claim to mean what it says
389
+ */
390
+ given('[case3] a consumer repo that also has a workflow of its OWN', () => {
391
+ const tempDir = genTempDir({
392
+ slug: 'cicd-common-consumer-owned',
393
+ clone:
394
+ './src/practices/cicd-common/.test/assets/repo-with-unpinned-workflow',
395
+ symlink: [
396
+ { at: 'declarations', to: './.test/assets/cicd-common/declarations' },
397
+ { at: 'node_modules', to: 'node_modules' },
398
+ ],
399
+ });
400
+
401
+ when('[t0] the whole practice is applied, unscoped', () => {
402
+ const fileBefore = useBeforeAll(async () => ({
403
+ contents: await fs.readFile(
404
+ path.join(tempDir, PATH_OF_CONSUMER_OWNED),
405
+ 'utf-8',
406
+ ),
407
+ }));
408
+
409
+ useBeforeAll(async () => {
410
+ await executeApply({
411
+ config: path.join(tempDir, 'declapract.use.yml'),
412
+ practice: 'cicd-common',
413
+ });
414
+ }, 240_000);
415
+
416
+ then(
417
+ "the consumer's own workflow is byte-identical -- if red, the practice rewrote a file it does not own",
418
+ async () => {
419
+ const contentsAfter = await fs.readFile(
420
+ path.join(tempDir, PATH_OF_CONSUMER_OWNED),
421
+ 'utf-8',
422
+ );
423
+
424
+ expect(contentsAfter).toEqual(fileBefore.contents);
425
+ },
426
+ );
427
+
428
+ /**
429
+ * .why = the assertion above passes vacuously if the apply did no work at all — a
430
+ * misconfigured fixture, a practice that failed to evaluate, an apply that
431
+ * matched zero files would each leave the consumer's file untouched for the
432
+ * wrong reason. so the same run must be shown to have delivered elsewhere
433
+ */
434
+ then(
435
+ 'and the template-owned file DID change in the same run -- if red, the check above is vacuous',
436
+ async () => {
437
+ const contentsOfTemplateOwned = await fs.readFile(
438
+ path.join(tempDir, PATH_IN_CONSUMER),
439
+ 'utf-8',
440
+ );
441
+
442
+ expect(contentsOfTemplateOwned).toContain(
443
+ 'amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5',
444
+ );
445
+ },
446
+ );
447
+
448
+ /**
449
+ * .why = stated of the tag refs directly, so the claim reads as what it is: the
450
+ * consumer's `@v4` and `@v3` are STILL THERE. byte-equality already implies it,
451
+ * but a reader of the failure output should not have to diff two files to see
452
+ * which property broke
453
+ */
454
+ then("the consumer's own tag refs are untouched", async () => {
455
+ const contentsAfter = await fs.readFile(
456
+ path.join(tempDir, PATH_OF_CONSUMER_OWNED),
457
+ 'utf-8',
458
+ );
459
+
460
+ expect(contentsAfter).toContain('actions/checkout@v4');
461
+ expect(contentsAfter).toContain('actions/setup-node@v3');
462
+ });
463
+
464
+ /**
465
+ * .why = `[case1]` and `[case2]` each snapshot their file before AND after, so a reviewer
466
+ * reads the pin arrive as a diff. this case had assertions and no snapshot, which
467
+ * made the ONE property the wish states as a constraint — the practice does not
468
+ * rewrite what it does not own — the only outcome here a reviewer could not see.
469
+ * `rule.require.declapract-integration-tests` asks for both, and is explicit that
470
+ * an assertion alone checks only the substrings its author thought of
471
+ * .note = both halves are snapped even though byte-equality is asserted above, because a
472
+ * snapshot of the AFTER alone cannot show a reviewer what it was compared against.
473
+ * two identical blocks in the file are the legible form of "untouched"
474
+ */
475
+ then('and both halves of the untouched file match snapshot', async () => {
476
+ const contentsAfter = await fs.readFile(
477
+ path.join(tempDir, PATH_OF_CONSUMER_OWNED),
478
+ 'utf-8',
479
+ );
480
+
481
+ expect(fileBefore.contents).toMatchSnapshot('consumer-owned.yml -- before');
482
+ expect(contentsAfter).toMatchSnapshot('consumer-owned.yml -- after');
483
+ });
484
+ });
485
+ });
486
+
487
+ /**
488
+ * .what = the ABSENT-file path — a repo where the declared workflow does not exist at all
489
+ * .why = this is the wish's FIRST acceptance criterion, and until now it had no executed
490
+ * proof. "a fresh scaffold's CI starts" describes a repo with no `.github/workflows/`
491
+ * yet; every other case here starts from a file that is already present and merely
492
+ * wrong. a fix that only ever REWROTE would satisfy all of them and leave a fresh
493
+ * scaffold with no workflow at all — which fails the criterion silently, since a repo
494
+ * with no workflow has no red ci to notice
495
+ * .why = it is also the negative variant `rule.require.contract-snapshot-exhaustiveness` asks
496
+ * for on this contract: absent input. the error variants beside it — a malformed
497
+ * config, an unresolvable practice — belong to declapract's own cli and are not this
498
+ * behavior's to snapshot
499
+ * .note = the file is removed from a CLONE rather than carried by a second fixture, so the
500
+ * before-state is derived from the same asset the cases above use. a second fixture
501
+ * could drift from the first and quietly cease to be the same repo
502
+ */
503
+ given('[case4] a consumer repo where the declared workflow is ABSENT', () => {
504
+ const tempDir = genTempDir({
505
+ slug: 'cicd-common-absent-workflow',
506
+ clone:
507
+ './src/practices/cicd-common/.test/assets/repo-with-unpinned-workflow',
508
+ symlink: [
509
+ { at: 'declarations', to: './.test/assets/cicd-common/declarations' },
510
+ { at: 'node_modules', to: 'node_modules' },
511
+ ],
512
+ });
513
+
514
+ when('[t0] before any apply, with the workflow removed', () => {
515
+ const stateBefore = useThen('the workflow is removed', async () => {
516
+ await fs.rm(path.join(tempDir, PATH_IN_CONSUMER));
517
+ return {
518
+ report: await fs
519
+ .readFile(path.join(tempDir, PATH_IN_CONSUMER), 'utf-8')
520
+ .then(() => 'present')
521
+ .catch(() => 'absent'),
522
+ };
523
+ });
524
+
525
+ // .why = the absent state is SNAPPED rather than only asserted, so the negative path has a
526
+ // recorded before to read the after against. a reviewer who sees only the after
527
+ // cannot tell a created file from a rewritten one
528
+ then('the before-state reads as absent', () => {
529
+ expect(stateBefore.report).toMatchSnapshot('review.yml -- before (absent)');
530
+ });
531
+ });
532
+
533
+ when('[t1] the cicd-common practice is applied', () => {
534
+ useBeforeAll(async () => {
535
+ await executeApply({
536
+ config: path.join(tempDir, 'declapract.use.yml'),
537
+ practice: 'cicd-common',
538
+ file: PATH_IN_CONSUMER,
539
+ });
540
+ }, 120_000);
541
+
542
+ const fileAfter = useThen('the apply settles', async () => ({
543
+ contents: await fs.readFile(
544
+ path.join(tempDir, PATH_IN_CONSUMER),
545
+ 'utf-8',
546
+ ),
547
+ }));
548
+
549
+ then(
550
+ 'the workflow is CREATED, not skipped -- if red, a fresh scaffold comes away with no workflow and no red ci to reveal it',
551
+ async () => {
552
+ const expected = await fs.readFile(PATH_OF_TEMPLATE, 'utf-8');
553
+
554
+ expect(fileAfter.contents).toEqual(expected);
555
+ },
556
+ );
557
+
558
+ then('and it arrives already pinned', () => {
559
+ expect(fileAfter.contents).toContain(
560
+ 'amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5',
561
+ );
562
+ expect(fileAfter.contents).not.toContain(
563
+ 'amannn/action-semantic-pull-request@v5',
564
+ );
565
+ });
566
+
567
+ then('the created file matches snapshot', () => {
568
+ expect(fileAfter.contents).toMatchSnapshot('review.yml -- after (created)');
569
+ });
570
+ });
571
+ });
572
+ });
@@ -0,0 +1,19 @@
1
+ name: .test
2
+
3
+ on:
4
+ workflow_call:
5
+
6
+ jobs:
7
+ test:
8
+ runs-on: ubuntu-24.04
9
+ steps:
10
+ - name: checkout
11
+ uses: actions/checkout@v4
12
+
13
+ - name: node
14
+ uses: actions/setup-node@v4
15
+ with:
16
+ node-version: 22
17
+
18
+ - name: test:unit
19
+ run: npm run test:unit
@@ -0,0 +1,21 @@
1
+ # .what = a workflow NO practice declares — it belongs to the consumer alone
2
+ # .why = wish constraint 2: "the template's reach ends at the refs it owns. do not silently
3
+ # rewrite what the practice does not own."
4
+ # .note = the tag refs below are DELIBERATE and must NOT be pinned. they are what the test
5
+ # asserts survives untouched. a "helpful" pin here makes the clamp vacuous
6
+ name: consumer-owned
7
+
8
+ on:
9
+ workflow_dispatch:
10
+
11
+ jobs:
12
+ whatever:
13
+ runs-on: ubuntu-24.04
14
+ steps:
15
+ - name: checkout
16
+ uses: actions/checkout@v4
17
+
18
+ - name: setup node
19
+ uses: actions/setup-node@v3
20
+ with:
21
+ node-version: 20
@@ -0,0 +1,26 @@
1
+ name: review
2
+
3
+ on:
4
+ pull_request:
5
+ types:
6
+ - opened
7
+ - edited
8
+ - synchronize
9
+
10
+ permissions:
11
+ pull-requests: read
12
+
13
+ jobs:
14
+ pullreq-title:
15
+ runs-on: ubuntu-24.04
16
+ steps:
17
+ - name: test:pullreq:title
18
+ uses: amannn/action-semantic-pull-request@v5
19
+ env:
20
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21
+ with:
22
+ # https://github.com/commitizen/conventional-commit-types
23
+ types: |
24
+ fix
25
+ feat
26
+ chore
@@ -0,0 +1,6 @@
1
+ # .note = declarations dir is symlinked by the test
2
+ # .why = `typescript-project` here carries `cicd-common` ALONE, so the apply exercises
3
+ # the workflow declarations and no others. the claim under test is about one
4
+ # file's delivery, not about a full scaffold
5
+ declarations: 'declarations/declapract.declare.yml'
6
+ useCase: typescript-project
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "test-project",
3
+ "version": "1.0.0"
4
+ }