actions-warden 0.1.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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * YAML workflow parser.
3
+ *
4
+ * Parses GitHub Actions workflow files into a structure annotated with line
5
+ * numbers for every job, step, and `uses:` reference. Line numbers refer to
6
+ * 1-indexed positions in the source.
7
+ */
8
+
9
+ import { readFile } from 'node:fs/promises';
10
+ import { parseDocument, isMap, isSeq, isPair, isScalar } from 'yaml';
11
+
12
+ /**
13
+ * @typedef {object} ActionRef
14
+ * @property {string} raw - e.g. `actions/checkout@v3` or `./local`
15
+ * @property {string|null} owner
16
+ * @property {string|null} repo
17
+ * @property {string|null} subpath - reusable-workflow sub-path
18
+ * @property {string|null} ref - tag, branch, or SHA after `@`
19
+ * @property {'external'|'reusable-workflow'|'local'|'docker'|'unknown'} kind
20
+ * @property {number} line
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} StepNode
25
+ * @property {string|null} name
26
+ * @property {string|null} id
27
+ * @property {ActionRef|null} uses
28
+ * @property {string|null} run
29
+ * @property {object|null} env
30
+ * @property {object|null} with_
31
+ * @property {number} line
32
+ */
33
+
34
+ /**
35
+ * @typedef {object} JobNode
36
+ * @property {string} name
37
+ * @property {object|null} permissions
38
+ * @property {string|null} runsOn
39
+ * @property {object|null} env
40
+ * @property {StepNode[]} steps
41
+ * @property {number} line
42
+ */
43
+
44
+ /**
45
+ * @typedef {object} WorkflowDoc
46
+ * @property {string} path
47
+ * @property {string} source
48
+ * @property {string|null} name
49
+ * @property {unknown} on
50
+ * @property {object|null} permissions
51
+ * @property {object|null} env
52
+ * @property {JobNode[]} jobs
53
+ * @property {object} raw - the parsed plain object (for rules to query)
54
+ */
55
+
56
+ /**
57
+ * @param {string} raw e.g. `actions/checkout@v3` or `./local`
58
+ * @param {number} line
59
+ * @returns {ActionRef}
60
+ */
61
+ export function parseActionRef(raw, line) {
62
+ /** @type {ActionRef} */
63
+ const base = {
64
+ raw,
65
+ owner: null,
66
+ repo: null,
67
+ subpath: null,
68
+ ref: null,
69
+ kind: 'unknown',
70
+ line,
71
+ };
72
+ if (typeof raw !== 'string' || raw.length === 0) return base;
73
+ if (raw.startsWith('./') || raw.startsWith('../')) {
74
+ return { ...base, kind: 'local' };
75
+ }
76
+ if (raw.startsWith('docker://')) {
77
+ return { ...base, kind: 'docker' };
78
+ }
79
+ const atIndex = raw.lastIndexOf('@');
80
+ if (atIndex <= 0) return base;
81
+ const left = raw.slice(0, atIndex);
82
+ const ref = raw.slice(atIndex + 1);
83
+ const parts = left.split('/');
84
+ if (parts.length < 2) return base;
85
+ const owner = parts[0];
86
+ const repo = parts[1];
87
+ const rest = parts.slice(2).join('/');
88
+ const kind = /\.(yml|yaml)$/.test(rest) ? 'reusable-workflow' : 'external';
89
+ return { ...base, owner, repo, subpath: rest || null, ref, kind };
90
+ }
91
+
92
+ /**
93
+ * @param {unknown} node
94
+ * @returns {number}
95
+ */
96
+ function lineOf(node) {
97
+ if (node && typeof node === 'object' && 'range' in node && Array.isArray(node.range)) {
98
+ // We won't use this path; line is computed externally from the document.
99
+ }
100
+ return 0;
101
+ }
102
+
103
+ /**
104
+ * Find the 1-based line of a Pair/Scalar node within the YAML document.
105
+ *
106
+ * @param {string} source
107
+ * @param {{range?: number[]}} node
108
+ * @returns {number}
109
+ */
110
+ function lineFromRange(source, node) {
111
+ if (!node || !node.range || node.range.length === 0) return 0;
112
+ const offset = node.range[0];
113
+ let line = 1;
114
+ for (let i = 0; i < offset && i < source.length; i += 1) {
115
+ if (source.charCodeAt(i) === 10) line += 1;
116
+ }
117
+ return line;
118
+ }
119
+
120
+ /**
121
+ * Read a Pair value into a JS scalar where possible.
122
+ *
123
+ * @param {unknown} node
124
+ */
125
+ function toJs(node) {
126
+ if (node == null) return null;
127
+ if (typeof node !== 'object') return node;
128
+ if ('toJSON' in node && typeof node.toJSON === 'function') return node.toJSON();
129
+ return node;
130
+ }
131
+
132
+ /**
133
+ * Locate a child Pair by key within a YAMLMap.
134
+ *
135
+ * @param {object} map - yaml YAMLMap node
136
+ * @param {string} key
137
+ * @returns {object|null}
138
+ */
139
+ function findPair(map, key) {
140
+ if (!map || !isMap(map)) return null;
141
+ for (const item of map.items) {
142
+ if (isPair(item) && isScalar(item.key) && item.key.value === key) {
143
+ return item;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * @param {string} source
151
+ * @param {object} doc - yaml Document
152
+ * @returns {JobNode[]}
153
+ */
154
+ function extractJobs(source, doc) {
155
+ /** @type {JobNode[]} */
156
+ const jobs = [];
157
+ const jobsPair = findPair(doc.contents, 'jobs');
158
+ if (!jobsPair || !isMap(jobsPair.value)) return jobs;
159
+
160
+ for (const jobPair of jobsPair.value.items) {
161
+ if (!isPair(jobPair) || !isScalar(jobPair.key)) continue;
162
+ const jobName = String(jobPair.key.value);
163
+ const jobNode = jobPair.value;
164
+ const jobLine = lineFromRange(source, jobPair.key);
165
+
166
+ /** @type {JobNode} */
167
+ const job = {
168
+ name: jobName,
169
+ permissions: null,
170
+ runsOn: null,
171
+ env: null,
172
+ steps: [],
173
+ line: jobLine,
174
+ };
175
+
176
+ if (isMap(jobNode)) {
177
+ const perms = findPair(jobNode, 'permissions');
178
+ if (perms) job.permissions = toJs(perms.value);
179
+ const runsOn = findPair(jobNode, 'runs-on');
180
+ if (runsOn) job.runsOn = toJs(runsOn.value);
181
+ const env = findPair(jobNode, 'env');
182
+ if (env) job.env = toJs(env.value);
183
+
184
+ const stepsPair = findPair(jobNode, 'steps');
185
+ if (stepsPair && isSeq(stepsPair.value)) {
186
+ for (const stepNode of stepsPair.value.items) {
187
+ if (!isMap(stepNode)) continue;
188
+ const usesPair = findPair(stepNode, 'uses');
189
+ const runPair = findPair(stepNode, 'run');
190
+ const namePair = findPair(stepNode, 'name');
191
+ const idPair = findPair(stepNode, 'id');
192
+ const envPair = findPair(stepNode, 'env');
193
+ const withPair = findPair(stepNode, 'with');
194
+
195
+ const stepLine = lineFromRange(source, stepNode);
196
+ /** @type {StepNode} */
197
+ const step = {
198
+ name: namePair && isScalar(namePair.value) ? String(namePair.value.value) : null,
199
+ id: idPair && isScalar(idPair.value) ? String(idPair.value.value) : null,
200
+ uses: null,
201
+ run: runPair && isScalar(runPair.value) ? String(runPair.value.value) : null,
202
+ env: envPair ? toJs(envPair.value) : null,
203
+ with_: withPair ? toJs(withPair.value) : null,
204
+ line: stepLine,
205
+ };
206
+ if (usesPair && isScalar(usesPair.value)) {
207
+ const usesLine = lineFromRange(source, usesPair.value);
208
+ step.uses = parseActionRef(String(usesPair.value.value), usesLine);
209
+ }
210
+ job.steps.push(step);
211
+ }
212
+ }
213
+ }
214
+ jobs.push(job);
215
+ }
216
+ return jobs;
217
+ }
218
+
219
+ /**
220
+ * Parse a workflow YAML source into a {@link WorkflowDoc}.
221
+ *
222
+ * @param {string} source
223
+ * @param {string} path
224
+ * @returns {WorkflowDoc}
225
+ */
226
+ export function parseWorkflowSource(source, path) {
227
+ const doc = parseDocument(source, { keepSourceTokens: true });
228
+ if (doc.errors && doc.errors.length > 0) {
229
+ const first = doc.errors[0];
230
+ throw new Error(`yaml parse error in ${path}: ${first.message}`);
231
+ }
232
+ /** @type {WorkflowDoc} */
233
+ const result = {
234
+ path,
235
+ source,
236
+ name: null,
237
+ on: null,
238
+ permissions: null,
239
+ env: null,
240
+ jobs: [],
241
+ raw: doc.toJS() ?? {},
242
+ };
243
+ if (!doc.contents || !isMap(doc.contents)) return result;
244
+ const namePair = findPair(doc.contents, 'name');
245
+ if (namePair && isScalar(namePair.value)) result.name = String(namePair.value.value);
246
+ const onPair = findPair(doc.contents, 'on');
247
+ if (onPair) result.on = toJs(onPair.value);
248
+ const permPair = findPair(doc.contents, 'permissions');
249
+ if (permPair) result.permissions = toJs(permPair.value);
250
+ const envPair = findPair(doc.contents, 'env');
251
+ if (envPair) result.env = toJs(envPair.value);
252
+ result.jobs = extractJobs(source, doc);
253
+ return result;
254
+ }
255
+
256
+ /**
257
+ * @param {string} path
258
+ * @returns {Promise<WorkflowDoc>}
259
+ */
260
+ export async function parseWorkflowFile(path) {
261
+ const source = await readFile(path, 'utf8');
262
+ return parseWorkflowSource(source, path);
263
+ }
264
+
265
+ /**
266
+ * Iterate every action reference in a workflow.
267
+ *
268
+ * @param {WorkflowDoc} workflow
269
+ * @returns {Array<{ref: ActionRef, jobName: string, stepIndex: number}>}
270
+ */
271
+ export function collectUses(workflow) {
272
+ const out = [];
273
+ for (const job of workflow.jobs) {
274
+ job.steps.forEach((step, i) => {
275
+ if (step.uses) out.push({ ref: step.uses, jobName: job.name, stepIndex: i });
276
+ });
277
+ }
278
+ return out;
279
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Workflow file discovery with safe path handling.
3
+ */
4
+
5
+ import { readdir, stat } from 'node:fs/promises';
6
+ import { resolve, join, relative } from 'node:path';
7
+ import picomatch from 'picomatch';
8
+
9
+ /**
10
+ * Default workflow directory globs.
11
+ */
12
+ export const DEFAULT_WORKFLOW_PATTERNS = [
13
+ '.github/workflows/*.yml',
14
+ '.github/workflows/*.yaml',
15
+ ];
16
+
17
+ /**
18
+ * Reject path-traversal and absolute-escape attempts.
19
+ *
20
+ * @param {string} p
21
+ * @param {string} cwd
22
+ */
23
+ function assertInside(p, cwd) {
24
+ const abs = resolve(cwd, p);
25
+ const rel = relative(cwd, abs);
26
+ if (rel.startsWith('..') || rel.includes('\0')) {
27
+ throw new Error(`path traversal rejected: ${p}`);
28
+ }
29
+ return abs;
30
+ }
31
+
32
+ /**
33
+ * Recursively list files under a directory.
34
+ *
35
+ * @param {string} dir
36
+ * @param {string[]} acc
37
+ * @returns {Promise<string[]>}
38
+ */
39
+ async function walk(dir, acc = []) {
40
+ let entries;
41
+ try {
42
+ entries = await readdir(dir, { withFileTypes: true });
43
+ } catch {
44
+ return acc;
45
+ }
46
+ for (const entry of entries) {
47
+ if (entry.name.startsWith('.git') && entry.name !== '.github') continue;
48
+ if (entry.name === 'node_modules') continue;
49
+ const full = join(dir, entry.name);
50
+ if (entry.isDirectory()) {
51
+ await walk(full, acc);
52
+ } else if (entry.isFile()) {
53
+ acc.push(full);
54
+ }
55
+ }
56
+ return acc;
57
+ }
58
+
59
+ /**
60
+ * Discover workflow files matching the given patterns (relative to cwd).
61
+ *
62
+ * @param {object} opts
63
+ * @param {string[]} [opts.patterns]
64
+ * @param {string} [opts.cwd]
65
+ * @returns {Promise<string[]>}
66
+ */
67
+ export async function discoverWorkflows({ patterns = DEFAULT_WORKFLOW_PATTERNS, cwd = process.cwd() } = {}) {
68
+ for (const p of patterns) assertInside(p, cwd);
69
+ const matchers = patterns.map(p => picomatch(p, { dot: true }));
70
+ const all = await walk(cwd);
71
+ const out = [];
72
+ for (const file of all) {
73
+ const rel = relative(cwd, file);
74
+ if (matchers.some(m => m(rel))) out.push(file);
75
+ }
76
+ return out.sort();
77
+ }
78
+
79
+ /**
80
+ * Resolve a single workflow path argument. If it's a directory or glob,
81
+ * expand it; if a file, validate it exists.
82
+ *
83
+ * @param {string} input
84
+ * @param {string} [cwd]
85
+ * @returns {Promise<string[]>}
86
+ */
87
+ export async function resolveWorkflowArg(input, cwd = process.cwd()) {
88
+ const abs = assertInside(input, cwd);
89
+ try {
90
+ const st = await stat(abs);
91
+ if (st.isDirectory()) {
92
+ const files = await walk(abs);
93
+ return files.filter(f => /\.ya?ml$/.test(f));
94
+ }
95
+ return [abs];
96
+ } catch {
97
+ return discoverWorkflows({ patterns: [input], cwd });
98
+ }
99
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Secret redaction utility.
3
+ *
4
+ * Replaces values that look like tokens, credentials, or high-entropy strings
5
+ * with `<redacted>`. Conservative by design: prefers false positives over leaks.
6
+ */
7
+
8
+ const TOKEN_PATTERNS = [
9
+ /ghp_[A-Za-z0-9]{30,}/g,
10
+ /ghs_[A-Za-z0-9]{30,}/g,
11
+ /gho_[A-Za-z0-9]{30,}/g,
12
+ /ghu_[A-Za-z0-9]{30,}/g,
13
+ /github_pat_[A-Za-z0-9_]{30,}/g,
14
+ /xox[abprs]-[A-Za-z0-9-]{10,}/g,
15
+ /AKIA[0-9A-Z]{16}/g,
16
+ /sk-[A-Za-z0-9]{20,}/g,
17
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
18
+ ];
19
+
20
+ const KV_TOKEN_KEYS = /\b(token|secret|password|api[_-]?key|auth[_-]?token|access[_-]?key|private[_-]?key)\s*[:=]\s*["']?([^"'\s,]+)/gi;
21
+
22
+ /**
23
+ * Redacts sensitive substrings from a value.
24
+ *
25
+ * @param {unknown} input
26
+ * @returns {string}
27
+ */
28
+ export function redact(input) {
29
+ if (input == null) return '';
30
+ let s = typeof input === 'string' ? input : String(input);
31
+ for (const re of TOKEN_PATTERNS) s = s.replace(re, '<redacted>');
32
+ s = s.replace(KV_TOKEN_KEYS, (_, key) => `${key}=<redacted>`);
33
+ return s;
34
+ }
35
+
36
+ /**
37
+ * Wraps console.error/log so any string-coerced argument is redacted first.
38
+ *
39
+ * @param {(...args: unknown[]) => void} fn
40
+ * @returns {(...args: unknown[]) => void}
41
+ */
42
+ export function safeLogger(fn) {
43
+ return (...args) => fn(...args.map(a => (typeof a === 'string' ? redact(a) : a)));
44
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * GitHub API version resolver.
3
+ *
4
+ * Resolves tags/branches to commit SHAs and looks up latest releases. Uses
5
+ * native fetch, with exponential backoff on rate-limit (HTTP 403 + ratelimit
6
+ * remaining 0) and 5xx responses. Caches successful responses to disk.
7
+ */
8
+
9
+ import semver from 'semver';
10
+ import { readCache, writeCache } from './cache.js';
11
+ import { redact } from './redact.js';
12
+
13
+ const API = 'https://api.github.com';
14
+
15
+ /**
16
+ * Resolve the API token. Precedence: explicit param > GITHUB_TOKEN > GH_TOKEN.
17
+ *
18
+ * @param {string|undefined} explicit
19
+ * @returns {string|undefined}
20
+ */
21
+ export function resolveToken(explicit) {
22
+ if (explicit && explicit.length > 0) return explicit;
23
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
24
+ if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
25
+ return undefined;
26
+ }
27
+
28
+ /**
29
+ * @param {object} opts
30
+ * @param {string} opts.url
31
+ * @param {string} [opts.token]
32
+ * @param {number} [opts.retries]
33
+ * @param {string} [opts.cwd]
34
+ * @param {boolean} [opts.useCache]
35
+ * @returns {Promise<{status: number, body: unknown}>}
36
+ */
37
+ export async function ghFetch({ url, token, retries = 3, cwd = process.cwd(), useCache = true }) {
38
+ if (useCache) {
39
+ const cached = await readCache({ key: url, cwd });
40
+ if (cached !== undefined) return { status: 200, body: cached };
41
+ }
42
+
43
+ const headers = {
44
+ accept: 'application/vnd.github+json',
45
+ 'user-agent': 'actions-warden',
46
+ 'x-github-api-version': '2022-11-28',
47
+ };
48
+ if (token) headers.authorization = `Bearer ${token}`;
49
+
50
+ let attempt = 0;
51
+ for (;;) {
52
+ let response;
53
+ try {
54
+ response = await fetch(url, { headers });
55
+ } catch (err) {
56
+ if (attempt >= retries) throw new Error(`github fetch failed: ${redact(String(err))}`);
57
+ await sleep(backoff(attempt));
58
+ attempt += 1;
59
+ continue;
60
+ }
61
+ const remaining = response.headers.get('x-ratelimit-remaining');
62
+ if ((response.status === 403 && remaining === '0') || response.status === 429) {
63
+ const reset = Number(response.headers.get('x-ratelimit-reset') ?? 0) * 1000;
64
+ const wait = Math.max(reset - Date.now(), backoff(attempt));
65
+ if (attempt >= retries) {
66
+ throw new Error('github rate limit exhausted');
67
+ }
68
+ await sleep(Math.min(wait, 30_000));
69
+ attempt += 1;
70
+ continue;
71
+ }
72
+ if (response.status >= 500 && attempt < retries) {
73
+ await sleep(backoff(attempt));
74
+ attempt += 1;
75
+ continue;
76
+ }
77
+ const text = await response.text();
78
+ /** @type {unknown} */
79
+ let body;
80
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
81
+ if (response.status === 200 && useCache) {
82
+ await writeCache({ key: url, value: body, cwd });
83
+ }
84
+ return { status: response.status, body };
85
+ }
86
+ }
87
+
88
+ function backoff(attempt) {
89
+ return Math.min(1000 * 2 ** attempt, 8000);
90
+ }
91
+ function sleep(ms) {
92
+ return new Promise(res => setTimeout(res, ms));
93
+ }
94
+
95
+ /**
96
+ * Resolve a ref (tag, branch, or commit-ish) to an immutable commit SHA.
97
+ *
98
+ * @param {object} opts
99
+ * @param {string} opts.owner
100
+ * @param {string} opts.repo
101
+ * @param {string} opts.ref
102
+ * @param {string} [opts.token]
103
+ * @param {string} [opts.cwd]
104
+ * @returns {Promise<{sha: string, type: 'tag'|'branch'|'commit'}>}
105
+ */
106
+ export async function resolveRefToSha({ owner, repo, ref, token, cwd }) {
107
+ // Already a full SHA?
108
+ if (/^[0-9a-f]{40}$/i.test(ref)) {
109
+ return { sha: ref.toLowerCase(), type: 'commit' };
110
+ }
111
+ // Try as tag.
112
+ const tagUrl = `${API}/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(ref)}`;
113
+ const tagRes = await ghFetch({ url: tagUrl, token, cwd });
114
+ if (tagRes.status === 200 && tagRes.body && typeof tagRes.body === 'object') {
115
+ const obj = tagRes.body.object;
116
+ if (obj && obj.sha) {
117
+ if (obj.type === 'tag') {
118
+ // Annotated tag - dereference to commit.
119
+ const tagObjUrl = `${API}/repos/${owner}/${repo}/git/tags/${obj.sha}`;
120
+ const tagObj = await ghFetch({ url: tagObjUrl, token, cwd });
121
+ if (tagObj.status === 200 && tagObj.body && tagObj.body.object) {
122
+ return { sha: tagObj.body.object.sha, type: 'tag' };
123
+ }
124
+ }
125
+ return { sha: obj.sha, type: 'tag' };
126
+ }
127
+ }
128
+ // Try as branch.
129
+ const branchUrl = `${API}/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(ref)}`;
130
+ const branchRes = await ghFetch({ url: branchUrl, token, cwd });
131
+ if (branchRes.status === 200 && branchRes.body?.object?.sha) {
132
+ return { sha: branchRes.body.object.sha, type: 'branch' };
133
+ }
134
+ // Try as commit.
135
+ const commitUrl = `${API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`;
136
+ const commitRes = await ghFetch({ url: commitUrl, token, cwd });
137
+ if (commitRes.status === 200 && commitRes.body?.sha) {
138
+ return { sha: commitRes.body.sha, type: 'commit' };
139
+ }
140
+ throw new Error(`could not resolve ${owner}/${repo}@${ref}`);
141
+ }
142
+
143
+ /**
144
+ * List all tags for a repo (paginated, up to 200).
145
+ *
146
+ * @param {object} opts
147
+ * @param {string} opts.owner
148
+ * @param {string} opts.repo
149
+ * @param {string} [opts.token]
150
+ * @param {string} [opts.cwd]
151
+ * @returns {Promise<Array<{name: string, sha: string}>>}
152
+ */
153
+ export async function listTags({ owner, repo, token, cwd }) {
154
+ /** @type {Array<{name: string, sha: string}>} */
155
+ const out = [];
156
+ for (let page = 1; page <= 2; page += 1) {
157
+ const url = `${API}/repos/${owner}/${repo}/tags?per_page=100&page=${page}`;
158
+ const res = await ghFetch({ url, token, cwd });
159
+ if (res.status !== 200 || !Array.isArray(res.body)) break;
160
+ for (const t of res.body) {
161
+ if (t && t.name && t.commit?.sha) out.push({ name: t.name, sha: t.commit.sha });
162
+ }
163
+ if (res.body.length < 100) break;
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * Fetch the committer date of a commit (ms epoch).
170
+ *
171
+ * @param {object} opts
172
+ * @param {string} opts.owner
173
+ * @param {string} opts.repo
174
+ * @param {string} opts.sha
175
+ * @param {string} [opts.token]
176
+ * @param {string} [opts.cwd]
177
+ * @returns {Promise<number>}
178
+ */
179
+ export async function getCommitDate({ owner, repo, sha, token, cwd }) {
180
+ const url = `${API}/repos/${owner}/${repo}/commits/${encodeURIComponent(sha)}`;
181
+ const res = await ghFetch({ url, token, cwd });
182
+ if (res.status !== 200 || !res.body) {
183
+ throw new Error(`could not fetch commit date for ${owner}/${repo}@${sha}`);
184
+ }
185
+ const dateStr = res.body.commit?.committer?.date ?? res.body.commit?.author?.date;
186
+ if (!dateStr) throw new Error('commit response missing date');
187
+ const ms = Date.parse(dateStr);
188
+ if (!Number.isFinite(ms)) throw new Error(`invalid commit date: ${dateStr}`);
189
+ return ms;
190
+ }
191
+
192
+ /**
193
+ * Pick the highest semver tag matching the given policy.
194
+ *
195
+ * @param {object} opts
196
+ * @param {Array<{name: string}>} opts.tags
197
+ * @param {string|null} opts.currentRef - current ref ("v3", "v3.1.0", branch)
198
+ * @param {'major'|'minor'|'patch'} [opts.mode]
199
+ * @returns {{name: string}|null}
200
+ */
201
+ export function pickLatestTag({ tags, currentRef, mode = 'major' }) {
202
+ const semverTags = tags
203
+ .map(t => ({ tag: t, parsed: semver.coerce(t.name) }))
204
+ .filter(x => x.parsed)
205
+ .map(x => ({ tag: x.tag, version: x.parsed.version }))
206
+ .sort((a, b) => semver.rcompare(a.version, b.version));
207
+ if (semverTags.length === 0) return null;
208
+
209
+ const current = currentRef ? semver.coerce(currentRef) : null;
210
+ if (!current || mode === 'major') return semverTags[0].tag;
211
+ for (const candidate of semverTags) {
212
+ if (mode === 'minor' && semver.major(candidate.version) === current.major) {
213
+ return candidate.tag;
214
+ }
215
+ if (
216
+ mode === 'patch' &&
217
+ semver.major(candidate.version) === current.major &&
218
+ semver.minor(candidate.version) === current.minor
219
+ ) {
220
+ return candidate.tag;
221
+ }
222
+ }
223
+ return null;
224
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Safe file writer with dry-run guard.
3
+ *
4
+ * Every mutation flows through {@link writeFileGuarded}. When `dryRun` is true
5
+ * (the default), no bytes are written - the change is recorded and reported.
6
+ */
7
+
8
+ import { writeFile } from 'node:fs/promises';
9
+ import { resolve, relative } from 'node:path';
10
+
11
+ /**
12
+ * @typedef {object} WriteResult
13
+ * @property {string} path
14
+ * @property {boolean} written
15
+ * @property {boolean} dryRun
16
+ * @property {number} bytes
17
+ */
18
+
19
+ /**
20
+ * @param {object} args
21
+ * @param {string} args.path
22
+ * @param {string} args.content
23
+ * @param {boolean} [args.dryRun]
24
+ * @param {string} [args.cwd]
25
+ * @returns {Promise<WriteResult>}
26
+ */
27
+ export async function writeFileGuarded({ path, content, dryRun = true, cwd = process.cwd() }) {
28
+ const abs = resolve(cwd, path);
29
+ const rel = relative(cwd, abs);
30
+ if (rel.startsWith('..') || rel.includes('\0')) {
31
+ throw new Error(`refusing to write outside working directory: ${path}`);
32
+ }
33
+ const bytes = Buffer.byteLength(content, 'utf8');
34
+ if (dryRun) {
35
+ return { path: abs, written: false, dryRun: true, bytes };
36
+ }
37
+ await writeFile(abs, content, 'utf8');
38
+ return { path: abs, written: true, dryRun: false, bytes };
39
+ }
40
+
41
+ /**
42
+ * Throws on path traversal attempts.
43
+ *
44
+ * @param {string} path
45
+ * @param {string} [cwd]
46
+ * @returns {string} absolute path
47
+ */
48
+ export function assertSafePath(path, cwd = process.cwd()) {
49
+ if (typeof path !== 'string' || path.includes('\0')) {
50
+ throw new Error('invalid path');
51
+ }
52
+ if (path.includes('..')) {
53
+ throw new Error(`path traversal rejected: ${path}`);
54
+ }
55
+ return resolve(cwd, path);
56
+ }