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.
- 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/.declapract.readme.md +27 -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-app-react-native-expo/best-practice/.gitignore.declapract.ts +18 -11
- 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/dist/practices/git/.declapract.integration.test.ts +950 -0
- package/dist/practices/git/.test/assets/repo-with-rhachet-only/declapract.use.yml +6 -0
- package/dist/practices/git/.test/assets/repo-with-rhachet-only/package.json +4 -0
- package/dist/practices/git/.test/assets/repo-without-cache-ignores/declapract.use.yml +9 -0
- package/dist/practices/git/.test/assets/repo-without-cache-ignores/package.json +4 -0
- package/dist/practices/git/__snapshots__/.declapract.integration.test.ts.snap +51 -0
- package/dist/practices/git/best-practice/.declapract.readme.md +23 -0
- package/dist/practices/git/best-practice/.gitignore.declapract.ts +59 -33
- package/dist/practices/rhachet/best-practice/.declapract.readme.md +32 -0
- package/dist/practices/rhachet/best-practice/.gitignore.declapract.ts +64 -0
- package/dist/utils/defineExpectedGitignoreContents.ts +84 -0
- 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
|
package/dist/practices/cicd-common/.test/assets/repo-with-unpinned-workflow/declapract.use.yml
ADDED
|
@@ -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
|