github-issue-tower-defence-management 1.148.24 → 1.149.0

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 (81) hide show
  1. package/.github/workflows/publish.yml +40 -7
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +4 -4
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +2 -1
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +1 -0
  8. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  9. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js +126 -0
  10. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js.map +1 -0
  11. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +1 -0
  12. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -1
  13. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +5 -3
  14. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  15. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +6 -2
  16. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  17. package/bin/domain/entities/KevReportWatermark.js +3 -0
  18. package/bin/domain/entities/KevReportWatermark.js.map +1 -0
  19. package/bin/domain/usecases/DailySecurityScanUseCase.js +56 -7
  20. package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -1
  21. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +9 -0
  22. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  23. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +12 -11
  24. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  25. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
  26. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  27. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js +3 -0
  28. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js.map +1 -0
  29. package/package.json +1 -1
  30. package/scripts/defaultBranchTipVerify.sh +23 -0
  31. package/scripts/testWorkflowRunVerify.sh +47 -0
  32. package/src/adapter/ci/publishTestWorkflowGate.test.ts +653 -0
  33. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -0
  34. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +35 -0
  35. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +7 -0
  36. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.test.ts +253 -0
  37. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.ts +130 -0
  38. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +2 -0
  39. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +1 -0
  40. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +3 -0
  41. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +159 -0
  42. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +7 -1
  43. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +108 -0
  44. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +10 -2
  45. package/src/domain/entities/KevReportWatermark.ts +4 -0
  46. package/src/domain/entities/LiveSessionActivitySnapshot.ts +1 -0
  47. package/src/domain/usecases/DailySecurityScanUseCase.test.ts +210 -0
  48. package/src/domain/usecases/DailySecurityScanUseCase.ts +84 -7
  49. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +58 -1
  50. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +21 -0
  51. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +28 -0
  52. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +146 -8
  53. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +19 -10
  54. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +212 -0
  55. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +32 -0
  56. package/src/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.ts +11 -0
  57. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +3 -0
  58. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  59. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +2 -0
  60. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  61. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts +10 -0
  62. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts.map +1 -0
  63. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -1
  64. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  65. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -1
  66. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  67. package/types/domain/entities/KevReportWatermark.d.ts +5 -0
  68. package/types/domain/entities/KevReportWatermark.d.ts.map +1 -0
  69. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +1 -0
  70. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  71. package/types/domain/usecases/DailySecurityScanUseCase.d.ts +3 -1
  72. package/types/domain/usecases/DailySecurityScanUseCase.d.ts.map +1 -1
  73. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -0
  74. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  75. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  76. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +3 -2
  77. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  78. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts +15 -0
  79. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts.map +1 -0
  80. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +1 -0
  81. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -0,0 +1,653 @@
1
+ import { spawnSync } from 'child_process';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { parse } from 'yaml';
6
+
7
+ const repositoryRoot = path.resolve(__dirname, '..', '..', '..');
8
+ const workflowDirectory = path.join(repositoryRoot, '.github', 'workflows');
9
+ const publishWorkflowFileName = 'publish.yml';
10
+ const gateJobId = 'verify-test-workflow-conclusion';
11
+ const gateScriptRelativePath = './scripts/testWorkflowRunVerify.sh';
12
+ const gateScriptAbsolutePath = path.join(
13
+ repositoryRoot,
14
+ 'scripts',
15
+ 'testWorkflowRunVerify.sh',
16
+ );
17
+ const tipScriptRelativePath = './scripts/defaultBranchTipVerify.sh';
18
+ const tipScriptAbsolutePath = path.join(
19
+ repositoryRoot,
20
+ 'scripts',
21
+ 'defaultBranchTipVerify.sh',
22
+ );
23
+ const tipStepId = 'default-branch-tip';
24
+ const releasableCondition = `steps.${tipStepId}.outputs.releasable == 'true'`;
25
+ const releaseCommand = 'npx semantic-release';
26
+ const repositoryPushCommand = 'git push origin';
27
+ const testSuiteCommand = 'npm run test';
28
+ const ambiguousTestJobId = 'test';
29
+ const pushedCommitExpression = '${{ github.sha }}';
30
+ const pushedRefExpression = '${{ github.ref }}';
31
+ const defaultBranchExpression = '${{ github.event.repository.default_branch }}';
32
+ const writePermissionValue = 'write';
33
+ const shellScriptGlob = '*.sh';
34
+
35
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
36
+ typeof value === 'object' && value !== null && !Array.isArray(value);
37
+
38
+ const readWorkflow = (fileName: string): Record<string, unknown> => {
39
+ const workflow: unknown = parse(
40
+ fs.readFileSync(path.join(workflowDirectory, fileName), 'utf8'),
41
+ );
42
+ if (!isRecord(workflow)) {
43
+ throw new Error(`${fileName} does not parse to a workflow mapping`);
44
+ }
45
+ return workflow;
46
+ };
47
+
48
+ const workflowJobs = (
49
+ fileName: string,
50
+ ): Map<string, Record<string, unknown>> => {
51
+ const jobs = readWorkflow(fileName)['jobs'];
52
+ if (!isRecord(jobs)) {
53
+ throw new Error(`${fileName} does not declare a jobs mapping`);
54
+ }
55
+ const declaredJobs = new Map<string, Record<string, unknown>>();
56
+ for (const [jobId, job] of Object.entries(jobs)) {
57
+ if (!isRecord(job)) {
58
+ throw new Error(`${fileName} declares job ${jobId} as a non-mapping`);
59
+ }
60
+ declaredJobs.set(jobId, job);
61
+ }
62
+ return declaredJobs;
63
+ };
64
+
65
+ const publishWorkflowJobs = (): Map<string, Record<string, unknown>> =>
66
+ workflowJobs(publishWorkflowFileName);
67
+
68
+ const jobById = (
69
+ jobs: Map<string, Record<string, unknown>>,
70
+ jobId: string,
71
+ ): Record<string, unknown> => {
72
+ const job = jobs.get(jobId);
73
+ if (job === undefined) {
74
+ throw new Error(`${publishWorkflowFileName} does not declare job ${jobId}`);
75
+ }
76
+ return job;
77
+ };
78
+
79
+ const jobSteps = (job: Record<string, unknown>): Record<string, unknown>[] => {
80
+ const steps = job['steps'];
81
+ return Array.isArray(steps) ? steps.filter(isRecord) : [];
82
+ };
83
+
84
+ const stepRunCommand = (step: Record<string, unknown>): string => {
85
+ const run = step['run'];
86
+ return typeof run === 'string' ? run : '';
87
+ };
88
+
89
+ const stepCondition = (step: Record<string, unknown>): string => {
90
+ const condition = step['if'];
91
+ return typeof condition === 'string' ? condition : '';
92
+ };
93
+
94
+ const stepEnvironment = (
95
+ step: Record<string, unknown>,
96
+ ): Map<string, string> => {
97
+ const environment = step['env'];
98
+ const declared = new Map<string, string>();
99
+ if (!isRecord(environment)) {
100
+ return declared;
101
+ }
102
+ for (const [name, value] of Object.entries(environment)) {
103
+ declared.set(
104
+ name,
105
+ typeof value === 'string' ? value : JSON.stringify(value),
106
+ );
107
+ }
108
+ return declared;
109
+ };
110
+
111
+ const jobNeeds = (job: Record<string, unknown>): string[] => {
112
+ const needs = job['needs'];
113
+ if (typeof needs === 'string') {
114
+ return [needs];
115
+ }
116
+ return Array.isArray(needs)
117
+ ? needs.filter((need): need is string => typeof need === 'string')
118
+ : [];
119
+ };
120
+
121
+ const transitiveJobDependencies = (
122
+ jobs: Map<string, Record<string, unknown>>,
123
+ jobId: string,
124
+ ): Set<string> => {
125
+ const dependencies = new Set<string>();
126
+ const unvisited = jobNeeds(jobById(jobs, jobId));
127
+ while (unvisited.length > 0) {
128
+ const dependency = unvisited.pop();
129
+ if (dependency === undefined || dependencies.has(dependency)) {
130
+ continue;
131
+ }
132
+ dependencies.add(dependency);
133
+ unvisited.push(...jobNeeds(jobById(jobs, dependency)));
134
+ }
135
+ return dependencies;
136
+ };
137
+
138
+ const jobIdsRunningCommand = (
139
+ jobs: Map<string, Record<string, unknown>>,
140
+ command: string,
141
+ ): string[] =>
142
+ [...jobs.entries()]
143
+ .filter(([, job]) =>
144
+ jobSteps(job).some((step) => stepRunCommand(step).includes(command)),
145
+ )
146
+ .map(([jobId]) => jobId);
147
+
148
+ const gateVerificationStep = (): Record<string, unknown> => {
149
+ const step = jobSteps(jobById(publishWorkflowJobs(), gateJobId)).find(
150
+ (candidate) => stepRunCommand(candidate).includes(gateScriptRelativePath),
151
+ );
152
+ if (step === undefined) {
153
+ throw new Error(`job ${gateJobId} does not run ${gateScriptRelativePath}`);
154
+ }
155
+ return step;
156
+ };
157
+
158
+ const gateEnvironmentValue = (name: string): string => {
159
+ const value = stepEnvironment(gateVerificationStep()).get(name);
160
+ if (value === undefined) {
161
+ throw new Error(`the gate step does not declare ${name}`);
162
+ }
163
+ return value;
164
+ };
165
+
166
+ const prettierIgnorePatterns = (): string[] =>
167
+ fs
168
+ .readFileSync(path.join(repositoryRoot, '.prettierignore'), 'utf8')
169
+ .split('\n')
170
+ .map((line) => line.trim())
171
+ .filter((line) => line.length > 0);
172
+
173
+ const workflowFileNames = (): string[] =>
174
+ fs
175
+ .readdirSync(workflowDirectory)
176
+ .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml'))
177
+ .sort();
178
+
179
+ const workflowFileNamesRunningTheTestSuite = (): string[] =>
180
+ workflowFileNames().filter(
181
+ (fileName) =>
182
+ jobIdsRunningCommand(workflowJobs(fileName), testSuiteCommand).length > 0,
183
+ );
184
+
185
+ const workflowFileNamesDeclaringJob = (jobId: string): string[] =>
186
+ workflowFileNames().filter((fileName) => workflowJobs(fileName).has(jobId));
187
+
188
+ const publishWorkflowStepsRunning = (
189
+ command: string,
190
+ ): Record<string, unknown>[] =>
191
+ [...publishWorkflowJobs().values()].flatMap((job) =>
192
+ jobSteps(job).filter((step) => stepRunCommand(step).includes(command)),
193
+ );
194
+
195
+ const releaseJobId = (): string => {
196
+ const releasingJobIds = jobIdsRunningCommand(
197
+ publishWorkflowJobs(),
198
+ releaseCommand,
199
+ );
200
+ if (releasingJobIds.length !== 1) {
201
+ throw new Error(
202
+ `${publishWorkflowFileName} declares ${releasingJobIds.length} jobs running ${releaseCommand}`,
203
+ );
204
+ }
205
+ return releasingJobIds.join('');
206
+ };
207
+
208
+ const jobConcurrency = (jobId: string): Record<string, unknown> => {
209
+ const concurrency = jobById(publishWorkflowJobs(), jobId)['concurrency'];
210
+ if (!isRecord(concurrency)) {
211
+ throw new Error(
212
+ `${publishWorkflowFileName} job ${jobId} declares no concurrency mapping`,
213
+ );
214
+ }
215
+ return concurrency;
216
+ };
217
+
218
+ type WorkflowRunFixture = {
219
+ readonly id: number;
220
+ readonly run_started_at: string;
221
+ readonly status: string;
222
+ readonly conclusion: string | null;
223
+ };
224
+
225
+ type GateScriptResult = {
226
+ readonly exitStatus: number | null;
227
+ readonly output: string;
228
+ readonly requests: string[];
229
+ };
230
+
231
+ const fakeGitHubCliSource = `#!/usr/bin/env bash
232
+ set -euo pipefail
233
+ requestLine=$(printf '%s ' "$@" | tr '\\n' ' ')
234
+ printf '%s\\n' "\${requestLine}" >> "\${FAKE_GH_DIRECTORY}/requests.log"
235
+ callIndex=$(cat "\${FAKE_GH_DIRECTORY}/call-index")
236
+ echo $((callIndex + 1)) > "\${FAKE_GH_DIRECTORY}/call-index"
237
+ responseFile="\${FAKE_GH_DIRECTORY}/response-\${callIndex}.json"
238
+ if [ ! -f "\${responseFile}" ]; then
239
+ echo "no fake response prepared for call \${callIndex}" >&2
240
+ exit 70
241
+ fi
242
+ filter=""
243
+ while [ "$#" -gt 0 ]; do
244
+ case "$1" in
245
+ --jq)
246
+ filter="$2"
247
+ shift 2
248
+ ;;
249
+ *)
250
+ shift
251
+ ;;
252
+ esac
253
+ done
254
+ jq -r "\${filter}" < "\${responseFile}"
255
+ `;
256
+
257
+ const runGateScript = (options: {
258
+ readonly workflowRunsPerCall: readonly (readonly WorkflowRunFixture[])[];
259
+ readonly pollTimeoutSeconds: number;
260
+ readonly commitSha: string;
261
+ }): GateScriptResult => {
262
+ const fakeDirectory = fs.mkdtempSync(
263
+ path.join(os.tmpdir(), 'publish-test-workflow-gate-'),
264
+ );
265
+ const fakeGitHubCliPath = path.join(fakeDirectory, 'gh');
266
+ fs.writeFileSync(fakeGitHubCliPath, fakeGitHubCliSource, { mode: 0o755 });
267
+ fs.writeFileSync(path.join(fakeDirectory, 'call-index'), '1\n');
268
+ fs.writeFileSync(path.join(fakeDirectory, 'requests.log'), '');
269
+ options.workflowRunsPerCall.forEach((workflowRuns, callOffset) => {
270
+ fs.writeFileSync(
271
+ path.join(fakeDirectory, `response-${callOffset + 1}.json`),
272
+ JSON.stringify({ workflow_runs: workflowRuns }),
273
+ );
274
+ });
275
+
276
+ const completed = spawnSync(gateScriptAbsolutePath, [], {
277
+ encoding: 'utf8',
278
+ env: {
279
+ PATH: `${fakeDirectory}:${process.env['PATH'] ?? ''}`,
280
+ FAKE_GH_DIRECTORY: fakeDirectory,
281
+ GITHUB_REPOSITORY:
282
+ 'HiromiShikata/npm-cli-github-issue-tower-defence-management',
283
+ TEST_WORKFLOW_FILE: 'test.yml',
284
+ VERIFIED_COMMIT_SHA: options.commitSha,
285
+ REQUIRED_CONCLUSION: 'success',
286
+ POLL_INTERVAL_SECONDS: '0',
287
+ POLL_TIMEOUT_SECONDS: String(options.pollTimeoutSeconds),
288
+ },
289
+ });
290
+
291
+ return {
292
+ exitStatus: completed.status,
293
+ output: `${completed.stdout}${completed.stderr}`,
294
+ requests: fs
295
+ .readFileSync(path.join(fakeDirectory, 'requests.log'), 'utf8')
296
+ .split('\n')
297
+ .filter((line) => line.length > 0),
298
+ };
299
+ };
300
+
301
+ type TipScriptResult = {
302
+ readonly exitStatus: number | null;
303
+ readonly output: string;
304
+ readonly stepOutputs: string[];
305
+ readonly requests: string[];
306
+ };
307
+
308
+ const fakeGitSource = `#!/usr/bin/env bash
309
+ set -euo pipefail
310
+ requestLine=$(printf '%s ' "$@")
311
+ printf '%s\\n' "\${requestLine}" >> "\${FAKE_GIT_DIRECTORY}/requests.log"
312
+ cat "\${FAKE_GIT_DIRECTORY}/ls-remote-output"
313
+ `;
314
+
315
+ const runDefaultBranchTipScript = (options: {
316
+ readonly defaultBranchTipSha: string;
317
+ readonly verifiedCommitSha: string;
318
+ }): TipScriptResult => {
319
+ const fakeDirectory = fs.mkdtempSync(
320
+ path.join(os.tmpdir(), 'publish-default-branch-tip-'),
321
+ );
322
+ fs.writeFileSync(path.join(fakeDirectory, 'git'), fakeGitSource, {
323
+ mode: 0o755,
324
+ });
325
+ fs.writeFileSync(path.join(fakeDirectory, 'requests.log'), '');
326
+ fs.writeFileSync(
327
+ path.join(fakeDirectory, 'ls-remote-output'),
328
+ options.defaultBranchTipSha.length > 0
329
+ ? `${options.defaultBranchTipSha}\trefs/heads/main\n`
330
+ : '',
331
+ );
332
+ const stepOutputPath = path.join(fakeDirectory, 'step-output');
333
+ fs.writeFileSync(stepOutputPath, '');
334
+
335
+ const completed = spawnSync(tipScriptAbsolutePath, [], {
336
+ encoding: 'utf8',
337
+ env: {
338
+ PATH: `${fakeDirectory}:${process.env['PATH'] ?? ''}`,
339
+ FAKE_GIT_DIRECTORY: fakeDirectory,
340
+ GITHUB_OUTPUT: stepOutputPath,
341
+ DEFAULT_BRANCH: 'main',
342
+ VERIFIED_COMMIT_SHA: options.verifiedCommitSha,
343
+ },
344
+ });
345
+
346
+ const readLines = (filePath: string): string[] =>
347
+ fs
348
+ .readFileSync(filePath, 'utf8')
349
+ .split('\n')
350
+ .filter((line) => line.length > 0);
351
+
352
+ return {
353
+ exitStatus: completed.status,
354
+ output: `${completed.stdout}${completed.stderr}`,
355
+ stepOutputs: readLines(stepOutputPath),
356
+ requests: readLines(path.join(fakeDirectory, 'requests.log')),
357
+ };
358
+ };
359
+
360
+ const workflowRun = (
361
+ overrides: Partial<WorkflowRunFixture>,
362
+ ): WorkflowRunFixture => ({
363
+ id: 1,
364
+ run_started_at: '2026-07-29T09:28:29Z',
365
+ status: 'completed',
366
+ conclusion: 'success',
367
+ ...overrides,
368
+ });
369
+
370
+ describe('publish workflow release gate wiring', () => {
371
+ it('makes every job that cuts a release depend on the test conclusion gate', () => {
372
+ const jobs = publishWorkflowJobs();
373
+ const releasingJobIds = jobIdsRunningCommand(jobs, releaseCommand);
374
+ expect(releasingJobIds.length).toBeGreaterThan(0);
375
+ for (const releasingJobId of releasingJobIds) {
376
+ expect([...transitiveJobDependencies(jobs, releasingJobId)]).toContain(
377
+ gateJobId,
378
+ );
379
+ }
380
+ });
381
+
382
+ it('verifies the test conclusion of the pushed commit rather than of a branch or a ref', () => {
383
+ expect(gateEnvironmentValue('VERIFIED_COMMIT_SHA')).toBe(
384
+ pushedCommitExpression,
385
+ );
386
+ });
387
+
388
+ it('verifies the workflow file that actually runs the repository test suite', () => {
389
+ expect(workflowFileNamesRunningTheTestSuite()).toEqual([
390
+ gateEnvironmentValue('TEST_WORKFLOW_FILE'),
391
+ ]);
392
+ });
393
+
394
+ it('identifies the test suite by workflow file, which more than one workflow declaring a job of the same name cannot impersonate', () => {
395
+ const fileNamesDeclaringTheAmbiguousJob =
396
+ workflowFileNamesDeclaringJob(ambiguousTestJobId);
397
+ expect(fileNamesDeclaringTheAmbiguousJob.length).toBeGreaterThan(1);
398
+ const verifiedWorkflowFileName = gateEnvironmentValue('TEST_WORKFLOW_FILE');
399
+ expect(workflowFileNames()).toContain(verifiedWorkflowFileName);
400
+ for (const fileName of fileNamesDeclaringTheAmbiguousJob.filter(
401
+ (candidate) => candidate !== verifiedWorkflowFileName,
402
+ )) {
403
+ expect(
404
+ jobIdsRunningCommand(workflowJobs(fileName), testSuiteCommand),
405
+ ).toEqual([]);
406
+ }
407
+ });
408
+
409
+ it('accepts only a successful test conclusion as releasable', () => {
410
+ expect(gateEnvironmentValue('REQUIRED_CONCLUSION')).toBe('success');
411
+ });
412
+
413
+ it('bounds the time it waits for the test conclusion', () => {
414
+ expect(
415
+ Number(gateEnvironmentValue('POLL_TIMEOUT_SECONDS')),
416
+ ).toBeGreaterThan(0);
417
+ expect(
418
+ Number(gateEnvironmentValue('POLL_INTERVAL_SECONDS')),
419
+ ).toBeGreaterThan(0);
420
+ });
421
+
422
+ it('resolves the default branch tip inside the release job, after the build and before the release', () => {
423
+ const steps = jobSteps(jobById(publishWorkflowJobs(), releaseJobId()));
424
+ const tipStepIndex = steps.findIndex((step) =>
425
+ stepRunCommand(step).includes(tipScriptRelativePath),
426
+ );
427
+ const releaseStepIndex = steps.findIndex((step) =>
428
+ stepRunCommand(step).includes(releaseCommand),
429
+ );
430
+ expect(tipStepIndex).toBeGreaterThan(-1);
431
+ expect(releaseStepIndex).toBeGreaterThan(tipStepIndex);
432
+ expect(steps[tipStepIndex]?.['id']).toBe(tipStepId);
433
+ });
434
+
435
+ it('resolves the default branch tip of the repository default branch rather than of a hardcoded branch', () => {
436
+ const tipStep = jobSteps(
437
+ jobById(publishWorkflowJobs(), releaseJobId()),
438
+ ).find((step) => stepRunCommand(step).includes(tipScriptRelativePath));
439
+ expect(tipStep).toBeDefined();
440
+ if (tipStep === undefined) {
441
+ return;
442
+ }
443
+ expect(stepEnvironment(tipStep).get('DEFAULT_BRANCH')).toBe(
444
+ defaultBranchExpression,
445
+ );
446
+ expect(stepEnvironment(tipStep).get('VERIFIED_COMMIT_SHA')).toBe(
447
+ pushedCommitExpression,
448
+ );
449
+ });
450
+
451
+ it.each([releaseCommand, repositoryPushCommand])(
452
+ 'runs %s only while the triggering commit is still the default branch tip',
453
+ (command) => {
454
+ const steps = publishWorkflowStepsRunning(command);
455
+ expect(steps.length).toBeGreaterThan(0);
456
+ for (const step of steps) {
457
+ expect(stepCondition(step)).toContain(releasableCondition);
458
+ }
459
+ },
460
+ );
461
+
462
+ it('serialises release execution and never cancels a release already in progress', () => {
463
+ const concurrency = jobConcurrency(releaseJobId());
464
+ expect(concurrency['group']).toBe(`publish-release-${pushedRefExpression}`);
465
+ expect(concurrency['cancel-in-progress']).toBe(false);
466
+ });
467
+
468
+ it('excludes the gate shell scripts from the repository formatter, which cannot infer a parser for them', () => {
469
+ expect(prettierIgnorePatterns()).toContain(shellScriptGlob);
470
+ });
471
+
472
+ it('keeps the release credentials out of the gate job', () => {
473
+ expect(readWorkflow(publishWorkflowFileName)['env']).toBeUndefined();
474
+ const gatePermissions = jobById(publishWorkflowJobs(), gateJobId)[
475
+ 'permissions'
476
+ ];
477
+ expect(isRecord(gatePermissions)).toBe(true);
478
+ if (!isRecord(gatePermissions)) {
479
+ return;
480
+ }
481
+ expect(Object.values(gatePermissions)).not.toContain(writePermissionValue);
482
+ });
483
+ });
484
+
485
+ describe('test workflow run verification script', () => {
486
+ it('is executable so the publish workflow can invoke it directly', () => {
487
+ expect(fs.statSync(gateScriptAbsolutePath).mode & 0o111).not.toBe(0);
488
+ });
489
+
490
+ it('asks the GitHub API for the test workflow runs of the verified commit only', () => {
491
+ const commitSha = '4f188f506ee020addfc91e60a9df22fb77c2225f';
492
+ const result = runGateScript({
493
+ workflowRunsPerCall: [[workflowRun({})]],
494
+ pollTimeoutSeconds: 0,
495
+ commitSha,
496
+ });
497
+ expect(result.exitStatus).toBe(0);
498
+ expect(result.requests).toHaveLength(1);
499
+ expect(result.requests[0]).toContain('actions/workflows/test.yml/runs');
500
+ expect(result.requests[0]).toContain(`head_sha=${commitSha}`);
501
+ });
502
+
503
+ it('releases when the test run of the commit concluded success', () => {
504
+ expect(
505
+ runGateScript({
506
+ workflowRunsPerCall: [[workflowRun({})]],
507
+ pollTimeoutSeconds: 0,
508
+ commitSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
509
+ }).exitStatus,
510
+ ).toBe(0);
511
+ });
512
+
513
+ it.each(['cancelled', 'failure', 'timed_out'])(
514
+ 'refuses to release when the test run of the commit concluded %s',
515
+ (conclusion) => {
516
+ const result = runGateScript({
517
+ workflowRunsPerCall: [[workflowRun({ conclusion })]],
518
+ pollTimeoutSeconds: 0,
519
+ commitSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
520
+ });
521
+ expect(result.exitStatus).toBe(1);
522
+ expect(result.output).toContain(conclusion);
523
+ },
524
+ );
525
+
526
+ it('refuses to release when no test run exists for the commit', () => {
527
+ const result = runGateScript({
528
+ workflowRunsPerCall: [[]],
529
+ pollTimeoutSeconds: 0,
530
+ commitSha: 'cccccccccccccccccccccccccccccccccccccccc',
531
+ });
532
+ expect(result.exitStatus).toBe(1);
533
+ expect(result.output).toContain('absent');
534
+ });
535
+
536
+ it('refuses to release when the test run of the commit never concludes', () => {
537
+ const result = runGateScript({
538
+ workflowRunsPerCall: [
539
+ [workflowRun({ status: 'in_progress', conclusion: null })],
540
+ ],
541
+ pollTimeoutSeconds: 0,
542
+ commitSha: 'dddddddddddddddddddddddddddddddddddddddd',
543
+ });
544
+ expect(result.exitStatus).toBe(1);
545
+ expect(result.output).toContain('pending');
546
+ });
547
+
548
+ it('waits for a running test run and releases once it concludes success', () => {
549
+ const result = runGateScript({
550
+ workflowRunsPerCall: [
551
+ [workflowRun({ status: 'in_progress', conclusion: null })],
552
+ [workflowRun({})],
553
+ ],
554
+ pollTimeoutSeconds: 120,
555
+ commitSha: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
556
+ });
557
+ expect(result.exitStatus).toBe(0);
558
+ expect(result.requests).toHaveLength(2);
559
+ });
560
+
561
+ it('refuses to release when the most recent test run of the commit was cancelled after an earlier success', () => {
562
+ const result = runGateScript({
563
+ workflowRunsPerCall: [
564
+ [
565
+ workflowRun({
566
+ id: 1,
567
+ run_started_at: '2026-07-29T09:28:29Z',
568
+ conclusion: 'success',
569
+ }),
570
+ workflowRun({
571
+ id: 2,
572
+ run_started_at: '2026-07-29T09:29:27Z',
573
+ conclusion: 'cancelled',
574
+ }),
575
+ ],
576
+ ],
577
+ pollTimeoutSeconds: 0,
578
+ commitSha: 'ffffffffffffffffffffffffffffffffffffffff',
579
+ });
580
+ expect(result.exitStatus).toBe(1);
581
+ expect(result.output).toContain('cancelled');
582
+ });
583
+
584
+ it('releases when the most recent test run of the commit succeeded after an earlier cancellation', () => {
585
+ expect(
586
+ runGateScript({
587
+ workflowRunsPerCall: [
588
+ [
589
+ workflowRun({
590
+ id: 1,
591
+ run_started_at: '2026-07-29T09:28:29Z',
592
+ conclusion: 'cancelled',
593
+ }),
594
+ workflowRun({
595
+ id: 2,
596
+ run_started_at: '2026-07-29T09:29:27Z',
597
+ conclusion: 'success',
598
+ }),
599
+ ],
600
+ ],
601
+ pollTimeoutSeconds: 0,
602
+ commitSha: '1111111111111111111111111111111111111111',
603
+ }).exitStatus,
604
+ ).toBe(0);
605
+ });
606
+ });
607
+
608
+ describe('default branch tip verification script', () => {
609
+ const tipSha = '2b5253004b28f5c1c5e5c2a9e0dd7d4a1b6cbb31';
610
+ const olderSha = '4f188f506ee020addfc91e60a9df22fb77c2225f';
611
+
612
+ it('is executable so the publish workflow can invoke it directly', () => {
613
+ expect(fs.statSync(tipScriptAbsolutePath).mode & 0o111).not.toBe(0);
614
+ });
615
+
616
+ it('reads the tip of the default branch from the remote', () => {
617
+ const result = runDefaultBranchTipScript({
618
+ defaultBranchTipSha: tipSha,
619
+ verifiedCommitSha: tipSha,
620
+ });
621
+ expect(result.requests).toEqual(['ls-remote origin refs/heads/main ']);
622
+ });
623
+
624
+ it('allows the release when the triggering commit is still the default branch tip', () => {
625
+ const result = runDefaultBranchTipScript({
626
+ defaultBranchTipSha: tipSha,
627
+ verifiedCommitSha: tipSha,
628
+ });
629
+ expect(result.exitStatus).toBe(0);
630
+ expect(result.stepOutputs).toContain('releasable=true');
631
+ });
632
+
633
+ it('withholds the release from a commit that a newer commit has replaced as the default branch tip', () => {
634
+ const result = runDefaultBranchTipScript({
635
+ defaultBranchTipSha: tipSha,
636
+ verifiedCommitSha: olderSha,
637
+ });
638
+ expect(result.exitStatus).toBe(0);
639
+ expect(result.stepOutputs).toContain('releasable=false');
640
+ expect(result.stepOutputs).not.toContain('releasable=true');
641
+ expect(result.output).toContain(olderSha);
642
+ expect(result.output).toContain(tipSha);
643
+ });
644
+
645
+ it('fails rather than releasing when the default branch tip cannot be resolved', () => {
646
+ const result = runDefaultBranchTipScript({
647
+ defaultBranchTipSha: '',
648
+ verifiedCommitSha: olderSha,
649
+ });
650
+ expect(result.exitStatus).toBe(1);
651
+ expect(result.stepOutputs).toEqual([]);
652
+ });
653
+ });
@@ -69,6 +69,7 @@ import { ProjectRequiredFieldCreateUseCase } from '../../../domain/usecases/Proj
69
69
  import { SetupTowerDefenceProjectUseCase } from '../../../domain/usecases/SetupTowerDefenceProjectUseCase';
70
70
  import { DailySecurityScanUseCase } from '../../../domain/usecases/DailySecurityScanUseCase';
71
71
  import { KyHttpRepository } from '../../repositories/KyHttpRepository';
72
+ import { FileSystemKevReportWatermarkRepository } from '../../repositories/FileSystemKevReportWatermarkRepository';
72
73
  import {
73
74
  AWAITING_QUALITY_CHECK_STATUS_NAME,
74
75
  AWAITING_WORKSPACE_STATUS_NAME,
@@ -494,6 +495,7 @@ export class HandleScheduledEventUseCaseHandler {
494
495
  nodeLocalCommandRunner,
495
496
  issueRepository,
496
497
  new KyHttpRepository(),
498
+ new FileSystemKevReportWatermarkRepository(),
497
499
  )
498
500
  : null;
499
501