vouchington-tooling 0.0.9 → 0.0.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/README.md +16 -1
- package/dist/cli/commands/gha-artifacts-cleanup.d.mts +6 -0
- package/dist/cli/commands/gha-artifacts-cleanup.mjs +50 -0
- package/dist/cli/commands/http-origin.d.mts +2 -0
- package/dist/cli/commands/http-origin.mjs +14 -0
- package/dist/cli/index.mjs +11 -0
- package/dist/cli/parse-gha-artifacts-cleanup.d.mts +15 -0
- package/dist/cli/parse-gha-artifacts-cleanup.mjs +74 -0
- package/dist/cli/parse.d.mts +7 -2
- package/dist/cli/parse.mjs +36 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +9 -0
- package/dist/gha-artifacts-cleanup/classify.d.mts +14 -0
- package/dist/gha-artifacts-cleanup/classify.mjs +32 -0
- package/dist/gha-artifacts-cleanup/commands.d.mts +23 -0
- package/dist/gha-artifacts-cleanup/commands.mjs +55 -0
- package/dist/gha-artifacts-cleanup/github.d.mts +16 -0
- package/dist/gha-artifacts-cleanup/github.mjs +100 -0
- package/dist/gha-artifacts-cleanup/index.d.mts +8 -0
- package/dist/gha-artifacts-cleanup/index.mjs +4 -0
- package/dist/gha-artifacts-cleanup/plan.d.mts +17 -0
- package/dist/gha-artifacts-cleanup/plan.mjs +40 -0
- package/dist/gha-selected-files/index.d.mts +6 -0
- package/dist/gha-selected-files/index.mjs +38 -0
- package/dist/http-origin/index.d.mts +1 -0
- package/dist/http-origin/index.mjs +23 -0
- package/dist/index.d.mts +7 -2
- package/dist/index.mjs +5 -1
- package/dist/process-line-buffer/index.d.mts +7 -0
- package/dist/process-line-buffer/index.mjs +28 -0
- package/dist/shared-context/{fake-git.test-helpers.d.mts → fake-git.d.mts} +1 -0
- package/dist/shared-context/{fake-git.test-helpers.mjs → fake-git.mjs} +4 -1
- package/dist/shared-context/index.d.mts +2 -0
- package/dist/shared-context/index.mjs +1 -0
- package/package.json +27 -1
- package/scripts/gha/diagnose-port-collision.sh +232 -0
- package/scripts/gha/prepare-trivy-db.sh +32 -0
package/README.md
CHANGED
|
@@ -22,6 +22,10 @@ vouchington gha-needs-results
|
|
|
22
22
|
vouchington download-with-diagnostics <url> <destination>
|
|
23
23
|
vouchington host-pressure-diagnostics
|
|
24
24
|
vouchington allocate-browser-safe-ports 2 --policy ./policy.json --forbidden-ports ./ports.json
|
|
25
|
+
vouchington diagnose-port-collision --ports "2200 2216"
|
|
26
|
+
vouchington prepare-trivy-db
|
|
27
|
+
vouchington gha-artifacts-cleanup run --run-id 123 --keep-pattern 'plan-*' --delete-pattern 'coverage-*'
|
|
28
|
+
vouchington http-origin --field cdn_origin https://images.example.com
|
|
25
29
|
vouchington vitest-blob-manifest <suite> [reports-directory]
|
|
26
30
|
vouchington pnpm-install --runner-lifecycle persistent --install-scripts true
|
|
27
31
|
```
|
|
@@ -48,5 +52,16 @@ import { splitSqlStatements, stripSqlComments } from 'vouchington-tooling/sql-sc
|
|
|
48
52
|
import { auditCiJobRuntime } from 'vouchington-tooling/gha-runtime-audit'
|
|
49
53
|
import { writeVitestBlobManifest } from 'vouchington-tooling/vitest-blob-manifest'
|
|
50
54
|
import { runInstallLifecycle } from 'vouchington-tooling/pnpm-install'
|
|
51
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
buildSharedContext,
|
|
57
|
+
installFakeGit,
|
|
58
|
+
runNamedChecks,
|
|
59
|
+
} from 'vouchington-tooling/shared-context'
|
|
60
|
+
import {
|
|
61
|
+
decodeSelectedFiles,
|
|
62
|
+
writeSelectedFilesOutput,
|
|
63
|
+
} from 'vouchington-tooling/gha-selected-files'
|
|
64
|
+
import { createArtifactClassifier, runCleanup } from 'vouchington-tooling/gha-artifacts-cleanup'
|
|
65
|
+
import { validateOptionalHttpOrigin } from 'vouchington-tooling/http-origin'
|
|
66
|
+
import { boundPendingLine, splitCompleteLines } from 'vouchington-tooling/process-line-buffer'
|
|
52
67
|
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ParsedGhaArtifactsCleanup } from '../parse-gha-artifacts-cleanup.mts';
|
|
2
|
+
export declare function loadCleanupPatterns(parsed: ParsedGhaArtifactsCleanup): {
|
|
3
|
+
keepPatterns: string[];
|
|
4
|
+
deletePatterns: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function runGhaArtifactsCleanup(parsed: ParsedGhaArtifactsCleanup, env?: Record<string, string | undefined>): Promise<number>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createArtifactClassifier, parseArtifactPatternsJson, runCleanup, sweepCleanup, } from '../../gha-artifacts-cleanup/index.mjs';
|
|
3
|
+
function usage() {
|
|
4
|
+
return [
|
|
5
|
+
'Usage: vouchington gha-artifacts-cleanup run --run-id <id> [pattern options]',
|
|
6
|
+
' vouchington gha-artifacts-cleanup sweep --older-than-hours <n> [pattern options]',
|
|
7
|
+
'',
|
|
8
|
+
'Pattern options: --keep-pattern <glob> --delete-pattern <glob> --patterns-file <json>',
|
|
9
|
+
'Requires GITHUB_TOKEN or GH_TOKEN and GITHUB_REPOSITORY (owner/repo) in the env.',
|
|
10
|
+
].join('\n');
|
|
11
|
+
}
|
|
12
|
+
function logSummary(subcommand, summary) {
|
|
13
|
+
const mb = (summary.bytesFreed / (1024 * 1024)).toFixed(1);
|
|
14
|
+
console.log(`[gha-artifacts-cleanup] ${subcommand}: deleted ${summary.deletedCount} artifact(s), freed ~${mb} MB`);
|
|
15
|
+
}
|
|
16
|
+
export function loadCleanupPatterns(parsed) {
|
|
17
|
+
const fromFile = parsed.patternsFile === undefined
|
|
18
|
+
? { keepPatterns: [], deletePatterns: [] }
|
|
19
|
+
: parseArtifactPatternsJson(JSON.parse(readFileSync(parsed.patternsFile, 'utf8')));
|
|
20
|
+
return {
|
|
21
|
+
keepPatterns: [...fromFile.keepPatterns, ...parsed.keepPatterns],
|
|
22
|
+
deletePatterns: [...fromFile.deletePatterns, ...parsed.deletePatterns],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export async function runGhaArtifactsCleanup(parsed, env = process.env) {
|
|
26
|
+
const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
27
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
28
|
+
if (!token || !repo) {
|
|
29
|
+
console.error('[gha-artifacts-cleanup] missing GITHUB_TOKEN/GH_TOKEN or GITHUB_REPOSITORY');
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
const patterns = loadCleanupPatterns(parsed);
|
|
33
|
+
const { classify } = createArtifactClassifier(patterns);
|
|
34
|
+
if (parsed.subcommand === 'run') {
|
|
35
|
+
const runId = parsed.runId;
|
|
36
|
+
if (runId === undefined) {
|
|
37
|
+
console.error(usage());
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
logSummary('run', await runCleanup({ repo, token, classify, runId }));
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
const olderThanHours = parsed.olderThanHours;
|
|
44
|
+
if (olderThanHours === undefined) {
|
|
45
|
+
console.error(usage());
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
logSummary('sweep', await sweepCleanup({ repo, token, classify, olderThanHours }));
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { validateOptionalHttpOrigin } from '../../http-origin/index.mjs';
|
|
2
|
+
export function formatCliError(error) {
|
|
3
|
+
return error instanceof Error ? error.message : String(error);
|
|
4
|
+
}
|
|
5
|
+
export function runHttpOrigin(field, value) {
|
|
6
|
+
try {
|
|
7
|
+
validateOptionalHttpOrigin(value, field);
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/cli/index.mjs
CHANGED
|
@@ -3,7 +3,9 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { readPackageVersion } from '../package-version.mjs';
|
|
6
|
+
import { runGhaArtifactsCleanup } from './commands/gha-artifacts-cleanup.mjs';
|
|
6
7
|
import { runGhaRuntimeAudit } from './commands/gha-runtime-audit.mjs';
|
|
8
|
+
import { runHttpOrigin } from './commands/http-origin.mjs';
|
|
7
9
|
import { runPnpmInstallCli } from './commands/pnpm-install.mjs';
|
|
8
10
|
import { runRunnerPortPolicy } from './commands/runner-port-policy.mjs';
|
|
9
11
|
import { runScript } from './commands/spawn-script.mjs';
|
|
@@ -27,6 +29,11 @@ const SCRIPT_PATHS = {
|
|
|
27
29
|
command: 'python3',
|
|
28
30
|
path: 'scripts/allocate-browser-safe-ports.py',
|
|
29
31
|
},
|
|
32
|
+
'diagnose-port-collision': {
|
|
33
|
+
command: 'bash',
|
|
34
|
+
path: 'scripts/gha/diagnose-port-collision.sh',
|
|
35
|
+
},
|
|
36
|
+
'prepare-trivy-db': { command: 'bash', path: 'scripts/gha/prepare-trivy-db.sh' },
|
|
30
37
|
};
|
|
31
38
|
export function runCli(argv = process.argv) {
|
|
32
39
|
const parsed = parseCli(argv);
|
|
@@ -55,6 +62,10 @@ export function runCli(argv = process.argv) {
|
|
|
55
62
|
return runPnpmInstallCli(parsed.args);
|
|
56
63
|
case 'vitest-blob-manifest':
|
|
57
64
|
return runVitestBlobManifestCommand(parsed.args);
|
|
65
|
+
case 'http-origin':
|
|
66
|
+
return runHttpOrigin(parsed.field, parsed.value);
|
|
67
|
+
case 'gha-artifacts-cleanup':
|
|
68
|
+
return runGhaArtifactsCleanup(parsed);
|
|
58
69
|
}
|
|
59
70
|
}
|
|
60
71
|
function readInstalledVersion() {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ParsedGhaArtifactsCleanup = {
|
|
2
|
+
kind: 'gha-artifacts-cleanup';
|
|
3
|
+
subcommand: 'run' | 'sweep';
|
|
4
|
+
runId?: string;
|
|
5
|
+
olderThanHours?: number;
|
|
6
|
+
keepPatterns: string[];
|
|
7
|
+
deletePatterns: string[];
|
|
8
|
+
patternsFile?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function parseGhaArtifactsCleanup(args: readonly string[]): ParsedGhaArtifactsCleanup | {
|
|
11
|
+
kind: 'help';
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'error';
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export function parseGhaArtifactsCleanup(args) {
|
|
2
|
+
const [subcommand, ...rest] = args;
|
|
3
|
+
if (subcommand === '--help' || subcommand === '-h' || subcommand === undefined) {
|
|
4
|
+
return subcommand === undefined
|
|
5
|
+
? { kind: 'error', message: 'gha-artifacts-cleanup requires run or sweep' }
|
|
6
|
+
: { kind: 'help' };
|
|
7
|
+
}
|
|
8
|
+
if (subcommand !== 'run' && subcommand !== 'sweep') {
|
|
9
|
+
return { kind: 'error', message: `unknown gha-artifacts-cleanup subcommand: ${subcommand}` };
|
|
10
|
+
}
|
|
11
|
+
let runId;
|
|
12
|
+
let olderThanHours;
|
|
13
|
+
let patternsFile;
|
|
14
|
+
const keepPatterns = [];
|
|
15
|
+
const deletePatterns = [];
|
|
16
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
17
|
+
const flag = rest[index];
|
|
18
|
+
if (flag === '--help' || flag === '-h')
|
|
19
|
+
return { kind: 'help' };
|
|
20
|
+
if (flag === '--run-id' || flag === '--older-than-hours' || flag === '--patterns-file') {
|
|
21
|
+
const value = rest[index + 1];
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return { kind: 'error', message: `${flag} requires a value` };
|
|
24
|
+
index += 1;
|
|
25
|
+
if (flag === '--run-id')
|
|
26
|
+
runId = value;
|
|
27
|
+
else if (flag === '--patterns-file')
|
|
28
|
+
patternsFile = value;
|
|
29
|
+
else {
|
|
30
|
+
const parsed = Number(value.trim());
|
|
31
|
+
if (!value.trim() || !Number.isFinite(parsed) || parsed < 0) {
|
|
32
|
+
return { kind: 'error', message: '--older-than-hours must be a non-negative number' };
|
|
33
|
+
}
|
|
34
|
+
olderThanHours = parsed;
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (flag === '--keep-pattern' || flag === '--delete-pattern') {
|
|
39
|
+
const value = rest[index + 1];
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return { kind: 'error', message: `${flag} requires a value` };
|
|
42
|
+
index += 1;
|
|
43
|
+
if (flag === '--keep-pattern')
|
|
44
|
+
keepPatterns.push(value);
|
|
45
|
+
else
|
|
46
|
+
deletePatterns.push(value);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
return { kind: 'error', message: `unknown gha-artifacts-cleanup option: ${flag}` };
|
|
50
|
+
}
|
|
51
|
+
if (subcommand === 'run') {
|
|
52
|
+
if (!runId)
|
|
53
|
+
return { kind: 'error', message: 'gha-artifacts-cleanup run requires --run-id' };
|
|
54
|
+
return {
|
|
55
|
+
kind: 'gha-artifacts-cleanup',
|
|
56
|
+
subcommand: 'run',
|
|
57
|
+
runId,
|
|
58
|
+
keepPatterns,
|
|
59
|
+
deletePatterns,
|
|
60
|
+
...(patternsFile === undefined ? {} : { patternsFile }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (olderThanHours === undefined) {
|
|
64
|
+
return { kind: 'error', message: 'gha-artifacts-cleanup sweep requires --older-than-hours' };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
kind: 'gha-artifacts-cleanup',
|
|
68
|
+
subcommand: 'sweep',
|
|
69
|
+
olderThanHours,
|
|
70
|
+
keepPatterns,
|
|
71
|
+
deletePatterns,
|
|
72
|
+
...(patternsFile === undefined ? {} : { patternsFile }),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/dist/cli/parse.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ParsedGhaArtifactsCleanup } from './parse-gha-artifacts-cleanup.mts';
|
|
1
2
|
import { type ParsedGhaRuntimeAudit } from './parse-gha-runtime-audit.mts';
|
|
2
3
|
export type ParsedCli = {
|
|
3
4
|
kind: 'help';
|
|
@@ -23,6 +24,10 @@ export type ParsedCli = {
|
|
|
23
24
|
} | {
|
|
24
25
|
kind: 'vitest-blob-manifest';
|
|
25
26
|
args: string[];
|
|
26
|
-
} |
|
|
27
|
-
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'http-origin';
|
|
29
|
+
field: string;
|
|
30
|
+
value: string;
|
|
31
|
+
} | ParsedGhaRuntimeAudit | ParsedGhaArtifactsCleanup;
|
|
32
|
+
export type ScriptCommand = 'gha-output' | 'gha-needs-results' | 'download-with-diagnostics' | 'host-pressure-diagnostics' | 'allocate-browser-safe-ports' | 'diagnose-port-collision' | 'prepare-trivy-db';
|
|
28
33
|
export declare function parseCli(argv: readonly string[]): ParsedCli;
|
package/dist/cli/parse.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseGhaArtifactsCleanup, } from './parse-gha-artifacts-cleanup.mjs';
|
|
1
2
|
import { parseGhaRuntimeAudit } from './parse-gha-runtime-audit.mjs';
|
|
2
3
|
const SCRIPT_COMMANDS = new Set([
|
|
3
4
|
'gha-output',
|
|
@@ -5,6 +6,8 @@ const SCRIPT_COMMANDS = new Set([
|
|
|
5
6
|
'download-with-diagnostics',
|
|
6
7
|
'host-pressure-diagnostics',
|
|
7
8
|
'allocate-browser-safe-ports',
|
|
9
|
+
'diagnose-port-collision',
|
|
10
|
+
'prepare-trivy-db',
|
|
8
11
|
]);
|
|
9
12
|
export function parseCli(argv) {
|
|
10
13
|
const args = argv.slice(2);
|
|
@@ -23,6 +26,10 @@ export function parseCli(argv) {
|
|
|
23
26
|
return { kind: 'pnpm-install', args: rest };
|
|
24
27
|
if (command === 'vitest-blob-manifest')
|
|
25
28
|
return { kind: 'vitest-blob-manifest', args: rest };
|
|
29
|
+
if (command === 'http-origin')
|
|
30
|
+
return parseHttpOrigin(rest);
|
|
31
|
+
if (command === 'gha-artifacts-cleanup')
|
|
32
|
+
return parseGhaArtifactsCleanup(rest);
|
|
26
33
|
if (command !== undefined && SCRIPT_COMMANDS.has(command)) {
|
|
27
34
|
return { kind: 'script', command: command, args: rest };
|
|
28
35
|
}
|
|
@@ -62,3 +69,32 @@ function parseRunnerPortPolicy(args) {
|
|
|
62
69
|
...(reserved === undefined ? {} : { reserved }),
|
|
63
70
|
};
|
|
64
71
|
}
|
|
72
|
+
function parseHttpOrigin(args) {
|
|
73
|
+
let field = 'origin';
|
|
74
|
+
const values = [];
|
|
75
|
+
let index = 0;
|
|
76
|
+
while (index < args.length) {
|
|
77
|
+
const flag = args[index];
|
|
78
|
+
index += 1;
|
|
79
|
+
if (flag === '--help' || flag === '-h')
|
|
80
|
+
return { kind: 'help' };
|
|
81
|
+
if (flag === '--field') {
|
|
82
|
+
const value = args[index];
|
|
83
|
+
if (value === undefined)
|
|
84
|
+
return { kind: 'error', message: '--field requires a name' };
|
|
85
|
+
field = value;
|
|
86
|
+
index += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (flag === '--') {
|
|
90
|
+
values.push(...args.slice(index));
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (flag.startsWith('-'))
|
|
94
|
+
return { kind: 'error', message: `unknown http-origin option: ${flag}` };
|
|
95
|
+
values.push(flag);
|
|
96
|
+
}
|
|
97
|
+
if (values.length > 1)
|
|
98
|
+
return { kind: 'error', message: 'http-origin accepts at most one value' };
|
|
99
|
+
return { kind: 'http-origin', field, value: values[0] ?? '' };
|
|
100
|
+
}
|
package/dist/cli/usage.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n gha-output Write a collision-safe multiline GITHUB_OUTPUT record\n gha-needs-results Fail if required GitHub Actions job results failed\n download-with-diagnostics Download a URL and report HTTP status on failure\n host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot\n allocate-browser-safe-ports Allocate Fetch-safe localhost ports\n vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file\n pnpm-install Install a pnpm workspace with retry and release-age fail-fast\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n\ngha-runtime-audit\n [--repository owner/name] Default GITHUB_REPOSITORY\n [--branch main]\n --pr-workflow <name|/regex/> Repeatable\n --push-workflow <name|/regex/> Repeatable\n\ngha-output <name>\ngha-needs-results [label]\ndownload-with-diagnostics <url> <destination> [-- curl-args...]\nhost-pressure-diagnostics\nallocate-browser-safe-ports [count] [--policy path] [--forbidden-ports path]\nvitest-blob-manifest <suite> [reports-directory]\npnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false\n";
|
|
1
|
+
export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n gha-output Write a collision-safe multiline GITHUB_OUTPUT record\n gha-needs-results Fail if required GitHub Actions job results failed\n download-with-diagnostics Download a URL and report HTTP status on failure\n host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot\n allocate-browser-safe-ports Allocate Fetch-safe localhost ports\n diagnose-port-collision Capture bounded localhost port diagnostics\n prepare-trivy-db Download the Trivy vulnerability database\n gha-artifacts-cleanup Delete classified GitHub Actions artifacts\n http-origin Validate an optional HTTP(S) origin\n vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file\n pnpm-install Install a pnpm workspace with retry and release-age fail-fast\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n\ngha-runtime-audit\n [--repository owner/name] Default GITHUB_REPOSITORY\n [--branch main]\n --pr-workflow <name|/regex/> Repeatable\n --push-workflow <name|/regex/> Repeatable\n\ngha-output <name>\ngha-needs-results [label]\ndownload-with-diagnostics <url> <destination> [-- curl-args...]\nhost-pressure-diagnostics\nallocate-browser-safe-ports [count] [--policy path] [--forbidden-ports path]\ndiagnose-port-collision [--ports \"2200 2216\"] [--output-dir PATH]\nprepare-trivy-db\ngha-artifacts-cleanup run --run-id <id> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\ngha-artifacts-cleanup sweep --older-than-hours <n> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\nhttp-origin [--field NAME] [value]\nvitest-blob-manifest <suite> [reports-directory]\npnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false\n";
|
|
2
2
|
export declare function printUsage(stream?: NodeJS.WritableStream): void;
|
package/dist/cli/usage.mjs
CHANGED
|
@@ -9,6 +9,10 @@ Commands:
|
|
|
9
9
|
download-with-diagnostics Download a URL and report HTTP status on failure
|
|
10
10
|
host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot
|
|
11
11
|
allocate-browser-safe-ports Allocate Fetch-safe localhost ports
|
|
12
|
+
diagnose-port-collision Capture bounded localhost port diagnostics
|
|
13
|
+
prepare-trivy-db Download the Trivy vulnerability database
|
|
14
|
+
gha-artifacts-cleanup Delete classified GitHub Actions artifacts
|
|
15
|
+
http-origin Validate an optional HTTP(S) origin
|
|
12
16
|
vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file
|
|
13
17
|
pnpm-install Install a pnpm workspace with retry and release-age fail-fast
|
|
14
18
|
|
|
@@ -41,6 +45,11 @@ gha-needs-results [label]
|
|
|
41
45
|
download-with-diagnostics <url> <destination> [-- curl-args...]
|
|
42
46
|
host-pressure-diagnostics
|
|
43
47
|
allocate-browser-safe-ports [count] [--policy path] [--forbidden-ports path]
|
|
48
|
+
diagnose-port-collision [--ports "2200 2216"] [--output-dir PATH]
|
|
49
|
+
prepare-trivy-db
|
|
50
|
+
gha-artifacts-cleanup run --run-id <id> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]
|
|
51
|
+
gha-artifacts-cleanup sweep --older-than-hours <n> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]
|
|
52
|
+
http-origin [--field NAME] [value]
|
|
44
53
|
vitest-blob-manifest <suite> [reports-directory]
|
|
45
54
|
pnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false
|
|
46
55
|
`;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type ArtifactClassification = 'keep' | 'delete';
|
|
2
|
+
export type ArtifactClassifier = {
|
|
3
|
+
classify: (name: string) => ArtifactClassification;
|
|
4
|
+
isExplicitlyClassified: (name: string) => boolean;
|
|
5
|
+
};
|
|
6
|
+
export type ArtifactPatterns = {
|
|
7
|
+
keepPatterns: readonly string[];
|
|
8
|
+
deletePatterns: readonly string[];
|
|
9
|
+
};
|
|
10
|
+
export declare function createArtifactClassifier(patterns: ArtifactPatterns): ArtifactClassifier;
|
|
11
|
+
export declare function parseArtifactPatternsJson(value: unknown): {
|
|
12
|
+
keepPatterns: string[];
|
|
13
|
+
deletePatterns: string[];
|
|
14
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import picomatch from 'picomatch';
|
|
2
|
+
function matcher(patterns) {
|
|
3
|
+
if (patterns.length === 0)
|
|
4
|
+
return () => false;
|
|
5
|
+
return picomatch([...patterns]);
|
|
6
|
+
}
|
|
7
|
+
export function createArtifactClassifier(patterns) {
|
|
8
|
+
const isKeep = matcher(patterns.keepPatterns);
|
|
9
|
+
const isDelete = matcher(patterns.deletePatterns);
|
|
10
|
+
return {
|
|
11
|
+
classify: (name) => (isKeep(name) ? 'keep' : 'delete'),
|
|
12
|
+
isExplicitlyClassified: (name) => isKeep(name) || isDelete(name),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function parseArtifactPatternsJson(value) {
|
|
16
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
17
|
+
throw new Error('patterns file must be a JSON object with keep and delete arrays');
|
|
18
|
+
}
|
|
19
|
+
const record = value;
|
|
20
|
+
return {
|
|
21
|
+
keepPatterns: stringArray(record.keep, 'keep'),
|
|
22
|
+
deletePatterns: stringArray(record.delete, 'delete'),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function stringArray(value, field) {
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
return [];
|
|
28
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
29
|
+
throw new Error(`patterns file ${field} must be an array of strings`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ArtifactClassification } from './classify.mts';
|
|
2
|
+
import { deleteArtifact, getRunConclusion, listArtifactsPage, listRunArtifacts } from './github.mts';
|
|
3
|
+
import { type DeletionSummary } from './plan.mts';
|
|
4
|
+
export interface CleanupDeps {
|
|
5
|
+
listRunArtifacts: typeof listRunArtifacts;
|
|
6
|
+
listArtifactsPage: typeof listArtifactsPage;
|
|
7
|
+
getRunConclusion: typeof getRunConclusion;
|
|
8
|
+
deleteArtifact: typeof deleteArtifact;
|
|
9
|
+
}
|
|
10
|
+
export declare const defaultDeps: CleanupDeps;
|
|
11
|
+
export type CleanupRequest = {
|
|
12
|
+
repo: string;
|
|
13
|
+
token: string;
|
|
14
|
+
classify: (name: string) => ArtifactClassification;
|
|
15
|
+
deps?: CleanupDeps;
|
|
16
|
+
log?: (message: string) => void;
|
|
17
|
+
};
|
|
18
|
+
export declare function runCleanup(request: CleanupRequest & {
|
|
19
|
+
runId: string;
|
|
20
|
+
}): Promise<DeletionSummary>;
|
|
21
|
+
export declare function sweepCleanup(request: CleanupRequest & {
|
|
22
|
+
olderThanHours: number;
|
|
23
|
+
}): Promise<DeletionSummary>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { deleteArtifact, getRunConclusion, listArtifactsPage, listRunArtifacts, } from './github.mjs';
|
|
2
|
+
import { isSweepCandidate, nextPagingState, planRunDeletions, planSweepDeletions, shouldStopPaging, summarize, } from './plan.mjs';
|
|
3
|
+
export const defaultDeps = {
|
|
4
|
+
listRunArtifacts,
|
|
5
|
+
listArtifactsPage,
|
|
6
|
+
getRunConclusion,
|
|
7
|
+
deleteArtifact,
|
|
8
|
+
};
|
|
9
|
+
async function deleteAll(repo, token, artifacts, deps, log) {
|
|
10
|
+
const deleted = [];
|
|
11
|
+
for (const artifact of artifacts) {
|
|
12
|
+
const outcome = await deps.deleteArtifact(repo, token, artifact.id);
|
|
13
|
+
if (outcome === 'failed') {
|
|
14
|
+
log(`[gha-artifacts-cleanup] failed to delete ${artifact.name} (id ${artifact.id})`);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (outcome === 'not-found')
|
|
18
|
+
continue;
|
|
19
|
+
deleted.push(artifact);
|
|
20
|
+
}
|
|
21
|
+
return summarize(deleted);
|
|
22
|
+
}
|
|
23
|
+
export async function runCleanup(request) {
|
|
24
|
+
const deps = request.deps ?? defaultDeps;
|
|
25
|
+
const log = request.log ?? console.error;
|
|
26
|
+
const artifacts = await deps.listRunArtifacts(request.repo, request.token, request.runId);
|
|
27
|
+
const toDelete = planRunDeletions(artifacts, request.classify);
|
|
28
|
+
return deleteAll(request.repo, request.token, toDelete, deps, log);
|
|
29
|
+
}
|
|
30
|
+
async function collectSweepCandidates(request, cutoffIso, deps) {
|
|
31
|
+
const candidates = [];
|
|
32
|
+
let state = { page: 1, consecutiveExpiredPages: 0 };
|
|
33
|
+
for (;;) {
|
|
34
|
+
const page = await deps.listArtifactsPage(request.repo, request.token, state.page);
|
|
35
|
+
const pageArtifacts = page ?? [];
|
|
36
|
+
if (page != null) {
|
|
37
|
+
candidates.push(...pageArtifacts.filter((artifact) => isSweepCandidate(artifact, cutoffIso, request.classify)));
|
|
38
|
+
}
|
|
39
|
+
if (page == null)
|
|
40
|
+
break;
|
|
41
|
+
state = nextPagingState(pageArtifacts, state);
|
|
42
|
+
if (shouldStopPaging(pageArtifacts, state))
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
return candidates;
|
|
46
|
+
}
|
|
47
|
+
export async function sweepCleanup(request) {
|
|
48
|
+
const deps = request.deps ?? defaultDeps;
|
|
49
|
+
const log = request.log ?? console.error;
|
|
50
|
+
const cutoffIso = new Date(Date.now() - request.olderThanHours * 60 * 60 * 1000).toISOString();
|
|
51
|
+
const candidates = await collectSweepCandidates(request, cutoffIso, deps);
|
|
52
|
+
const cache = new Map();
|
|
53
|
+
const toDelete = await planSweepDeletions(candidates, (runId) => deps.getRunConclusion(request.repo, request.token, runId, cache));
|
|
54
|
+
return deleteAll(request.repo, request.token, toDelete, deps, log);
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface GithubArtifact {
|
|
2
|
+
id: number;
|
|
3
|
+
name: string;
|
|
4
|
+
size_in_bytes: number;
|
|
5
|
+
expired: boolean;
|
|
6
|
+
created_at: string;
|
|
7
|
+
workflow_run?: {
|
|
8
|
+
id: number;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export type DeleteOutcome = 'deleted' | 'not-found' | 'failed';
|
|
12
|
+
export declare function githubGet(path: string, token: string, sleepFn?: (ms: number) => Promise<void>): Promise<Response>;
|
|
13
|
+
export declare function deleteArtifact(repo: string, token: string, artifactId: number, sleepFn?: (ms: number) => Promise<void>): Promise<DeleteOutcome>;
|
|
14
|
+
export declare function listRunArtifacts(repo: string, token: string, runId: string): Promise<GithubArtifact[]>;
|
|
15
|
+
export declare function listArtifactsPage(repo: string, token: string, page: number): Promise<GithubArtifact[] | null>;
|
|
16
|
+
export declare function getRunConclusion(repo: string, token: string, runId: number, cache: Map<number, string | null>): Promise<string | null>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const API_BASE = 'https://api.github.com';
|
|
2
|
+
const API_VERSION = '2022-11-28';
|
|
3
|
+
const MAX_RETRIES = 4;
|
|
4
|
+
const PER_PAGE = 100;
|
|
5
|
+
function authHeaders(token) {
|
|
6
|
+
return {
|
|
7
|
+
Authorization: `Bearer ${token}`,
|
|
8
|
+
Accept: 'application/vnd.github+json',
|
|
9
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
function retryDelayMs(response, attempt) {
|
|
16
|
+
const retryAfter = Number(response?.headers.get('retry-after'));
|
|
17
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0)
|
|
18
|
+
return retryAfter * 1000;
|
|
19
|
+
return Math.min(1000 * 2 ** attempt, 30_000);
|
|
20
|
+
}
|
|
21
|
+
export async function githubGet(path, token, sleepFn = sleep) {
|
|
22
|
+
for (let attempt = 0;; attempt += 1) {
|
|
23
|
+
let response;
|
|
24
|
+
try {
|
|
25
|
+
response = await fetch(`${API_BASE}${path}`, { headers: authHeaders(token) });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
if (attempt === MAX_RETRIES)
|
|
29
|
+
return new Response(null, { status: 599 });
|
|
30
|
+
await sleepFn(retryDelayMs(null, attempt));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (response.status !== 403 && response.status !== 429)
|
|
34
|
+
return response;
|
|
35
|
+
if (attempt === MAX_RETRIES)
|
|
36
|
+
return response;
|
|
37
|
+
await sleepFn(retryDelayMs(response, attempt));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function deleteArtifact(repo, token, artifactId, sleepFn = sleep) {
|
|
41
|
+
for (let attempt = 0;; attempt += 1) {
|
|
42
|
+
let response;
|
|
43
|
+
try {
|
|
44
|
+
response = await fetch(`${API_BASE}/repos/${repo}/actions/artifacts/${artifactId}`, {
|
|
45
|
+
method: 'DELETE',
|
|
46
|
+
headers: authHeaders(token),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
if (attempt === MAX_RETRIES)
|
|
51
|
+
return 'failed';
|
|
52
|
+
await sleepFn(retryDelayMs(null, attempt));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (response.status === 404)
|
|
56
|
+
return 'not-found';
|
|
57
|
+
if (response.ok)
|
|
58
|
+
return 'deleted';
|
|
59
|
+
if (response.status !== 403 && response.status !== 429)
|
|
60
|
+
return 'failed';
|
|
61
|
+
if (attempt === MAX_RETRIES)
|
|
62
|
+
return 'failed';
|
|
63
|
+
await sleepFn(retryDelayMs(response, attempt));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function listRunArtifacts(repo, token, runId) {
|
|
67
|
+
const artifacts = [];
|
|
68
|
+
for (let page = 1;; page += 1) {
|
|
69
|
+
const path = `/repos/${repo}/actions/runs/${runId}/artifacts?per_page=${PER_PAGE}&page=${page}`;
|
|
70
|
+
const response = await githubGet(path, token);
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
break;
|
|
73
|
+
const body = (await response.json());
|
|
74
|
+
artifacts.push(...body.artifacts);
|
|
75
|
+
if (body.artifacts.length < PER_PAGE)
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
return artifacts;
|
|
79
|
+
}
|
|
80
|
+
export async function listArtifactsPage(repo, token, page) {
|
|
81
|
+
const path = `/repos/${repo}/actions/artifacts?per_page=${PER_PAGE}&page=${page}`;
|
|
82
|
+
const response = await githubGet(path, token);
|
|
83
|
+
if (!response.ok)
|
|
84
|
+
return null;
|
|
85
|
+
const body = (await response.json());
|
|
86
|
+
return body.artifacts;
|
|
87
|
+
}
|
|
88
|
+
export async function getRunConclusion(repo, token, runId, cache) {
|
|
89
|
+
if (cache.has(runId))
|
|
90
|
+
return cache.get(runId);
|
|
91
|
+
const response = await githubGet(`/repos/${repo}/actions/runs/${runId}`, token);
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
cache.set(runId, null);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const body = (await response.json());
|
|
97
|
+
const conclusion = body.status === 'completed' ? body.conclusion : null;
|
|
98
|
+
cache.set(runId, conclusion);
|
|
99
|
+
return conclusion;
|
|
100
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createArtifactClassifier, parseArtifactPatternsJson } from './classify.mts';
|
|
2
|
+
export type { ArtifactClassification, ArtifactClassifier, ArtifactPatterns } from './classify.mts';
|
|
3
|
+
export { defaultDeps, runCleanup, sweepCleanup } from './commands.mts';
|
|
4
|
+
export type { CleanupDeps, CleanupRequest } from './commands.mts';
|
|
5
|
+
export { deleteArtifact, getRunConclusion, githubGet, listArtifactsPage, listRunArtifacts, } from './github.mts';
|
|
6
|
+
export type { DeleteOutcome, GithubArtifact } from './github.mts';
|
|
7
|
+
export { isSweepCandidate, nextPagingState, planRunDeletions, planSweepDeletions, shouldStopPaging, summarize, } from './plan.mts';
|
|
8
|
+
export type { ArtifactLike, DeletionSummary, PagingState } from './plan.mts';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createArtifactClassifier, parseArtifactPatternsJson } from './classify.mjs';
|
|
2
|
+
export { defaultDeps, runCleanup, sweepCleanup } from './commands.mjs';
|
|
3
|
+
export { deleteArtifact, getRunConclusion, githubGet, listArtifactsPage, listRunArtifacts, } from './github.mjs';
|
|
4
|
+
export { isSweepCandidate, nextPagingState, planRunDeletions, planSweepDeletions, shouldStopPaging, summarize, } from './plan.mjs';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ArtifactClassification } from './classify.mts';
|
|
2
|
+
import type { GithubArtifact as ArtifactLike } from './github.mts';
|
|
3
|
+
export type { ArtifactLike };
|
|
4
|
+
export interface DeletionSummary {
|
|
5
|
+
deletedCount: number;
|
|
6
|
+
bytesFreed: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function summarize(deleted: Array<Pick<ArtifactLike, 'size_in_bytes'>>): DeletionSummary;
|
|
9
|
+
export declare function planRunDeletions(artifacts: ArtifactLike[], classify: (name: string) => ArtifactClassification): ArtifactLike[];
|
|
10
|
+
export declare function isSweepCandidate(artifact: ArtifactLike, cutoffIso: string, classify: (name: string) => ArtifactClassification): boolean;
|
|
11
|
+
export interface PagingState {
|
|
12
|
+
page: number;
|
|
13
|
+
consecutiveExpiredPages: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function shouldStopPaging(pageArtifacts: ArtifactLike[], state: PagingState): boolean;
|
|
16
|
+
export declare function nextPagingState(pageArtifacts: ArtifactLike[], state: PagingState): PagingState;
|
|
17
|
+
export declare function planSweepDeletions(candidates: ArtifactLike[], getConclusion: (runId: number) => Promise<string | null>): Promise<ArtifactLike[]>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function summarize(deleted) {
|
|
2
|
+
return {
|
|
3
|
+
deletedCount: deleted.length,
|
|
4
|
+
bytesFreed: deleted.reduce((total, artifact) => total + artifact.size_in_bytes, 0),
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
export function planRunDeletions(artifacts, classify) {
|
|
8
|
+
return artifacts.filter((artifact) => !artifact.expired && classify(artifact.name) === 'delete');
|
|
9
|
+
}
|
|
10
|
+
export function isSweepCandidate(artifact, cutoffIso, classify) {
|
|
11
|
+
return (!artifact.expired && artifact.created_at < cutoffIso && classify(artifact.name) === 'delete');
|
|
12
|
+
}
|
|
13
|
+
const MAX_CONSECUTIVE_EXPIRED_PAGES = 5;
|
|
14
|
+
const MAX_PAGES = 150;
|
|
15
|
+
export function shouldStopPaging(pageArtifacts, state) {
|
|
16
|
+
if (state.page >= MAX_PAGES)
|
|
17
|
+
return true;
|
|
18
|
+
if (pageArtifacts.length === 0)
|
|
19
|
+
return true;
|
|
20
|
+
return state.consecutiveExpiredPages >= MAX_CONSECUTIVE_EXPIRED_PAGES;
|
|
21
|
+
}
|
|
22
|
+
export function nextPagingState(pageArtifacts, state) {
|
|
23
|
+
const pageFullyExpired = pageArtifacts.every((artifact) => artifact.expired);
|
|
24
|
+
return {
|
|
25
|
+
page: state.page + 1,
|
|
26
|
+
consecutiveExpiredPages: pageFullyExpired ? state.consecutiveExpiredPages + 1 : 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function planSweepDeletions(candidates, getConclusion) {
|
|
30
|
+
const toDelete = [];
|
|
31
|
+
for (const artifact of candidates) {
|
|
32
|
+
const runId = artifact.workflow_run?.id;
|
|
33
|
+
if (runId == null)
|
|
34
|
+
continue;
|
|
35
|
+
const conclusion = await getConclusion(runId);
|
|
36
|
+
if (conclusion === 'success' || conclusion === 'cancelled')
|
|
37
|
+
toDelete.push(artifact);
|
|
38
|
+
}
|
|
39
|
+
return toDelete;
|
|
40
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function encodeSelectedFiles(files: readonly string[]): string;
|
|
2
|
+
export declare const SELECTED_FILES_ENV_MAX_BYTES = 120000;
|
|
3
|
+
export declare function selectedFilesExceedEnvBudget(files: readonly string[]): boolean;
|
|
4
|
+
export declare function decodeSelectedFiles(raw: string | undefined | null): string[];
|
|
5
|
+
export declare function formatMultilineOutput(key: string, value: string, createId?: () => string): string;
|
|
6
|
+
export declare function writeSelectedFilesOutput(key: string, files: readonly string[], createId?: () => string): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFileSync } from 'node:fs';
|
|
3
|
+
export function encodeSelectedFiles(files) {
|
|
4
|
+
return files.join('\n');
|
|
5
|
+
}
|
|
6
|
+
// Linux MAX_ARG_STRLEN is 131072. GitHub Actions interpolates selected-file
|
|
7
|
+
// lists into a step env block, so the encoded list plus the variable prefix
|
|
8
|
+
// must fit in one exec argument.
|
|
9
|
+
export const SELECTED_FILES_ENV_MAX_BYTES = 120_000;
|
|
10
|
+
export function selectedFilesExceedEnvBudget(files) {
|
|
11
|
+
return Buffer.byteLength(encodeSelectedFiles(files), 'utf8') > SELECTED_FILES_ENV_MAX_BYTES;
|
|
12
|
+
}
|
|
13
|
+
export function decodeSelectedFiles(raw) {
|
|
14
|
+
if (!raw)
|
|
15
|
+
return [];
|
|
16
|
+
return raw.split('\n').filter((file) => file.trim().length > 0);
|
|
17
|
+
}
|
|
18
|
+
export function formatMultilineOutput(key, value, createId = randomUUID) {
|
|
19
|
+
const delimiter = collisionFreeDelimiter(key, value, createId);
|
|
20
|
+
const body = value === '' ? '' : `${value}\n`;
|
|
21
|
+
return `${key}<<${delimiter}\n${body}${delimiter}\n`;
|
|
22
|
+
}
|
|
23
|
+
export function writeSelectedFilesOutput(key, files, createId = randomUUID) {
|
|
24
|
+
const target = process.env.GITHUB_OUTPUT;
|
|
25
|
+
if (target) {
|
|
26
|
+
appendFileSync(target, formatMultilineOutput(key, encodeSelectedFiles(files), createId));
|
|
27
|
+
}
|
|
28
|
+
console.log(`[select] ${key} (${files.length} file${files.length === 1 ? '' : 's'})`);
|
|
29
|
+
}
|
|
30
|
+
function collisionFreeDelimiter(key, value, createId) {
|
|
31
|
+
const prefix = key.toUpperCase().replace(/[^A-Z0-9]/g, '_');
|
|
32
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
33
|
+
const candidate = `${prefix}_${createId().toUpperCase().replaceAll('-', '_')}`;
|
|
34
|
+
if (!value.includes(candidate))
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`could not create a collision-free GitHub output delimiter for ${key} after 10 attempts`);
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function validateOptionalHttpOrigin(value: string, fieldName?: string): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function validateOptionalHttpOrigin(value, fieldName = 'origin') {
|
|
2
|
+
if (!value)
|
|
3
|
+
return;
|
|
4
|
+
let url;
|
|
5
|
+
try {
|
|
6
|
+
url = new URL(value);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw invalidOriginError(fieldName);
|
|
10
|
+
}
|
|
11
|
+
if (!['http:', 'https:'].includes(url.protocol) ||
|
|
12
|
+
url.username !== '' ||
|
|
13
|
+
url.password !== '' ||
|
|
14
|
+
url.pathname !== '/' ||
|
|
15
|
+
url.search !== '' ||
|
|
16
|
+
url.hash !== '' ||
|
|
17
|
+
![url.origin, `${url.origin}/`].includes(value)) {
|
|
18
|
+
throw invalidOriginError(fieldName);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function invalidOriginError(fieldName) {
|
|
22
|
+
return new Error(`${fieldName} must be empty or a pure HTTP(S) origin without credentials, path, query, or fragment`);
|
|
23
|
+
}
|
package/dist/index.d.mts
CHANGED
|
@@ -9,5 +9,10 @@ export { createVitestBlobManifest, inspectVitestBlobBundle, parseVitestBlobManif
|
|
|
9
9
|
export type { InspectedVitestBlobBundle, VitestBlobIdentity, VitestBlobManifest, } from './vitest-blob-manifest/index.mts';
|
|
10
10
|
export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mts';
|
|
11
11
|
export type { InstallOptions, Lifecycle } from './pnpm-install/index.mts';
|
|
12
|
-
export { buildContextFromTrackedFiles, buildSharedContext, gitEnv, runNamedChecks, } from './shared-context/index.mts';
|
|
13
|
-
export type { NamedCheck, SharedContext } from './shared-context/index.mts';
|
|
12
|
+
export { buildContextFromTrackedFiles, buildSharedContext, clearFakeGitEnv, gitEnv, installFakeGit, runNamedChecks, setFakeGitTrackedFiles, } from './shared-context/index.mts';
|
|
13
|
+
export type { FakeGitOptions, NamedCheck, SharedContext } from './shared-context/index.mts';
|
|
14
|
+
export { decodeSelectedFiles, encodeSelectedFiles, formatMultilineOutput, SELECTED_FILES_ENV_MAX_BYTES, selectedFilesExceedEnvBudget, writeSelectedFilesOutput, } from './gha-selected-files/index.mts';
|
|
15
|
+
export { createArtifactClassifier, parseArtifactPatternsJson, planRunDeletions, runCleanup, sweepCleanup, } from './gha-artifacts-cleanup/index.mts';
|
|
16
|
+
export type { ArtifactClassification, ArtifactClassifier, ArtifactPatterns, CleanupRequest, DeletionSummary, } from './gha-artifacts-cleanup/index.mts';
|
|
17
|
+
export { validateOptionalHttpOrigin } from './http-origin/index.mts';
|
|
18
|
+
export { boundPendingLine, DEFAULT_MAX_PENDING_LINE_LENGTH, DEFAULT_TRUNCATED_LINE_MARKER, splitCompleteLines, } from './process-line-buffer/index.mts';
|
package/dist/index.mjs
CHANGED
|
@@ -4,4 +4,8 @@ export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, re
|
|
|
4
4
|
export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/index.mjs';
|
|
5
5
|
export { createVitestBlobManifest, inspectVitestBlobBundle, parseVitestBlobManifest, serializeVitestBlobManifest, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, } from './vitest-blob-manifest/index.mjs';
|
|
6
6
|
export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mjs';
|
|
7
|
-
export { buildContextFromTrackedFiles, buildSharedContext, gitEnv, runNamedChecks, } from './shared-context/index.mjs';
|
|
7
|
+
export { buildContextFromTrackedFiles, buildSharedContext, clearFakeGitEnv, gitEnv, installFakeGit, runNamedChecks, setFakeGitTrackedFiles, } from './shared-context/index.mjs';
|
|
8
|
+
export { decodeSelectedFiles, encodeSelectedFiles, formatMultilineOutput, SELECTED_FILES_ENV_MAX_BYTES, selectedFilesExceedEnvBudget, writeSelectedFilesOutput, } from './gha-selected-files/index.mjs';
|
|
9
|
+
export { createArtifactClassifier, parseArtifactPatternsJson, planRunDeletions, runCleanup, sweepCleanup, } from './gha-artifacts-cleanup/index.mjs';
|
|
10
|
+
export { validateOptionalHttpOrigin } from './http-origin/index.mjs';
|
|
11
|
+
export { boundPendingLine, DEFAULT_MAX_PENDING_LINE_LENGTH, DEFAULT_TRUNCATED_LINE_MARKER, splitCompleteLines, } from './process-line-buffer/index.mjs';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const DEFAULT_TRUNCATED_LINE_MARKER = " [oversized line truncated] ";
|
|
2
|
+
export declare const DEFAULT_MAX_PENDING_LINE_LENGTH: number;
|
|
3
|
+
export declare function boundPendingLine(value: string, marker?: string, maxLength?: number): string;
|
|
4
|
+
export declare function splitCompleteLines(value: string): {
|
|
5
|
+
complete: string[];
|
|
6
|
+
pending: string;
|
|
7
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const DEFAULT_TRUNCATED_LINE_MARKER = ' [oversized line truncated] ';
|
|
2
|
+
export const DEFAULT_MAX_PENDING_LINE_LENGTH = 64 * 1024;
|
|
3
|
+
export function boundPendingLine(value, marker = DEFAULT_TRUNCATED_LINE_MARKER, maxLength = DEFAULT_MAX_PENDING_LINE_LENGTH) {
|
|
4
|
+
if (value.length <= maxLength)
|
|
5
|
+
return value;
|
|
6
|
+
if (marker.length >= maxLength)
|
|
7
|
+
return marker;
|
|
8
|
+
const retainedLength = maxLength - marker.length;
|
|
9
|
+
const headLength = Math.floor(retainedLength / 2);
|
|
10
|
+
const tailLength = retainedLength - headLength;
|
|
11
|
+
return value.slice(0, headLength) + marker + value.slice(-tailLength);
|
|
12
|
+
}
|
|
13
|
+
export function splitCompleteLines(value) {
|
|
14
|
+
const complete = [];
|
|
15
|
+
let start = 0;
|
|
16
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
17
|
+
const character = value[index];
|
|
18
|
+
if (character !== '\n' && character !== '\r')
|
|
19
|
+
continue;
|
|
20
|
+
if (character === '\r' && index === value.length - 1)
|
|
21
|
+
break;
|
|
22
|
+
if (character === '\r' && value[index + 1] === '\n')
|
|
23
|
+
index += 1;
|
|
24
|
+
complete.push(value.slice(start, index + 1));
|
|
25
|
+
start = index + 1;
|
|
26
|
+
}
|
|
27
|
+
return { complete, pending: value.slice(start) };
|
|
28
|
+
}
|
|
@@ -8,4 +8,5 @@ export type FakeGitOptions = {
|
|
|
8
8
|
trackedFiles?: readonly string[];
|
|
9
9
|
};
|
|
10
10
|
export declare function installFakeGit({ binDir, isInsideWorkTree, lsFilesExitCode, lsFilesStderr, pathPrefix, repoRoot, trackedFiles, }: FakeGitOptions): void;
|
|
11
|
+
export declare function setFakeGitTrackedFiles(files: readonly string[]): void;
|
|
11
12
|
export declare function clearFakeGitEnv(): void;
|
|
@@ -15,7 +15,10 @@ export function installFakeGit({ binDir, isInsideWorkTree = true, lsFilesExitCod
|
|
|
15
15
|
process.env[FAKE_GIT_LS_FILES_STDERR] = lsFilesStderr;
|
|
16
16
|
if (repoRoot !== undefined)
|
|
17
17
|
process.env[FAKE_GIT_ROOT] = repoRoot;
|
|
18
|
-
|
|
18
|
+
setFakeGitTrackedFiles(trackedFiles);
|
|
19
|
+
}
|
|
20
|
+
export function setFakeGitTrackedFiles(files) {
|
|
21
|
+
process.env[FAKE_GIT_FILES] = [...files].toSorted().join('\n');
|
|
19
22
|
}
|
|
20
23
|
export function clearFakeGitEnv() {
|
|
21
24
|
process.env.FAKE_GIT_INSIDE = 'false';
|
|
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
export { clearFakeGitEnv, installFakeGit, setFakeGitTrackedFiles } from './fake-git.mjs';
|
|
6
7
|
export function gitEnv() {
|
|
7
8
|
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')));
|
|
8
9
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -65,6 +65,26 @@
|
|
|
65
65
|
"import": "./dist/shared-context/index.mjs",
|
|
66
66
|
"default": "./dist/shared-context/index.mjs"
|
|
67
67
|
},
|
|
68
|
+
"./gha-selected-files": {
|
|
69
|
+
"types": "./dist/gha-selected-files/index.d.mts",
|
|
70
|
+
"import": "./dist/gha-selected-files/index.mjs",
|
|
71
|
+
"default": "./dist/gha-selected-files/index.mjs"
|
|
72
|
+
},
|
|
73
|
+
"./gha-artifacts-cleanup": {
|
|
74
|
+
"types": "./dist/gha-artifacts-cleanup/index.d.mts",
|
|
75
|
+
"import": "./dist/gha-artifacts-cleanup/index.mjs",
|
|
76
|
+
"default": "./dist/gha-artifacts-cleanup/index.mjs"
|
|
77
|
+
},
|
|
78
|
+
"./http-origin": {
|
|
79
|
+
"types": "./dist/http-origin/index.d.mts",
|
|
80
|
+
"import": "./dist/http-origin/index.mjs",
|
|
81
|
+
"default": "./dist/http-origin/index.mjs"
|
|
82
|
+
},
|
|
83
|
+
"./process-line-buffer": {
|
|
84
|
+
"types": "./dist/process-line-buffer/index.d.mts",
|
|
85
|
+
"import": "./dist/process-line-buffer/index.mjs",
|
|
86
|
+
"default": "./dist/process-line-buffer/index.mjs"
|
|
87
|
+
},
|
|
68
88
|
"./package.json": "./package.json"
|
|
69
89
|
},
|
|
70
90
|
"publishConfig": {
|
|
@@ -75,6 +95,12 @@
|
|
|
75
95
|
"prepack": "pnpm run build",
|
|
76
96
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
|
77
97
|
},
|
|
98
|
+
"dependencies": {
|
|
99
|
+
"picomatch": "4.0.5"
|
|
100
|
+
},
|
|
101
|
+
"devDependencies": {
|
|
102
|
+
"@types/picomatch": "^4.0.3"
|
|
103
|
+
},
|
|
78
104
|
"optionalDependencies": {
|
|
79
105
|
"@libpg-query/parser": "^18.0.0"
|
|
80
106
|
},
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
PORTS="${PORTS:-}"
|
|
4
|
+
OUTPUT_DIR="${OUTPUT_DIR:-${RUNNER_TEMP:-/tmp}/port-diagnostics}"
|
|
5
|
+
|
|
6
|
+
if [ "${PORT_DIAGNOSTICS_BOUNDED_CHILD:-}" != 1 ]; then
|
|
7
|
+
PORT_DIAGNOSTICS_BOUNDED_CHILD=1 python3 - \
|
|
8
|
+
"${PORT_DIAGNOSTICS_TIMEOUT_SECONDS:-45}" "$0" "$@" <<'PY'
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
timeout_seconds = float(sys.argv[1])
|
|
16
|
+
if timeout_seconds <= 0 or timeout_seconds > 300:
|
|
17
|
+
raise ValueError
|
|
18
|
+
except ValueError:
|
|
19
|
+
timeout_seconds = 45
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
process = subprocess.Popen(
|
|
23
|
+
["bash", sys.argv[2], *sys.argv[3:]],
|
|
24
|
+
env=os.environ.copy(),
|
|
25
|
+
start_new_session=True,
|
|
26
|
+
)
|
|
27
|
+
except OSError as error:
|
|
28
|
+
print(f"::warning::Could not start port diagnostics: {error}", file=sys.stderr)
|
|
29
|
+
sys.exit(0)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
process.wait(timeout=timeout_seconds)
|
|
33
|
+
except subprocess.TimeoutExpired:
|
|
34
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
35
|
+
try:
|
|
36
|
+
process.wait(timeout=2)
|
|
37
|
+
except subprocess.TimeoutExpired:
|
|
38
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
39
|
+
process.wait()
|
|
40
|
+
print(
|
|
41
|
+
f"::warning::Port diagnostics collector exceeded {timeout_seconds:g}s; uploading partial evidence",
|
|
42
|
+
file=sys.stderr,
|
|
43
|
+
)
|
|
44
|
+
PY
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
usage() {
|
|
49
|
+
cat <<'USAGE'
|
|
50
|
+
Usage: diagnose-port-collision [--ports "2200 2216"] [--output-dir PATH]
|
|
51
|
+
|
|
52
|
+
Best-effort, non-masking diagnostics for a failed workflow that allocated localhost ports.
|
|
53
|
+
The script never returns a failure for an unavailable probe or an occupied port.
|
|
54
|
+
USAGE
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
while [ "$#" -gt 0 ]; do
|
|
58
|
+
case "$1" in
|
|
59
|
+
--ports)
|
|
60
|
+
[ "$#" -ge 2 ] || { usage >&2; exit 0; }
|
|
61
|
+
PORTS="$2"
|
|
62
|
+
shift
|
|
63
|
+
;;
|
|
64
|
+
--output-dir)
|
|
65
|
+
[ "$#" -ge 2 ] || { usage >&2; exit 0; }
|
|
66
|
+
OUTPUT_DIR="$2"
|
|
67
|
+
shift
|
|
68
|
+
;;
|
|
69
|
+
-h|--help)
|
|
70
|
+
usage
|
|
71
|
+
exit 0
|
|
72
|
+
;;
|
|
73
|
+
*)
|
|
74
|
+
echo "::warning::Ignoring unknown diagnostics argument: $1" >&2
|
|
75
|
+
;;
|
|
76
|
+
esac
|
|
77
|
+
shift
|
|
78
|
+
done
|
|
79
|
+
|
|
80
|
+
if ! mkdir -p "$OUTPUT_DIR" 2>/dev/null; then
|
|
81
|
+
echo "::warning::Could not create port diagnostics directory: $OUTPUT_DIR" >&2
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
REPORT="$OUTPUT_DIR/summary.txt"
|
|
86
|
+
LISTENERS="$OUTPUT_DIR/listeners.txt"
|
|
87
|
+
DOCKER_REPORT="$OUTPUT_DIR/docker.txt"
|
|
88
|
+
KERNEL_REPORT="$OUTPUT_DIR/kernel.txt"
|
|
89
|
+
RUNNER_REPORT="$OUTPUT_DIR/runner.txt"
|
|
90
|
+
|
|
91
|
+
write_report_header() {
|
|
92
|
+
local path="$1"
|
|
93
|
+
{
|
|
94
|
+
echo "generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unavailable)"
|
|
95
|
+
echo "workflow=${GITHUB_WORKFLOW:-unavailable}"
|
|
96
|
+
echo "job=${GITHUB_JOB:-unavailable}"
|
|
97
|
+
echo "run_id=${GITHUB_RUN_ID:-unavailable}"
|
|
98
|
+
echo "run_attempt=${GITHUB_RUN_ATTEMPT:-unavailable}"
|
|
99
|
+
echo "runner_name=${RUNNER_NAME:-unavailable}"
|
|
100
|
+
echo "runner_os=${RUNNER_OS:-unavailable}"
|
|
101
|
+
echo "workspace=${GITHUB_WORKSPACE:-unavailable}"
|
|
102
|
+
echo "allocated_ports=${PORTS:-none}"
|
|
103
|
+
} > "$path" 2>/dev/null || true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
write_report_header "$REPORT"
|
|
107
|
+
|
|
108
|
+
run_bounded() {
|
|
109
|
+
local seconds="$1"
|
|
110
|
+
shift
|
|
111
|
+
python3 - "$seconds" "$@" <<'PY'
|
|
112
|
+
import subprocess
|
|
113
|
+
import sys
|
|
114
|
+
try:
|
|
115
|
+
subprocess.run(sys.argv[2:], check=False, timeout=float(sys.argv[1]))
|
|
116
|
+
except subprocess.TimeoutExpired:
|
|
117
|
+
print(f'probe=timed-out after {sys.argv[1]}s', file=sys.stderr)
|
|
118
|
+
except OSError as error:
|
|
119
|
+
print(f'probe=unavailable ({error})', file=sys.stderr)
|
|
120
|
+
PY
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
valid_ports=()
|
|
124
|
+
invalid_ports=()
|
|
125
|
+
read -r -a requested_ports <<< "$PORTS"
|
|
126
|
+
for port in "${requested_ports[@]}"; do
|
|
127
|
+
case "$port" in
|
|
128
|
+
''|*[!0-9]*) invalid_ports+=("$port") ;;
|
|
129
|
+
*)
|
|
130
|
+
if [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; then
|
|
131
|
+
valid_ports+=("$port")
|
|
132
|
+
else
|
|
133
|
+
invalid_ports+=("$port")
|
|
134
|
+
fi
|
|
135
|
+
;;
|
|
136
|
+
esac
|
|
137
|
+
done
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
echo "requested_port_count=${#requested_ports[@]}"
|
|
141
|
+
echo "valid_port_count=${#valid_ports[@]}"
|
|
142
|
+
echo "invalid_ports=${invalid_ports[*]:-none}"
|
|
143
|
+
} >> "$REPORT" 2>/dev/null || true
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
echo "# Linux kernel port contract (best effort)"
|
|
147
|
+
if command -v sysctl >/dev/null 2>&1; then
|
|
148
|
+
echo "ip_local_reserved_ports=$(sysctl -n net.ipv4.ip_local_reserved_ports 2>/dev/null || echo unavailable)"
|
|
149
|
+
echo "ip_local_port_range=$(sysctl -n net.ipv4.ip_local_port_range 2>/dev/null || echo unavailable)"
|
|
150
|
+
else
|
|
151
|
+
echo "sysctl=unavailable"
|
|
152
|
+
fi
|
|
153
|
+
for path in /proc/sys/net/ipv4/ip_local_reserved_ports /proc/sys/net/ipv4/ip_local_port_range; do
|
|
154
|
+
if [ -r "$path" ]; then
|
|
155
|
+
echo "$path=$(tr -d '\n' < "$path" 2>/dev/null || true)"
|
|
156
|
+
fi
|
|
157
|
+
done
|
|
158
|
+
} > "$KERNEL_REPORT" 2>/dev/null || true
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
echo "# TCP socket diagnostics (best effort)"
|
|
162
|
+
lsof_available=false
|
|
163
|
+
ss_available=false
|
|
164
|
+
lsof_output=''
|
|
165
|
+
if command -v lsof >/dev/null 2>&1; then
|
|
166
|
+
lsof_available=true
|
|
167
|
+
lsof_output=$(run_bounded 10 lsof -nP -iTCP 2>&1 || true)
|
|
168
|
+
elif command -v ss >/dev/null 2>&1; then
|
|
169
|
+
ss_available=true
|
|
170
|
+
fi
|
|
171
|
+
for port in "${valid_ports[@]}"; do
|
|
172
|
+
occupied=false
|
|
173
|
+
echo "port=$port"
|
|
174
|
+
if [ "$lsof_available" = true ]; then
|
|
175
|
+
if lsof_filtered=$(printf '%s\n' "$lsof_output" | awk -v port="$port" '
|
|
176
|
+
/^probe=/ { print; next }
|
|
177
|
+
$1 == "COMMAND" { header = $0; next }
|
|
178
|
+
{
|
|
179
|
+
local_endpoint = $9
|
|
180
|
+
sub(/->.*/, "", local_endpoint)
|
|
181
|
+
if (local_endpoint ~ (":" port "$")) {
|
|
182
|
+
if (!printed_header && header != "") {
|
|
183
|
+
print header
|
|
184
|
+
printed_header = 1
|
|
185
|
+
}
|
|
186
|
+
print
|
|
187
|
+
found = 1
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
END { exit found ? 0 : 1 }
|
|
191
|
+
'); then
|
|
192
|
+
occupied=true
|
|
193
|
+
fi
|
|
194
|
+
[ -z "$lsof_filtered" ] || printf '%s\n' "$lsof_filtered"
|
|
195
|
+
elif [ "$ss_available" = true ]; then
|
|
196
|
+
ss -ntp "( sport = :$port )" 2>&1 || true
|
|
197
|
+
if ss -nt "( sport = :$port )" 2>/dev/null | tail -n +2 | grep -q .; then
|
|
198
|
+
occupied=true
|
|
199
|
+
fi
|
|
200
|
+
else
|
|
201
|
+
echo "probe=unavailable (neither lsof nor ss is installed)"
|
|
202
|
+
fi
|
|
203
|
+
echo "status=$([ "$occupied" = true ] && echo occupied || echo free-or-unobserved)"
|
|
204
|
+
echo
|
|
205
|
+
done
|
|
206
|
+
} > "$LISTENERS" 2>/dev/null || true
|
|
207
|
+
|
|
208
|
+
{
|
|
209
|
+
echo "# Docker diagnostics (best effort)"
|
|
210
|
+
if command -v docker >/dev/null 2>&1; then
|
|
211
|
+
run_bounded 10 docker version --format 'server={{.Server.Version}}' 2>&1 || true
|
|
212
|
+
docker_ps_output=$(run_bounded 10 docker ps --no-trunc --format 'container={{.ID}} names={{.Names}} ports={{.Ports}}' 2>&1 || true)
|
|
213
|
+
for port in "${valid_ports[@]}"; do
|
|
214
|
+
echo "published_port=$port"
|
|
215
|
+
printf '%s\n' "$docker_ps_output" | awk -v port="$port" '/^probe=/ || $0 ~ "(^|[^0-9])" port "->"' || true
|
|
216
|
+
done
|
|
217
|
+
else
|
|
218
|
+
echo "docker=unavailable"
|
|
219
|
+
fi
|
|
220
|
+
} > "$DOCKER_REPORT" 2>/dev/null || true
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
echo "# Runner context (deliberately excludes the process environment)"
|
|
224
|
+
uname -a 2>&1 || true
|
|
225
|
+
hostname 2>&1 || true
|
|
226
|
+
id 2>&1 || true
|
|
227
|
+
echo "runner_temp=${RUNNER_TEMP:-unavailable}"
|
|
228
|
+
echo "github_actions=${GITHUB_ACTIONS:-unavailable}"
|
|
229
|
+
} > "$RUNNER_REPORT" 2>/dev/null || true
|
|
230
|
+
|
|
231
|
+
echo "::notice::Port diagnostics captured under $OUTPUT_DIR"
|
|
232
|
+
exit 0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
mirror_repository="${TRIVY_DB_MIRROR_REPOSITORY:-mirror.gcr.io/aquasec/trivy-db:2}"
|
|
5
|
+
official_repository="${TRIVY_DB_OFFICIAL_REPOSITORY:-ghcr.io/aquasecurity/trivy-db:2}"
|
|
6
|
+
download_timeout="${TRIVY_DB_TIMEOUT:-75s}"
|
|
7
|
+
|
|
8
|
+
download_database() {
|
|
9
|
+
trivy image \
|
|
10
|
+
--download-db-only \
|
|
11
|
+
--no-progress \
|
|
12
|
+
--timeout "$download_timeout" \
|
|
13
|
+
--db-repository "$1"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
mirror_exit=0
|
|
17
|
+
download_database "$mirror_repository" || mirror_exit=$?
|
|
18
|
+
if [ "$mirror_exit" -eq 0 ]; then
|
|
19
|
+
echo "Trivy vulnerability database prepared from the Google mirror"
|
|
20
|
+
exit 0
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
echo "Trivy database mirror failed with exit ${mirror_exit}; retrying from official GHCR"
|
|
24
|
+
official_exit=0
|
|
25
|
+
download_database "$official_repository" || official_exit=$?
|
|
26
|
+
if [ "$official_exit" -eq 0 ]; then
|
|
27
|
+
echo "Trivy vulnerability database prepared from official GHCR"
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
echo "Trivy vulnerability database download failed from the Google mirror (exit ${mirror_exit}) and official GHCR (exit ${official_exit})" >&2
|
|
32
|
+
exit "$official_exit"
|