vouchington-tooling 0.1.4 → 0.1.6

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 (47) hide show
  1. package/README.md +36 -0
  2. package/dist/cli/index.mjs +4 -0
  3. package/dist/cli/parse.d.mts +1 -1
  4. package/dist/cli/parse.mjs +1 -0
  5. package/dist/cli/usage.d.mts +1 -1
  6. package/dist/cli/usage.mjs +3 -1
  7. package/dist/gha-post-review/post.mjs +2 -9
  8. package/dist/gha-review-payload/index.d.mts +1 -1
  9. package/dist/gha-review-payload/index.mjs +1 -1
  10. package/dist/gha-review-payload/payload.d.mts +1 -3
  11. package/dist/gha-review-payload/payload.mjs +15 -31
  12. package/dist/gha-review-payload/remap.d.mts +1 -1
  13. package/dist/gha-review-payload/remap.mjs +7 -16
  14. package/dist/index.d.mts +3 -1
  15. package/dist/index.mjs +2 -1
  16. package/dist/retrospective-transcript/codex-messages.d.mts +8 -0
  17. package/dist/retrospective-transcript/codex-messages.mjs +66 -0
  18. package/dist/retrospective-transcript/codex.mjs +9 -4
  19. package/dist/session-friction/ci-failures.d.mts +11 -0
  20. package/dist/session-friction/ci-failures.mjs +78 -0
  21. package/dist/session-friction/classify.d.mts +2 -0
  22. package/dist/session-friction/classify.mjs +87 -0
  23. package/dist/session-friction/directory.d.mts +1 -0
  24. package/dist/session-friction/directory.mjs +71 -0
  25. package/dist/session-friction/index.d.mts +6 -0
  26. package/dist/session-friction/index.mjs +5 -0
  27. package/dist/session-friction/lock.d.mts +1 -0
  28. package/dist/session-friction/lock.mjs +193 -0
  29. package/dist/session-friction/log.d.mts +8 -0
  30. package/dist/session-friction/log.mjs +195 -0
  31. package/dist/session-friction/normalize.d.mts +1 -0
  32. package/dist/session-friction/normalize.mjs +174 -0
  33. package/dist/session-friction/report.d.mts +2 -0
  34. package/dist/session-friction/report.mjs +113 -0
  35. package/dist/session-friction/sandbox.d.mts +2 -0
  36. package/dist/session-friction/sandbox.mjs +15 -0
  37. package/dist/session-friction/session-id.d.mts +2 -0
  38. package/dist/session-friction/session-id.mjs +17 -0
  39. package/dist/session-friction/text.d.mts +5 -0
  40. package/dist/session-friction/text.mjs +45 -0
  41. package/dist/session-friction/types.d.mts +52 -0
  42. package/dist/session-friction/types.mjs +1 -0
  43. package/dist/session-friction/utf8.d.mts +1 -0
  44. package/dist/session-friction/utf8.mjs +8 -0
  45. package/package.json +7 -2
  46. package/scripts/gha/download-optional-run-artifacts.sh +126 -0
  47. package/scripts/gha/install-github-release.sh +17 -4
@@ -0,0 +1,174 @@
1
+ const MAX_COMMAND_TOKEN_LENGTH = 40;
2
+ const MAX_COMMAND_LENGTH = 100_000;
3
+ const MAX_COMMAND_WRAPPERS = 16;
4
+ const REDACTED_TOKEN = '[REDACTED]';
5
+ const CREDENTIAL_OPTION = /^--?(?:api[-_]?key|auth|credential|password|secret|token)=/i;
6
+ const URL_USERINFO = /^[a-z][a-z0-9+.-]*:\/\/[^/@\s]+@/i;
7
+ const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
8
+ const PACKAGE_RUNNERS = new Set(['npm', 'npx', 'pnpm', 'pnpx', 'yarn']);
9
+ const GIT_OPTIONS_WITH_ARGS = new Set(['-C', '-c', '--git-dir', '--work-tree']);
10
+ const ENV_OPTIONS_WITH_ARGS = new Set(['-C', '--chdir', '-S', '--split-string', '-u', '--unset']);
11
+ const ENV_OPTIONS_WITHOUT_ARGS = new Set([
12
+ '-i',
13
+ '--ignore-environment',
14
+ '-0',
15
+ '--null',
16
+ '-v',
17
+ '--debug',
18
+ ]);
19
+ const GIT_OPTIONS_WITHOUT_ARGS = new Set([
20
+ '-v',
21
+ '--version',
22
+ '-h',
23
+ '--help',
24
+ '--exec-path',
25
+ '--html-path',
26
+ '--man-path',
27
+ '--info-path',
28
+ '-p',
29
+ '--paginate',
30
+ '-P',
31
+ '--no-pager',
32
+ '--no-replace-objects',
33
+ '--bare',
34
+ ]);
35
+ function splitCommand(command) {
36
+ const result = [];
37
+ let segment = [];
38
+ let word = '';
39
+ let quote;
40
+ let escaped = false;
41
+ const flushWord = () => {
42
+ if (word)
43
+ segment.push(word);
44
+ word = '';
45
+ };
46
+ const flushSegment = () => {
47
+ flushWord();
48
+ if (segment.length)
49
+ result.push(segment);
50
+ segment = [];
51
+ };
52
+ for (const char of command) {
53
+ if (escaped) {
54
+ word += char;
55
+ escaped = false;
56
+ }
57
+ else if (char === '\\' && quote !== "'") {
58
+ escaped = true;
59
+ }
60
+ else if (quote) {
61
+ if (char === quote)
62
+ quote = undefined;
63
+ else
64
+ word += char;
65
+ }
66
+ else if (char === '"' || char === "'")
67
+ quote = char;
68
+ else if (';&|<>\n\r'.includes(char))
69
+ flushSegment();
70
+ else if (/\s/.test(char))
71
+ flushWord();
72
+ else
73
+ word += char;
74
+ }
75
+ if (escaped)
76
+ word += '\\';
77
+ flushSegment();
78
+ return result;
79
+ }
80
+ function redact(token) {
81
+ return token.length > MAX_COMMAND_TOKEN_LENGTH ||
82
+ CREDENTIAL_OPTION.test(token) ||
83
+ URL_USERINFO.test(token)
84
+ ? REDACTED_TOKEN
85
+ : token;
86
+ }
87
+ function stripAssignments(tokens) {
88
+ let index = 0;
89
+ while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index]))
90
+ index++;
91
+ const wrapper = tokens[index];
92
+ // Assignments without an executable are represented by a safe sentinel.
93
+ if (wrapper !== 'env' && !wrapper?.endsWith('/env'))
94
+ return index < tokens.length ? tokens.slice(index) : index ? [REDACTED_TOKEN] : tokens;
95
+ index++;
96
+ while (index < tokens.length) {
97
+ const option = tokens[index];
98
+ if (option === '--') {
99
+ index++;
100
+ break;
101
+ }
102
+ const optionName = option.split('=', 1)[0];
103
+ const splitStringOption = optionName === '-S' || (optionName.length >= 5 && '--split-string'.startsWith(optionName));
104
+ if (splitStringOption)
105
+ return [REDACTED_TOKEN];
106
+ const abbreviatedArgument = optionName.length >= 5 && '--chdir'.startsWith(optionName);
107
+ if (ENV_OPTIONS_WITH_ARGS.has(optionName) || abbreviatedArgument) {
108
+ index += option.includes('=') ? 1 : 2;
109
+ continue;
110
+ }
111
+ if (ENV_OPTIONS_WITHOUT_ARGS.has(option)) {
112
+ index++;
113
+ continue;
114
+ }
115
+ if (option.startsWith('-'))
116
+ return [REDACTED_TOKEN];
117
+ break;
118
+ }
119
+ while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index]))
120
+ index++;
121
+ if (index < tokens.length)
122
+ return tokens.slice(index);
123
+ return tokens.some((token) => ENV_ASSIGNMENT.test(token)) ? [REDACTED_TOKEN] : [];
124
+ }
125
+ function stripGitOptions(tokens) {
126
+ let index = 0;
127
+ while (index < tokens.length) {
128
+ const token = tokens[index];
129
+ if (token === '--')
130
+ return tokens.slice(index + 1);
131
+ if (GIT_OPTIONS_WITH_ARGS.has(token)) {
132
+ if (index + 1 >= tokens.length)
133
+ return [REDACTED_TOKEN];
134
+ index += 2;
135
+ }
136
+ else if (GIT_OPTIONS_WITHOUT_ARGS.has(token) || /^--[a-z0-9-]+=/i.test(token))
137
+ index++;
138
+ else
139
+ break;
140
+ }
141
+ return tokens.slice(index);
142
+ }
143
+ function packageRunner(token) {
144
+ return [...PACKAGE_RUNNERS].some((runner) => token === runner || token.endsWith(`/${runner}`));
145
+ }
146
+ function normalizeSegment(tokens) {
147
+ const [first, ...rest] = tokens;
148
+ if (!first)
149
+ return '';
150
+ if (packageRunner(first) && (rest[0] === 'run' || rest[0] === 'exec') && rest[1])
151
+ return `${redact(first)} ${rest[0]} ${redact(rest[1])}`;
152
+ const effective = first === 'git' || first.endsWith('/git') ? stripGitOptions(rest) : rest;
153
+ return effective[0] ? `${redact(first)} ${redact(effective[0])}` : redact(first);
154
+ }
155
+ export function normalizeCommandPrefix(command, wrappersToStrip = []) {
156
+ if (command.length > MAX_COMMAND_LENGTH)
157
+ return '[REDACTED: command too long]';
158
+ const segments = splitCommand(command);
159
+ let index = 0;
160
+ while (index < segments.length - 1 && stripAssignments(segments[index])[0] === 'cd')
161
+ index++;
162
+ const tokens = stripAssignments(segments[index] ?? []);
163
+ const wrapperValues = Array.isArray(wrappersToStrip) ? wrappersToStrip : [];
164
+ const wrapperNames = new Set(wrapperValues
165
+ .slice(0, MAX_COMMAND_WRAPPERS)
166
+ .filter((value) => typeof value === 'string' && value.length > 0 && value.length <= MAX_COMMAND_TOKEN_LENGTH));
167
+ let wrappers = 0;
168
+ while (tokens[wrappers] && wrapperNames.has(tokens[wrappers]))
169
+ wrappers++;
170
+ if (!wrappers)
171
+ return normalizeSegment(tokens);
172
+ const nested = normalizeSegment(stripAssignments(tokens.slice(wrappers)));
173
+ return nested ? `${redact(tokens[0])} ${nested}` : redact(tokens[0]);
174
+ }
@@ -0,0 +1,2 @@
1
+ import type { SessionFrictionReport, SessionFrictionReportOptions } from './types.mts';
2
+ export declare function buildSessionFrictionReport(sessionId: string, options: SessionFrictionReportOptions): Promise<SessionFrictionReport>;
@@ -0,0 +1,113 @@
1
+ import { eventLimit, readFrictionLog, requireDirectory } from './log.mjs';
2
+ import { buildCiFailuresSection, getConformingGroups } from './ci-failures.mjs';
3
+ import { buildSandboxSection } from './sandbox.mjs';
4
+ import { validateSessionId } from './session-id.mjs';
5
+ const JOURNAL_ENTRY_MAX_COUNT = 500;
6
+ const JOURNAL_MARKDOWN_MAX_BYTES = 10_000;
7
+ const JOURNAL_TOTAL_MAX_BYTES = 1_000_000;
8
+ function journalData(entry) {
9
+ try {
10
+ const data = entry?.data;
11
+ return data ? { type: data.type, markdown: data.markdown } : undefined;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ async function collectEntries(entries) {
18
+ const result = [];
19
+ let consumed = 0;
20
+ let inspectedBytes = 0;
21
+ let retainedBytes = 0;
22
+ let truncated = false;
23
+ for await (const entry of entries) {
24
+ consumed++;
25
+ const data = journalData(entry);
26
+ const markdown = data?.markdown;
27
+ if (data?.type === 'journal' && typeof markdown === 'string') {
28
+ const bytes = Buffer.byteLength(markdown);
29
+ inspectedBytes += bytes;
30
+ if (bytes > JOURNAL_MARKDOWN_MAX_BYTES)
31
+ truncated = true;
32
+ if (bytes <= JOURNAL_MARKDOWN_MAX_BYTES && retainedBytes + bytes <= JOURNAL_TOTAL_MAX_BYTES) {
33
+ result.push({ data: { type: 'journal', markdown } });
34
+ retainedBytes += bytes;
35
+ }
36
+ }
37
+ if (consumed >= JOURNAL_ENTRY_MAX_COUNT ||
38
+ inspectedBytes >= JOURNAL_TOTAL_MAX_BYTES ||
39
+ retainedBytes >= JOURNAL_TOTAL_MAX_BYTES) {
40
+ truncated = true;
41
+ break;
42
+ }
43
+ }
44
+ return { entries: result, truncated };
45
+ }
46
+ function errorMessage(error) {
47
+ try {
48
+ return error instanceof Error ? error.message : String(error);
49
+ }
50
+ catch {
51
+ return '[unprintable error]';
52
+ }
53
+ }
54
+ function sandboxMarkdown(friction) {
55
+ if (friction.status === 'events')
56
+ return buildSandboxSection(friction.events);
57
+ const status = friction.status === 'empty' ? 'none observed' : 'unavailable (no friction log)';
58
+ return `## Sandbox & Permission Audit\nStatus: ${status}`;
59
+ }
60
+ function reportFromLog(sessionId, logOptions, journal) {
61
+ try {
62
+ const friction = readFrictionLog(sessionId, logOptions);
63
+ if (journal.status === 'ok' && journal.truncated)
64
+ return {
65
+ markdown: '## CI Failures\nStatus: unavailable (journal scan incomplete)\n\n' +
66
+ sandboxMarkdown(friction),
67
+ };
68
+ const markdown = buildCiFailuresSection(sessionId, journal, friction.status);
69
+ return { markdown: `${markdown}\n\n${sandboxMarkdown(friction)}` };
70
+ }
71
+ catch (error) {
72
+ const ciMarkdown = journal.status === 'ok' && journal.truncated
73
+ ? '## CI Failures\nStatus: unavailable (journal scan incomplete)'
74
+ : journal.status === 'ok' && journal.markdownBlocks.length === 0
75
+ ? '## CI Failures\nStatus: unavailable (friction log unreadable)'
76
+ : buildCiFailuresSection(sessionId, journal, 'empty');
77
+ return {
78
+ markdown: `${ciMarkdown}\n\n## Sandbox & Permission Audit\nStatus: unavailable (friction log unreadable)`,
79
+ diagnostic: errorMessage(error),
80
+ };
81
+ }
82
+ }
83
+ export async function buildSessionFrictionReport(sessionId, options) {
84
+ validateSessionId(sessionId);
85
+ requireDirectory(options.directory);
86
+ eventLimit(options.maxEvents);
87
+ let journal;
88
+ try {
89
+ const loaded = await options.journalLoader(sessionId);
90
+ if (loaded.status === 'not-found')
91
+ journal = { status: 'ok', markdownBlocks: [], truncated: false };
92
+ else {
93
+ const collected = await collectEntries(loaded.entries);
94
+ journal = {
95
+ status: 'ok',
96
+ markdownBlocks: getConformingGroups(collected.entries),
97
+ truncated: collected.truncated,
98
+ };
99
+ }
100
+ }
101
+ catch (error) {
102
+ const diagnostic = errorMessage(error);
103
+ journal = { status: 'unreachable', diagnostic };
104
+ }
105
+ const report = reportFromLog(sessionId, options, journal);
106
+ if (journal.status === 'unreachable') {
107
+ const diagnostic = report.diagnostic
108
+ ? `${journal.diagnostic}; ${report.diagnostic}`
109
+ : journal.diagnostic;
110
+ return { ...report, diagnostic };
111
+ }
112
+ return report;
113
+ }
@@ -0,0 +1,2 @@
1
+ import type { FrictionEvent } from './types.mts';
2
+ export declare function buildSandboxSection(events: FrictionEvent[]): string;
@@ -0,0 +1,15 @@
1
+ import { markdownAuditText } from './text.mjs';
2
+ const SANDBOX_SECTION_HEADER = '## Sandbox & Permission Audit';
3
+ const KIND_ORDER = ['sandbox-escalation', 'sandbox-failure'];
4
+ export function buildSandboxSection(events) {
5
+ const groups = KIND_ORDER.map((kind) => {
6
+ const selected = events.filter((event) => event.kind === kind);
7
+ return selected.length
8
+ ? [
9
+ `- ${kind} (${selected.length})`,
10
+ ...selected.map((event) => ` - ${markdownAuditText(event.commandPrefix)} — ${markdownAuditText(event.detail)} — ${markdownAuditText(event.timestamp)}`),
11
+ ].join('\n')
12
+ : undefined;
13
+ }).filter((group) => group !== undefined);
14
+ return `${SANDBOX_SECTION_HEADER}\nEvents observed: ${events.length}\n\n${groups.join('\n')}`;
15
+ }
@@ -0,0 +1,2 @@
1
+ export declare function validateSessionId(sessionId: string): void;
2
+ export declare function sanitizeSessionId(sessionId: string): string;
@@ -0,0 +1,17 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isWellFormedUnicode, normalizeAuditText } from './text.mjs';
3
+ const SESSION_ID_MAX_LENGTH = 4096;
4
+ export function validateSessionId(sessionId) {
5
+ if (typeof sessionId !== 'string')
6
+ throw new Error('sessionId must be a string');
7
+ if (sessionId.length > SESSION_ID_MAX_LENGTH)
8
+ throw new Error('sessionId is too long');
9
+ if (!isWellFormedUnicode(sessionId))
10
+ throw new Error('sessionId must be well-formed Unicode');
11
+ if (normalizeAuditText(sessionId) === '')
12
+ throw new Error('sessionId must be non-empty');
13
+ }
14
+ export function sanitizeSessionId(sessionId) {
15
+ validateSessionId(sessionId);
16
+ return createHash('sha256').update(sessionId).digest('hex');
17
+ }
@@ -0,0 +1,5 @@
1
+ export declare function normalizeAuditText(value: string): string;
2
+ export declare function boundedText(value: string, maximum: number): string;
3
+ export declare function isWellFormedUnicode(value: string): boolean;
4
+ export declare function isSafeAuditText(value: unknown): value is string;
5
+ export declare function markdownAuditText(value: string): string;
@@ -0,0 +1,45 @@
1
+ const CONTROL_CHARACTERS = /[\p{Cc}\p{Cf}]+/gu;
2
+ const MARKDOWN_CHARACTER = /\\|`|\*|_|~|\[|\]|<|>|#|-|\+|!|\||&/;
3
+ const MARKDOWN_AUDIT_MAX_LENGTH = 120;
4
+ export function normalizeAuditText(value) {
5
+ return value.replace(CONTROL_CHARACTERS, ' ').trim();
6
+ }
7
+ export function boundedText(value, maximum) {
8
+ let end = Math.min(value.length, maximum);
9
+ if (end < value.length &&
10
+ /[\uD800-\uDBFF]/.test(value[end - 1]) &&
11
+ /[\uDC00-\uDFFF]/.test(value[end]))
12
+ end--;
13
+ return value.slice(0, end);
14
+ }
15
+ export function isWellFormedUnicode(value) {
16
+ for (let index = 0; index < value.length; index++) {
17
+ const code = value.charCodeAt(index);
18
+ if (code >= 0xd800 && code <= 0xdbff) {
19
+ const next = value.charCodeAt(index + 1);
20
+ if (!(next >= 0xdc00 && next <= 0xdfff))
21
+ return false;
22
+ index++;
23
+ }
24
+ else if (code >= 0xdc00 && code <= 0xdfff)
25
+ return false;
26
+ }
27
+ return true;
28
+ }
29
+ export function isSafeAuditText(value) {
30
+ return (typeof value === 'string' &&
31
+ value !== '' &&
32
+ isWellFormedUnicode(value) &&
33
+ value === normalizeAuditText(value));
34
+ }
35
+ export function markdownAuditText(value) {
36
+ // Escaping is atomic, so the result may be shorter than the maximum.
37
+ let result = '';
38
+ for (const character of normalizeAuditText(value)) {
39
+ const escaped = MARKDOWN_CHARACTER.test(character) ? `\\${character}` : character;
40
+ if (result.length + escaped.length > MARKDOWN_AUDIT_MAX_LENGTH)
41
+ break;
42
+ result += escaped;
43
+ }
44
+ return result;
45
+ }
@@ -0,0 +1,52 @@
1
+ export type FrictionEventKind = 'sandbox-escalation' | 'sandbox-failure';
2
+ export type FrictionEvent = {
3
+ kind: FrictionEventKind;
4
+ timestamp: string;
5
+ commandPrefix: string;
6
+ detail: string;
7
+ };
8
+ export type ToolResultObservation = {
9
+ type: 'tool-result';
10
+ command: string;
11
+ commandWrappers?: string[];
12
+ escalationDetail?: string;
13
+ structuredStderr?: string;
14
+ };
15
+ export type PermissionRequestObservation = {
16
+ type: 'permission-request';
17
+ command: string;
18
+ commandWrappers?: string[];
19
+ };
20
+ export type FrictionObservation = ToolResultObservation | PermissionRequestObservation;
21
+ export type FrictionLogReadResult = {
22
+ status: 'absent';
23
+ } | {
24
+ status: 'empty';
25
+ } | {
26
+ status: 'events';
27
+ events: FrictionEvent[];
28
+ };
29
+ export type FrictionLogOptions = {
30
+ directory: string;
31
+ maxEvents?: number;
32
+ };
33
+ export type JournalEntry = {
34
+ data?: {
35
+ type?: unknown;
36
+ markdown?: unknown;
37
+ };
38
+ };
39
+ export type JournalLoadResult = {
40
+ status: 'ok';
41
+ entries: Iterable<JournalEntry> | AsyncIterable<JournalEntry>;
42
+ } | {
43
+ status: 'not-found';
44
+ };
45
+ export type JournalLoader = (sessionId: string) => JournalLoadResult | Promise<JournalLoadResult>;
46
+ export type SessionFrictionReport = {
47
+ markdown: string;
48
+ diagnostic?: string;
49
+ };
50
+ export type SessionFrictionReportOptions = FrictionLogOptions & {
51
+ journalLoader: JournalLoader;
52
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare function decodeUtf8(value: Uint8Array): string;
@@ -0,0 +1,8 @@
1
+ export function decodeUtf8(value) {
2
+ try {
3
+ return new TextDecoder('utf-8', { fatal: true }).decode(value);
4
+ }
5
+ catch {
6
+ throw new Error('session-friction log is not valid UTF-8');
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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": {
@@ -41,6 +41,11 @@
41
41
  "import": "./dist/retrospective-transcript/index.mjs",
42
42
  "default": "./dist/retrospective-transcript/index.mjs"
43
43
  },
44
+ "./session-friction": {
45
+ "types": "./dist/session-friction/index.d.mts",
46
+ "import": "./dist/session-friction/index.mjs",
47
+ "default": "./dist/session-friction/index.mjs"
48
+ },
44
49
  "./sql-ast": {
45
50
  "types": "./dist/sql-ast/index.d.mts",
46
51
  "import": "./dist/sql-ast/index.mjs",
@@ -223,7 +228,7 @@
223
228
  },
224
229
  "dependencies": {
225
230
  "csv-parse": "7.0.2",
226
- "csv-stringify": "6.8.2",
231
+ "csv-stringify": "6.8.3",
227
232
  "dockerfile-ast": "0.7.1",
228
233
  "picomatch": "4.0.5",
229
234
  "yaml": "2.9.0"
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ echo 'usage: download-optional-run-artifacts.sh (--name <name> | --pattern <pattern>) --dir <directory>' >&2
6
+ exit 2
7
+ }
8
+
9
+ selector=''
10
+ selector_type=''
11
+ destination=''
12
+ while [ "$#" -gt 0 ]; do
13
+ case "$1" in
14
+ --name|--pattern)
15
+ [ -z "$selector_type" ] && [ "$#" -ge 2 ] || usage
16
+ selector_type="${1#--}"
17
+ selector="$2"
18
+ shift 2
19
+ ;;
20
+ --dir)
21
+ [ -z "$destination" ] && [ "$#" -ge 2 ] || usage
22
+ destination="$2"
23
+ shift 2
24
+ ;;
25
+ *) usage ;;
26
+ esac
27
+ done
28
+
29
+ [ -n "$selector_type" ] && [ -n "$selector" ] && [ -n "$destination" ] || usage
30
+ [ -n "${GITHUB_OUTPUT:-}" ] || { echo 'GITHUB_OUTPUT must be set' >&2; exit 2; }
31
+ [ -n "${GITHUB_REPOSITORY:-}" ] || { echo 'GITHUB_REPOSITORY must be set' >&2; exit 2; }
32
+ [ -n "${GITHUB_RUN_ID:-}" ] || { echo 'GITHUB_RUN_ID must be set' >&2; exit 2; }
33
+ github_host="${GH_HOST:-${GITHUB_SERVER_URL:-https://github.com}}"
34
+ github_host="${github_host#*://}"
35
+ github_host="${github_host%%/*}"
36
+ [ -n "$github_host" ] || { echo 'GitHub host must be set' >&2; exit 2; }
37
+ repository="$GITHUB_REPOSITORY"
38
+ case "$github_host" in
39
+ github.com|*.ghe.com) ;;
40
+ *)
41
+ if [ -z "${GH_ENTERPRISE_TOKEN:-${GITHUB_ENTERPRISE_TOKEN:-}}" ]; then
42
+ enterprise_token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
43
+ [ -z "$enterprise_token" ] || export GH_ENTERPRISE_TOKEN="$enterprise_token"
44
+ fi
45
+ ;;
46
+ esac
47
+
48
+ validate_artifact_name() {
49
+ case "$1" in
50
+ ''|.|..|*/*|*\\*)
51
+ echo '[optional-run-artifacts] invalid artifact name' >&2
52
+ return 2
53
+ ;;
54
+ esac
55
+ }
56
+
57
+ download_exact() {
58
+ artifact="$1"
59
+ directory="$2"
60
+ validate_artifact_name "$artifact" || return $?
61
+ artifacts=$(list_artifact_names) || return $?
62
+ found=0
63
+ while IFS= read -r candidate; do
64
+ if [ "$candidate" = "$artifact" ]; then
65
+ found=1
66
+ break
67
+ fi
68
+ done < <(printf '%s\n' "$artifacts")
69
+ [ "$found" -eq 1 ] || return 3
70
+ echo "[optional-run-artifacts] attempt selector=$selector_type" >&2
71
+ GH_HOST="$github_host" gh run download "$GITHUB_RUN_ID" --repo "$repository" --name "$artifact" --dir "$directory"
72
+ }
73
+
74
+ list_artifact_names() {
75
+ GH_HOST="$github_host" gh api \
76
+ --paginate \
77
+ "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" \
78
+ --jq '.artifacts[] | select(.expired | not) | .name'
79
+ }
80
+
81
+ download_pattern() {
82
+ artifacts=$(list_artifact_names | awk '!seen[$0]++') || return $?
83
+ selected_artifacts=()
84
+ while IFS= read -r artifact; do
85
+ [ -n "$artifact" ] || continue
86
+ # shellcheck disable=SC2254
87
+ # selector is the caller's intended glob pattern.
88
+ case "$artifact" in
89
+ $selector) ;;
90
+ *) continue ;;
91
+ esac
92
+ validate_artifact_name "$artifact" || return $?
93
+ selected_artifacts+=("$artifact")
94
+ echo "[optional-run-artifacts] selected artifact=$artifact" >&2
95
+ done < <(printf '%s\n' "$artifacts")
96
+ count=${#selected_artifacts[@]}
97
+ echo "[optional-run-artifacts] selection selector=pattern count=$count" >&2
98
+ [ "$count" -gt 0 ] || return 3
99
+ for artifact in "${selected_artifacts[@]}"; do
100
+ echo "[optional-run-artifacts] attempt artifact=$artifact" >&2
101
+ GH_HOST="$github_host" gh run download "$GITHUB_RUN_ID" --repo "$repository" --name "$artifact" --dir "$destination/$artifact" || return $?
102
+ done
103
+ }
104
+
105
+ if [ "$selector_type" = name ]; then
106
+ if download_exact "$selector" "$destination"; then status=0; else status=$?; fi
107
+ else
108
+ if download_pattern; then status=0; else status=$?; fi
109
+ fi
110
+ if [ "$status" -eq 0 ]; then
111
+ echo 'availability=available' >> "$GITHUB_OUTPUT"
112
+ echo "[optional-run-artifacts] result=available selector=$selector_type" >&2
113
+ else
114
+ case "$status" in
115
+ 130|143) exit "$status" ;;
116
+ 3)
117
+ echo 'availability=unavailable' >> "$GITHUB_OUTPUT"
118
+ echo "::warning::Optional same-run artifact unavailable; continuing with validated fallback (selector=$selector_type exit=$status)" >&2
119
+ echo "[optional-run-artifacts] result=unavailable selector=$selector_type exit=$status" >&2
120
+ ;;
121
+ *)
122
+ echo "[optional-run-artifacts] result=error selector=$selector_type exit=$status" >&2
123
+ exit "$status"
124
+ ;;
125
+ esac
126
+ fi
@@ -2,7 +2,7 @@
2
2
  set -euo pipefail
3
3
 
4
4
  usage() {
5
- echo "usage: install-github-release.sh --repo owner/name --version VERSION --asset TEMPLATE --bin NAME [--tag-prefix PREFIX] [--strip-components N] [--bin-dir DIR] [--version-flag FLAG] [--checksums-asset NAME] [--no-checksum]" >&2
5
+ echo "usage: install-github-release.sh --repo owner/name --version VERSION --asset TEMPLATE --bin NAME [--tag-prefix PREFIX] [--strip-components N] [--bin-dir DIR] [--version-flag FLAG] [--checksums-asset NAME] [--expected-sha256 SHA256] [--no-checksum]" >&2
6
6
  }
7
7
 
8
8
  REPO="${REPO:-}"
@@ -14,6 +14,7 @@ STRIP_COMPONENTS="${STRIP_COMPONENTS:-1}"
14
14
  BIN_DIR="${INSTALL_BIN_DIR:-}"
15
15
  VERSION_FLAG="${VERSION_FLAG:---version}"
16
16
  CHECKSUMS_ASSET="${CHECKSUMS_ASSET:-}"
17
+ EXPECTED_SHA256="${EXPECTED_SHA256:-}"
17
18
  NO_CHECKSUM="${NO_CHECKSUM:-0}"
18
19
 
19
20
  if [ -n "${RELEASE_OWNER:-}" ] && [ -n "${RELEASE_REPO:-}" ]; then
@@ -67,6 +68,11 @@ while [ "${#}" -gt 0 ]; do
67
68
  CHECKSUMS_ASSET="$2"
68
69
  shift 2
69
70
  ;;
71
+ --expected-sha256)
72
+ [ "${#}" -ge 2 ] || { usage; exit 2; }
73
+ EXPECTED_SHA256="$2"
74
+ shift 2
75
+ ;;
70
76
  --no-checksum)
71
77
  NO_CHECKSUM=1
72
78
  shift
@@ -97,6 +103,11 @@ if ! [[ "$STRIP_COMPONENTS" =~ ^[0-9]+$ ]]; then
97
103
  exit 2
98
104
  fi
99
105
 
106
+ if [ -n "$EXPECTED_SHA256" ] && ! [[ "$EXPECTED_SHA256" =~ ^[[:xdigit:]]{64}$ ]]; then
107
+ echo "install-github-release.sh: --expected-sha256 must be a 64-character hexadecimal SHA-256 digest" >&2
108
+ exit 2
109
+ fi
110
+
100
111
  BIN_DIR="${BIN_DIR:-${RUNNER_TEMP:-${HOME}/.local}/bin}"
101
112
  mkdir -p "$BIN_DIR"
102
113
  if [ -n "${GITHUB_PATH:-}" ]; then
@@ -150,10 +161,12 @@ extract_dir="${work}-extract"
150
161
  rm -rf "$extract_dir"
151
162
  mkdir -p "$extract_dir"
152
163
  curl -fsSL -o "$archive" "${base_url}/${asset_name}"
153
- if [ "$NO_CHECKSUM" != 1 ]; then
154
- if [ -n "$checksums_name" ]; then
164
+ if [ -n "$EXPECTED_SHA256" ] || [ "$NO_CHECKSUM" != 1 ]; then
165
+ if [ -n "$EXPECTED_SHA256" ]; then
166
+ expected="$EXPECTED_SHA256"
167
+ elif [ -n "$checksums_name" ]; then
155
168
  curl -fsSL -o "${archive}.checksums" "${base_url}/${checksums_name}"
156
- expected=$(grep -F "$asset_name" "${archive}.checksums" | awk '{print $1}')
169
+ expected=$(awk -v file="$asset_name" '$2 == file || $2 == "*" file { print $1 }' "${archive}.checksums")
157
170
  rm "${archive}.checksums"
158
171
  else
159
172
  curl -fsSL -o "${archive}.sha256" "${base_url}/${asset_name}.sha256"