actions-warden 0.2.0 → 0.3.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.
- package/AGENTS.md +189 -0
- package/CONTRIBUTING.md +109 -0
- package/README.md +272 -224
- package/RELEASING.md +338 -0
- package/SECURITY.md +25 -3
- package/docs/AI-AGENTS.md +458 -0
- package/docs/CLI.md +421 -0
- package/docs/CONFIGURATION.md +340 -0
- package/docs/DEVELOPMENT.md +350 -0
- package/docs/GITHUB-ACTION.md +281 -0
- package/docs/JAVASCRIPT-API.md +355 -0
- package/docs/OUTPUTS.md +409 -0
- package/docs/README.md +27 -0
- package/examples/org-scan.yml +42 -0
- package/examples/upgrade-pr.yml +57 -0
- package/llms.txt +38 -0
- package/package.json +32 -10
- package/skills/actions-warden/SKILL.md +140 -38
- package/src/action.js +317 -0
- package/src/cli.js +267 -22
- package/src/commands/audit.js +189 -36
- package/src/commands/org-scan.js +549 -0
- package/src/commands/pin.js +59 -56
- package/src/commands/report.js +122 -10
- package/src/commands/upgrade.js +102 -62
- package/src/commands/verify.js +193 -0
- package/src/index.js +21 -4
- package/src/lib/action-status.js +27 -0
- package/src/lib/agent-mode.js +175 -0
- package/src/lib/annotations.js +250 -0
- package/src/lib/baseline.js +103 -0
- package/src/lib/cache.js +47 -10
- package/src/lib/concurrency.js +27 -0
- package/src/lib/config.js +185 -0
- package/src/lib/execution.js +71 -0
- package/src/lib/formatter.js +127 -8
- package/src/lib/github-org.js +374 -0
- package/src/lib/identity.js +62 -0
- package/src/lib/ignore.js +7 -6
- package/src/lib/org-checkpoint.js +378 -0
- package/src/lib/org-progress.js +60 -0
- package/src/lib/parser.js +326 -52
- package/src/lib/patcher.js +199 -0
- package/src/lib/paths.js +35 -12
- package/src/lib/redact.js +65 -4
- package/src/lib/resolver.js +225 -43
- package/src/lib/targets.js +28 -0
- package/src/lib/triggers.js +12 -0
- package/src/lib/writer.js +45 -8
- package/src/rules/excessive-permissions.js +24 -33
- package/src/rules/index.js +19 -1
- package/src/rules/pull-request-target-checkout.js +149 -18
- package/src/rules/reusable-workflow-secrets.js +32 -0
- package/src/rules/script-injection.js +77 -12
- package/src/rules/secrets-in-env.js +101 -18
- package/src/rules/unpinned-action.js +3 -2
- package/src/rules/unpinned-container-image.js +39 -0
- package/src/rules/unpinned-docker-action.js +30 -0
- package/src/rules/untrusted-self-hosted-runner.js +109 -0
- package/src/rules/workflow-run-artifact-execution.js +122 -0
- package/src/rules/workflow-structure.js +396 -0
- package/src/version.js +3 -0
package/src/commands/pin.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pin command - rewrite tag-based `uses:` refs to immutable commit SHAs.
|
|
3
3
|
*
|
|
4
|
-
* Format: `uses: owner/repo@<sha> # <original-ref>`
|
|
4
|
+
* Format: `uses: owner/repo@<sha> # actions-warden-ref: <original-ref>`
|
|
5
5
|
*
|
|
6
|
-
* The original tag is preserved as
|
|
7
|
-
*
|
|
6
|
+
* The original tag is preserved as explicit metadata so upgrades can find the
|
|
7
|
+
* human-readable version later.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFile } from 'node:fs/promises';
|
|
11
|
-
import { createHash } from 'node:crypto';
|
|
12
11
|
import { parseWorkflowSource, collectUses } from '../lib/parser.js';
|
|
13
|
-
import {
|
|
14
|
-
import { resolveRefToSha, resolveToken } from '../lib/resolver.js';
|
|
12
|
+
import { resolveRefToSha, resolveToken, verifyCommitInRepo } from '../lib/resolver.js';
|
|
15
13
|
import { writeFileGuarded } from '../lib/writer.js';
|
|
16
14
|
import { parseIgnoreDirectives, isIgnored } from '../lib/ignore.js';
|
|
17
15
|
import { format } from '../lib/formatter.js';
|
|
16
|
+
import { pinOccurrenceId, canonicalPath } from '../lib/identity.js';
|
|
17
|
+
import { applyPatches, planUsesPatches } from '../lib/patcher.js';
|
|
18
|
+
import { resolveTargets } from '../lib/targets.js';
|
|
19
|
+
import { mapLimit } from '../lib/concurrency.js';
|
|
18
20
|
|
|
19
21
|
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
20
22
|
|
|
@@ -39,11 +41,12 @@ const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
|
39
41
|
* @returns {Promise<{changes: PinChange[], errors: object[], status: 'OK'|'FAIL'}>}
|
|
40
42
|
*/
|
|
41
43
|
export async function pin({ cwd = process.cwd(), workflows, dryRun = true, token, fix } = {}) {
|
|
42
|
-
const files = await resolveTargets(workflows, cwd);
|
|
44
|
+
const files = await resolveTargets({ workflows, cwd });
|
|
43
45
|
const tok = resolveToken(token);
|
|
44
46
|
/** @type {PinChange[]} */
|
|
45
47
|
const changes = [];
|
|
46
48
|
const errors = [];
|
|
49
|
+
let matchedFix = false;
|
|
47
50
|
|
|
48
51
|
for (const file of files) {
|
|
49
52
|
let source;
|
|
@@ -62,11 +65,18 @@ export async function pin({ cwd = process.cwd(), workflows, dryRun = true, token
|
|
|
62
65
|
}
|
|
63
66
|
/** @type {Array<{ref: import('../lib/parser.js').ActionRef, sha: string, type: string}>} */
|
|
64
67
|
const planned = [];
|
|
68
|
+
const candidates = [];
|
|
65
69
|
const ignore = parseIgnoreDirectives(source);
|
|
66
70
|
for (const { ref } of collectUses(doc)) {
|
|
67
71
|
if (ref.kind !== 'external' && ref.kind !== 'reusable-workflow') continue;
|
|
68
72
|
if (!ref.ref || SHA_RE.test(ref.ref)) continue;
|
|
69
73
|
if (isIgnored(ignore, ref.line, 'unpinned-action')) continue;
|
|
74
|
+
const id = pinOccurrenceId({ file, cwd, ref });
|
|
75
|
+
if (fix && fix !== id) continue;
|
|
76
|
+
if (fix === id) matchedFix = true;
|
|
77
|
+
candidates.push({ id, ref });
|
|
78
|
+
}
|
|
79
|
+
const resolutions = await mapLimit(candidates, 4, async ({ id, ref }) => {
|
|
70
80
|
try {
|
|
71
81
|
const resolved = await resolveRefToSha({
|
|
72
82
|
owner: ref.owner,
|
|
@@ -75,17 +85,33 @@ export async function pin({ cwd = process.cwd(), workflows, dryRun = true, token
|
|
|
75
85
|
token: tok,
|
|
76
86
|
cwd,
|
|
77
87
|
});
|
|
78
|
-
|
|
88
|
+
await verifyCommitInRepo({
|
|
89
|
+
owner: ref.owner,
|
|
90
|
+
repo: ref.repo,
|
|
91
|
+
sha: resolved.sha,
|
|
92
|
+
token: tok,
|
|
93
|
+
cwd,
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
planned: { id, ref, sha: resolved.sha, type: resolved.type },
|
|
97
|
+
};
|
|
79
98
|
} catch (err) {
|
|
80
|
-
|
|
99
|
+
return {
|
|
100
|
+
error: { file, action: ref.raw, error: String(err.message ?? err) },
|
|
101
|
+
};
|
|
81
102
|
}
|
|
103
|
+
});
|
|
104
|
+
for (const result of resolutions) {
|
|
105
|
+
if (result.planned) planned.push(result.planned);
|
|
106
|
+
if (result.error) errors.push(result.error);
|
|
82
107
|
}
|
|
83
108
|
if (planned.length === 0) continue;
|
|
84
109
|
|
|
85
|
-
|
|
86
|
-
|
|
110
|
+
const patches = [];
|
|
111
|
+
const fileChanges = [];
|
|
112
|
+
for (const { id, ref, sha, type } of planned) {
|
|
87
113
|
const change = {
|
|
88
|
-
id
|
|
114
|
+
id,
|
|
89
115
|
file,
|
|
90
116
|
action: `${ref.owner}/${ref.repo}${ref.subpath ? `/${ref.subpath}` : ''}`,
|
|
91
117
|
fromRef: ref.ref,
|
|
@@ -93,14 +119,23 @@ export async function pin({ cwd = process.cwd(), workflows, dryRun = true, token
|
|
|
93
119
|
line: ref.line,
|
|
94
120
|
refType: type,
|
|
95
121
|
};
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
changes.push(change);
|
|
122
|
+
patches.push(...planUsesPatches(source, ref, sha, ref.ref));
|
|
123
|
+
fileChanges.push(change);
|
|
99
124
|
}
|
|
100
|
-
|
|
101
|
-
|
|
125
|
+
try {
|
|
126
|
+
const newSource = applyPatches(source, patches);
|
|
127
|
+
if (newSource !== source) {
|
|
128
|
+
parseWorkflowSource(newSource, file);
|
|
129
|
+
await writeFileGuarded({ path: file, content: newSource, dryRun, cwd });
|
|
130
|
+
}
|
|
131
|
+
changes.push(...fileChanges);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
errors.push({ file, error: String(err.message ?? err) });
|
|
102
134
|
}
|
|
103
135
|
}
|
|
136
|
+
if (fix && !matchedFix) {
|
|
137
|
+
errors.push({ error: `fix id not found: ${fix}` });
|
|
138
|
+
}
|
|
104
139
|
return { changes, errors, status: errors.length === 0 ? 'OK' : 'FAIL' };
|
|
105
140
|
}
|
|
106
141
|
|
|
@@ -115,42 +150,7 @@ export async function pin({ cwd = process.cwd(), workflows, dryRun = true, token
|
|
|
115
150
|
* @returns {string}
|
|
116
151
|
*/
|
|
117
152
|
export function rewriteUses(source, ref, sha) {
|
|
118
|
-
|
|
119
|
-
const escLeft = escapeRegExp(left);
|
|
120
|
-
const escRef = escapeRegExp(ref.ref);
|
|
121
|
-
// Match: optional quote, owner/repo[/sub]@ref, optional quote, optional comment.
|
|
122
|
-
const re = new RegExp(
|
|
123
|
-
`(uses\\s*:\\s*['"]?)${escLeft}@${escRef}(['"]?)([^\\n]*)`,
|
|
124
|
-
'g',
|
|
125
|
-
);
|
|
126
|
-
return source.replace(re, (_, prefix, closingQuote, trailing) => {
|
|
127
|
-
const tail = stripExistingVersionComment(trailing);
|
|
128
|
-
return `${prefix}${left}@${sha}${closingQuote}${tail} # ${ref.ref}`;
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function stripExistingVersionComment(trailing) {
|
|
133
|
-
// Remove any existing inline comment so we don't stack `# v3 # v3`.
|
|
134
|
-
const idx = trailing.indexOf('#');
|
|
135
|
-
if (idx === -1) return trailing;
|
|
136
|
-
return trailing.slice(0, idx).replace(/\s+$/, '');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function escapeRegExp(s) {
|
|
140
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function changeId(file, raw) {
|
|
144
|
-
return createHash('sha1').update(`pin:${file}:${raw}`).digest('hex').slice(0, 10);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function resolveTargets(workflows, cwd) {
|
|
148
|
-
if (!workflows || workflows.length === 0) return discoverWorkflows({ cwd });
|
|
149
|
-
const out = new Set();
|
|
150
|
-
for (const w of workflows) {
|
|
151
|
-
for (const f of await resolveWorkflowArg(w, cwd)) out.add(f);
|
|
152
|
-
}
|
|
153
|
-
return [...out].sort();
|
|
153
|
+
return applyPatches(source, planUsesPatches(source, ref, sha, ref.ref));
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
|
@@ -163,9 +163,13 @@ export function renderPin(result, opts) {
|
|
|
163
163
|
return format('json', [], {
|
|
164
164
|
status: result.status,
|
|
165
165
|
json: {
|
|
166
|
+
schemaVersion: '1.0',
|
|
166
167
|
dryRun: opts.dryRun,
|
|
167
168
|
changes: result.changes.map(c => ({ ...c, file: rel(c.file, cwd) })),
|
|
168
|
-
errors: result.errors
|
|
169
|
+
errors: result.errors.map(error => ({
|
|
170
|
+
...error,
|
|
171
|
+
file: error.file ? rel(error.file, cwd) : undefined,
|
|
172
|
+
})),
|
|
169
173
|
status: result.status,
|
|
170
174
|
},
|
|
171
175
|
});
|
|
@@ -194,6 +198,5 @@ export function renderPin(result, opts) {
|
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
function rel(p, cwd) {
|
|
197
|
-
|
|
198
|
-
return p;
|
|
201
|
+
return p ? canonicalPath(p, cwd) : p;
|
|
199
202
|
}
|
package/src/commands/report.js
CHANGED
|
@@ -7,6 +7,7 @@ import { audit } from './audit.js';
|
|
|
7
7
|
import { pin } from './pin.js';
|
|
8
8
|
import { upgrade } from './upgrade.js';
|
|
9
9
|
import { format } from '../lib/formatter.js';
|
|
10
|
+
import { canonicalPath } from '../lib/identity.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* @param {object} opts
|
|
@@ -14,26 +15,66 @@ import { format } from '../lib/formatter.js';
|
|
|
14
15
|
* @param {string[]} [opts.workflows]
|
|
15
16
|
* @param {string} [opts.token]
|
|
16
17
|
* @param {'major'|'minor'|'patch'} [opts.mode]
|
|
18
|
+
* @param {'low'|'medium'|'high'|'critical'} [opts.severity]
|
|
19
|
+
* @param {boolean} [opts.explain]
|
|
17
20
|
* @param {boolean} [opts.skipResolve] - when true, skip pin/upgrade (offline mode)
|
|
21
|
+
* @param {string|false} [opts.configPath]
|
|
22
|
+
* @param {string} [opts.baseline]
|
|
18
23
|
*/
|
|
19
24
|
export async function report({
|
|
20
25
|
cwd = process.cwd(),
|
|
21
26
|
workflows,
|
|
22
27
|
token,
|
|
23
28
|
mode = 'minor',
|
|
29
|
+
severity,
|
|
30
|
+
explain = true,
|
|
24
31
|
skipResolve = false,
|
|
25
32
|
minAgeDays = 7,
|
|
33
|
+
configPath,
|
|
34
|
+
baseline,
|
|
26
35
|
} = {}) {
|
|
27
|
-
const auditResult = await audit({
|
|
36
|
+
const auditResult = await audit({
|
|
37
|
+
cwd,
|
|
38
|
+
workflows,
|
|
39
|
+
severity,
|
|
40
|
+
explain,
|
|
41
|
+
configPath,
|
|
42
|
+
baseline,
|
|
43
|
+
});
|
|
28
44
|
let pinResult = { changes: [], errors: [], status: 'OK' };
|
|
29
45
|
let upgradeResult = { changes: [], errors: [], skipped: [], status: 'OK' };
|
|
30
|
-
if (!skipResolve) {
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
if (!skipResolve && auditResult.files.length > 0) {
|
|
47
|
+
// Audit owns target discovery and config path filtering. Reuse its exact
|
|
48
|
+
// file set so every report phase operates on the same scope.
|
|
49
|
+
const scopedWorkflows = auditResult.files;
|
|
50
|
+
pinResult = await pin({ cwd, workflows: scopedWorkflows, dryRun: true, token });
|
|
51
|
+
upgradeResult = await upgrade({
|
|
52
|
+
cwd,
|
|
53
|
+
workflows: scopedWorkflows,
|
|
54
|
+
dryRun: true,
|
|
55
|
+
token,
|
|
56
|
+
mode,
|
|
57
|
+
minAgeDays,
|
|
58
|
+
});
|
|
59
|
+
const upgradedOccurrences = new Set(
|
|
60
|
+
upgradeResult.changes.map(change => `${change.file}:${change.line}`),
|
|
61
|
+
);
|
|
62
|
+
pinResult = {
|
|
63
|
+
...pinResult,
|
|
64
|
+
changes: pinResult.changes.filter(
|
|
65
|
+
change => !upgradedOccurrences.has(`${change.file}:${change.line}`),
|
|
66
|
+
),
|
|
67
|
+
};
|
|
33
68
|
}
|
|
34
69
|
const status = [auditResult.status, pinResult.status, upgradeResult.status].includes('FAIL')
|
|
35
70
|
? 'FAIL' : 'OK';
|
|
36
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
audit: auditResult,
|
|
73
|
+
pin: pinResult,
|
|
74
|
+
upgrade: upgradeResult,
|
|
75
|
+
offline: skipResolve,
|
|
76
|
+
status,
|
|
77
|
+
};
|
|
37
78
|
}
|
|
38
79
|
|
|
39
80
|
/**
|
|
@@ -46,9 +87,39 @@ export function renderReport(result, opts) {
|
|
|
46
87
|
return format('json', [], {
|
|
47
88
|
status: result.status,
|
|
48
89
|
json: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
90
|
+
schemaVersion: '1.0',
|
|
91
|
+
audit: {
|
|
92
|
+
files: result.audit.files.map(f => rel(f, cwd)),
|
|
93
|
+
findings: result.audit.findings.map(f => ({ ...f, file: rel(f.file, cwd) })),
|
|
94
|
+
summary: result.audit.summary,
|
|
95
|
+
baseline: {
|
|
96
|
+
...result.audit.baseline,
|
|
97
|
+
path: result.audit.baseline.path ? rel(result.audit.baseline.path, cwd) : null,
|
|
98
|
+
},
|
|
99
|
+
status: result.audit.status,
|
|
100
|
+
},
|
|
101
|
+
pin: {
|
|
102
|
+
changes: result.pin.changes.map(c => ({ ...c, file: rel(c.file, cwd) })),
|
|
103
|
+
errors: result.pin.errors.map(error => ({
|
|
104
|
+
...error,
|
|
105
|
+
file: error.file ? rel(error.file, cwd) : undefined,
|
|
106
|
+
})),
|
|
107
|
+
status: result.pin.status,
|
|
108
|
+
},
|
|
109
|
+
upgrade: {
|
|
110
|
+
changes: result.upgrade.changes.map(c => ({ ...c, file: rel(c.file, cwd) })),
|
|
111
|
+
skipped: result.upgrade.skipped.map(skip => ({
|
|
112
|
+
...skip,
|
|
113
|
+
file: skip.file ? rel(skip.file, cwd) : undefined,
|
|
114
|
+
})),
|
|
115
|
+
errors: result.upgrade.errors.map(error => ({
|
|
116
|
+
...error,
|
|
117
|
+
file: error.file ? rel(error.file, cwd) : undefined,
|
|
118
|
+
})),
|
|
119
|
+
mode: opts.mode,
|
|
120
|
+
status: result.upgrade.status,
|
|
121
|
+
},
|
|
122
|
+
offline: result.offline,
|
|
52
123
|
status: result.status,
|
|
53
124
|
},
|
|
54
125
|
});
|
|
@@ -62,6 +133,9 @@ export function renderReport(result, opts) {
|
|
|
62
133
|
...finding.fields,
|
|
63
134
|
line: finding.line,
|
|
64
135
|
explain: finding.explain,
|
|
136
|
+
...(opts.format === 'sarif' && finding.fingerprint
|
|
137
|
+
? { fingerprint: finding.fingerprint }
|
|
138
|
+
: {}),
|
|
65
139
|
},
|
|
66
140
|
});
|
|
67
141
|
}
|
|
@@ -71,6 +145,42 @@ export function renderReport(result, opts) {
|
|
|
71
145
|
for (const c of result.upgrade.changes) {
|
|
72
146
|
records.push({ label: 'UPGRADE', fields: { id: c.id, file: rel(c.file, cwd), action: c.action, from: c.fromVersion ?? c.fromRef, to: c.toTag, level: c.level } });
|
|
73
147
|
}
|
|
148
|
+
for (const skipped of result.upgrade.skipped ?? []) {
|
|
149
|
+
records.push({
|
|
150
|
+
label: 'SKIP',
|
|
151
|
+
fields: {
|
|
152
|
+
stage: 'upgrade',
|
|
153
|
+
file: rel(skipped.file ?? '', cwd),
|
|
154
|
+
action: skipped.action,
|
|
155
|
+
tag: skipped.tag,
|
|
156
|
+
reason: skipped.reason,
|
|
157
|
+
age_days: skipped.ageDays,
|
|
158
|
+
age_source: skipped.ageSource,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
for (const error of result.pin.errors) {
|
|
163
|
+
records.push({
|
|
164
|
+
label: 'ERROR',
|
|
165
|
+
fields: {
|
|
166
|
+
stage: 'pin',
|
|
167
|
+
file: rel(error.file ?? '', cwd),
|
|
168
|
+
action: error.action,
|
|
169
|
+
msg: error.error,
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
for (const error of result.upgrade.errors) {
|
|
174
|
+
records.push({
|
|
175
|
+
label: 'ERROR',
|
|
176
|
+
fields: {
|
|
177
|
+
stage: 'upgrade',
|
|
178
|
+
file: rel(error.file ?? '', cwd),
|
|
179
|
+
action: error.action,
|
|
180
|
+
msg: error.error,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
74
184
|
records.push({
|
|
75
185
|
label: 'SUMMARY',
|
|
76
186
|
fields: {
|
|
@@ -82,12 +192,14 @@ export function renderReport(result, opts) {
|
|
|
82
192
|
low: result.audit.summary.low,
|
|
83
193
|
pins: result.pin.changes.length,
|
|
84
194
|
upgrades: result.upgrade.changes.length,
|
|
195
|
+
skipped: (result.upgrade.skipped ?? []).length,
|
|
196
|
+
errors: result.pin.errors.length + result.upgrade.errors.length,
|
|
197
|
+
offline: result.offline,
|
|
85
198
|
},
|
|
86
199
|
});
|
|
87
200
|
return format(opts.format, records, { status: result.status });
|
|
88
201
|
}
|
|
89
202
|
|
|
90
203
|
function rel(p, cwd) {
|
|
91
|
-
|
|
92
|
-
return p;
|
|
204
|
+
return p ? canonicalPath(p, cwd) : p;
|
|
93
205
|
}
|
package/src/commands/upgrade.js
CHANGED
|
@@ -3,23 +3,30 @@
|
|
|
3
3
|
* permitted by the chosen policy.
|
|
4
4
|
*
|
|
5
5
|
* For SHA-pinned refs, the human-readable version is read from the inline
|
|
6
|
-
* comment (e.g. `# v3.1.0`).
|
|
6
|
+
* comment (e.g. `# actions-warden-ref: v3.1.0`). Legacy plain semver comments
|
|
7
|
+
* remain readable. If metadata is absent, the action is reported as `unknown`
|
|
7
8
|
* and skipped.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { readFile } from 'node:fs/promises';
|
|
11
|
-
import { createHash } from 'node:crypto';
|
|
12
12
|
import semver from 'semver';
|
|
13
13
|
import { parseWorkflowSource, collectUses } from '../lib/parser.js';
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {
|
|
15
|
+
getTagAgeEvidence,
|
|
16
|
+
listTags,
|
|
17
|
+
pickLatestTag,
|
|
18
|
+
resolveRefToSha,
|
|
19
|
+
resolveToken,
|
|
20
|
+
verifyCommitInRepo,
|
|
21
|
+
} from '../lib/resolver.js';
|
|
16
22
|
import { writeFileGuarded } from '../lib/writer.js';
|
|
17
23
|
import { parseIgnoreDirectives, isIgnored } from '../lib/ignore.js';
|
|
18
|
-
import { rewriteUses } from './pin.js';
|
|
19
24
|
import { format } from '../lib/formatter.js';
|
|
25
|
+
import { occurrenceId, canonicalPath } from '../lib/identity.js';
|
|
26
|
+
import { applyPatches, planUsesPatches, readVersionComment } from '../lib/patcher.js';
|
|
27
|
+
import { resolveTargets } from '../lib/targets.js';
|
|
20
28
|
|
|
21
29
|
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
22
|
-
const INLINE_COMMENT_RE = /uses\s*:[^\n#]*#\s*([^\s]+)/;
|
|
23
30
|
|
|
24
31
|
/**
|
|
25
32
|
* @typedef {object} UpgradeChange
|
|
@@ -54,7 +61,7 @@ export async function upgrade({
|
|
|
54
61
|
fix,
|
|
55
62
|
minAgeDays = 7,
|
|
56
63
|
} = {}) {
|
|
57
|
-
const files = await resolveTargets(workflows, cwd);
|
|
64
|
+
const files = await resolveTargets({ workflows, cwd });
|
|
58
65
|
const tok = resolveToken(token);
|
|
59
66
|
/** @type {UpgradeChange[]} */
|
|
60
67
|
const changes = [];
|
|
@@ -62,6 +69,7 @@ export async function upgrade({
|
|
|
62
69
|
/** @type {object[]} */
|
|
63
70
|
const skipped = [];
|
|
64
71
|
const cooldownMs = Math.max(minAgeDays, 0) * 86_400_000;
|
|
72
|
+
let matchedFix = false;
|
|
65
73
|
|
|
66
74
|
for (const file of files) {
|
|
67
75
|
let source;
|
|
@@ -71,7 +79,6 @@ export async function upgrade({
|
|
|
71
79
|
errors.push({ file, error: String(err.message ?? err) });
|
|
72
80
|
continue;
|
|
73
81
|
}
|
|
74
|
-
const lines = source.split('\n');
|
|
75
82
|
let doc;
|
|
76
83
|
try {
|
|
77
84
|
doc = parseWorkflowSource(source, file);
|
|
@@ -85,10 +92,19 @@ export async function upgrade({
|
|
|
85
92
|
for (const { ref } of collectUses(doc)) {
|
|
86
93
|
if (ref.kind !== 'external' && ref.kind !== 'reusable-workflow') continue;
|
|
87
94
|
if (isIgnored(ignore, ref.line, 'unpinned-action')) continue;
|
|
88
|
-
const
|
|
89
|
-
const inlineVersion = readInlineVersion(usesLine);
|
|
95
|
+
const inlineVersion = readVersionComment(source, ref);
|
|
90
96
|
const currentVersion = ref.ref && SHA_RE.test(ref.ref) ? inlineVersion : ref.ref;
|
|
91
97
|
if (!currentVersion) continue;
|
|
98
|
+
const id = occurrenceId({
|
|
99
|
+
kind: 'upgrade',
|
|
100
|
+
file,
|
|
101
|
+
cwd,
|
|
102
|
+
line: ref.line,
|
|
103
|
+
start: ref.start,
|
|
104
|
+
subject: ref.raw,
|
|
105
|
+
});
|
|
106
|
+
if (fix && fix !== id) continue;
|
|
107
|
+
if (fix === id) matchedFix = true;
|
|
92
108
|
|
|
93
109
|
let tags;
|
|
94
110
|
try {
|
|
@@ -107,6 +123,7 @@ export async function upgrade({
|
|
|
107
123
|
token: tok,
|
|
108
124
|
cwd,
|
|
109
125
|
skipped,
|
|
126
|
+
errors,
|
|
110
127
|
file,
|
|
111
128
|
ref,
|
|
112
129
|
});
|
|
@@ -122,17 +139,25 @@ export async function upgrade({
|
|
|
122
139
|
token: tok,
|
|
123
140
|
cwd,
|
|
124
141
|
});
|
|
142
|
+
await verifyCommitInRepo({
|
|
143
|
+
owner: ref.owner,
|
|
144
|
+
repo: ref.repo,
|
|
145
|
+
sha: resolved.sha,
|
|
146
|
+
token: tok,
|
|
147
|
+
cwd,
|
|
148
|
+
});
|
|
125
149
|
} catch (err) {
|
|
126
150
|
errors.push({ file, action: ref.raw, error: String(err.message ?? err) });
|
|
127
151
|
continue;
|
|
128
152
|
}
|
|
129
|
-
planned.push({ ref, latest, sha: resolved.sha, level, currentVersion });
|
|
153
|
+
planned.push({ id, ref, latest, sha: resolved.sha, level, currentVersion });
|
|
130
154
|
}
|
|
131
155
|
|
|
132
|
-
|
|
133
|
-
|
|
156
|
+
const patches = [];
|
|
157
|
+
const fileChanges = [];
|
|
158
|
+
for (const { id, ref, latest, sha, level, currentVersion } of planned) {
|
|
134
159
|
const change = {
|
|
135
|
-
id
|
|
160
|
+
id,
|
|
136
161
|
file,
|
|
137
162
|
action: `${ref.owner}/${ref.repo}`,
|
|
138
163
|
fromRef: ref.ref,
|
|
@@ -142,15 +167,23 @@ export async function upgrade({
|
|
|
142
167
|
level,
|
|
143
168
|
line: ref.line,
|
|
144
169
|
};
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
newSource = fixInlineComment(newSource, ref, latest.name, sha);
|
|
148
|
-
changes.push(change);
|
|
170
|
+
patches.push(...planUsesPatches(source, ref, sha, latest.name));
|
|
171
|
+
fileChanges.push(change);
|
|
149
172
|
}
|
|
150
|
-
|
|
151
|
-
|
|
173
|
+
try {
|
|
174
|
+
const newSource = applyPatches(source, patches);
|
|
175
|
+
if (newSource !== source) {
|
|
176
|
+
parseWorkflowSource(newSource, file);
|
|
177
|
+
await writeFileGuarded({ path: file, content: newSource, dryRun, cwd });
|
|
178
|
+
}
|
|
179
|
+
changes.push(...fileChanges);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
errors.push({ file, error: String(err.message ?? err) });
|
|
152
182
|
}
|
|
153
183
|
}
|
|
184
|
+
if (fix && !matchedFix) {
|
|
185
|
+
errors.push({ error: `fix id not found: ${fix}` });
|
|
186
|
+
}
|
|
154
187
|
return { changes, errors, skipped, status: errors.length === 0 ? 'OK' : 'FAIL' };
|
|
155
188
|
}
|
|
156
189
|
|
|
@@ -158,7 +191,20 @@ export async function upgrade({
|
|
|
158
191
|
* Walk candidate tags newest-first and return the first whose commit is older
|
|
159
192
|
* than the cooldown threshold. Skipped candidates are recorded.
|
|
160
193
|
*/
|
|
161
|
-
async function pickAgedTag({
|
|
194
|
+
async function pickAgedTag({
|
|
195
|
+
tags,
|
|
196
|
+
currentRef,
|
|
197
|
+
mode,
|
|
198
|
+
cooldownMs,
|
|
199
|
+
owner,
|
|
200
|
+
repo,
|
|
201
|
+
token,
|
|
202
|
+
cwd,
|
|
203
|
+
skipped,
|
|
204
|
+
errors,
|
|
205
|
+
file,
|
|
206
|
+
ref,
|
|
207
|
+
}) {
|
|
162
208
|
if (cooldownMs <= 0) {
|
|
163
209
|
return pickLatestTag({ tags, currentRef, mode });
|
|
164
210
|
}
|
|
@@ -167,12 +213,25 @@ async function pickAgedTag({ tags, currentRef, mode, cooldownMs, owner, repo, to
|
|
|
167
213
|
for (;;) {
|
|
168
214
|
const candidate = pickLatestTag({ tags: remaining, currentRef, mode });
|
|
169
215
|
if (!candidate) return null;
|
|
170
|
-
let
|
|
216
|
+
let evidence;
|
|
171
217
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
218
|
+
evidence = await getTagAgeEvidence({
|
|
219
|
+
owner,
|
|
220
|
+
repo,
|
|
221
|
+
tag: candidate.name,
|
|
222
|
+
sha: candidate.sha,
|
|
223
|
+
token,
|
|
224
|
+
cwd,
|
|
225
|
+
});
|
|
226
|
+
} catch (err) {
|
|
227
|
+
errors.push({
|
|
228
|
+
file,
|
|
229
|
+
action: ref.raw,
|
|
230
|
+
error: String(err.message ?? err),
|
|
231
|
+
});
|
|
232
|
+
return null;
|
|
175
233
|
}
|
|
234
|
+
const { dateMs, source: ageSource } = evidence;
|
|
176
235
|
if (dateMs <= cutoff) return candidate;
|
|
177
236
|
skipped.push({
|
|
178
237
|
file,
|
|
@@ -180,6 +239,7 @@ async function pickAgedTag({ tags, currentRef, mode, cooldownMs, owner, repo, to
|
|
|
180
239
|
tag: candidate.name,
|
|
181
240
|
reason: 'cooldown',
|
|
182
241
|
ageDays: Math.round((Date.now() - dateMs) / 86_400_000),
|
|
242
|
+
ageSource,
|
|
183
243
|
});
|
|
184
244
|
const idx = remaining.findIndex(t => t.name === candidate.name);
|
|
185
245
|
if (idx === -1) return null;
|
|
@@ -187,11 +247,6 @@ async function pickAgedTag({ tags, currentRef, mode, cooldownMs, owner, repo, to
|
|
|
187
247
|
}
|
|
188
248
|
}
|
|
189
249
|
|
|
190
|
-
function readInlineVersion(line) {
|
|
191
|
-
const m = INLINE_COMMENT_RE.exec(line);
|
|
192
|
-
return m ? m[1] : null;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
250
|
function bumpLevel(from, to) {
|
|
196
251
|
const a = semver.coerce(from);
|
|
197
252
|
const b = semver.coerce(to);
|
|
@@ -201,34 +256,6 @@ function bumpLevel(from, to) {
|
|
|
201
256
|
return 'patch';
|
|
202
257
|
}
|
|
203
258
|
|
|
204
|
-
function fixInlineComment(source, ref, newTag, sha) {
|
|
205
|
-
const left = ref.subpath ? `${ref.owner}/${ref.repo}/${ref.subpath}` : `${ref.owner}/${ref.repo}`;
|
|
206
|
-
const escLeft = escRe(left);
|
|
207
|
-
const escSha = escRe(sha);
|
|
208
|
-
const re = new RegExp(
|
|
209
|
-
`(uses\\s*:\\s*['"]?)${escLeft}@${escSha}(['"]?)\\s*#\\s*[^\\n]+`,
|
|
210
|
-
'g',
|
|
211
|
-
);
|
|
212
|
-
return source.replace(re, (_, prefix, closingQuote) => `${prefix}${left}@${sha}${closingQuote} # ${newTag}`);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function escRe(s) {
|
|
216
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function changeId(file, raw, target) {
|
|
220
|
-
return createHash('sha1').update(`up:${file}:${raw}->${target}`).digest('hex').slice(0, 10);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async function resolveTargets(workflows, cwd) {
|
|
224
|
-
if (!workflows || workflows.length === 0) return discoverWorkflows({ cwd });
|
|
225
|
-
const out = new Set();
|
|
226
|
-
for (const w of workflows) {
|
|
227
|
-
for (const f of await resolveWorkflowArg(w, cwd)) out.add(f);
|
|
228
|
-
}
|
|
229
|
-
return [...out].sort();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
259
|
/**
|
|
233
260
|
* @param {Awaited<ReturnType<typeof upgrade>>} result
|
|
234
261
|
* @param {{format: 'toon'|'json'|'text', dryRun: boolean, mode: string, cwd?: string}} opts
|
|
@@ -239,11 +266,15 @@ export function renderUpgrade(result, opts) {
|
|
|
239
266
|
return format('json', [], {
|
|
240
267
|
status: result.status,
|
|
241
268
|
json: {
|
|
269
|
+
schemaVersion: '1.0',
|
|
242
270
|
dryRun: opts.dryRun,
|
|
243
271
|
mode: opts.mode,
|
|
244
272
|
changes: result.changes.map(c => ({ ...c, file: rel(c.file, cwd) })),
|
|
245
273
|
skipped: (result.skipped ?? []).map(s => ({ ...s, file: rel(s.file ?? '', cwd) })),
|
|
246
|
-
errors: result.errors
|
|
274
|
+
errors: result.errors.map(error => ({
|
|
275
|
+
...error,
|
|
276
|
+
file: error.file ? rel(error.file, cwd) : undefined,
|
|
277
|
+
})),
|
|
247
278
|
status: result.status,
|
|
248
279
|
},
|
|
249
280
|
});
|
|
@@ -266,7 +297,17 @@ export function renderUpgrade(result, opts) {
|
|
|
266
297
|
});
|
|
267
298
|
}
|
|
268
299
|
for (const s of result.skipped ?? []) {
|
|
269
|
-
records.push({
|
|
300
|
+
records.push({
|
|
301
|
+
label: 'SKIP',
|
|
302
|
+
fields: {
|
|
303
|
+
file: rel(s.file ?? '', cwd),
|
|
304
|
+
action: s.action,
|
|
305
|
+
tag: s.tag,
|
|
306
|
+
reason: s.reason,
|
|
307
|
+
age_days: s.ageDays,
|
|
308
|
+
age_source: s.ageSource,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
270
311
|
}
|
|
271
312
|
for (const e of result.errors) {
|
|
272
313
|
records.push({ label: 'ERROR', fields: { file: rel(e.file ?? '', cwd), action: e.action ?? '', msg: e.error } });
|
|
@@ -276,6 +317,5 @@ export function renderUpgrade(result, opts) {
|
|
|
276
317
|
}
|
|
277
318
|
|
|
278
319
|
function rel(p, cwd) {
|
|
279
|
-
|
|
280
|
-
return p;
|
|
320
|
+
return p ? canonicalPath(p, cwd) : p;
|
|
281
321
|
}
|