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.
Files changed (62) hide show
  1. package/AGENTS.md +189 -0
  2. package/CONTRIBUTING.md +109 -0
  3. package/README.md +272 -224
  4. package/RELEASING.md +338 -0
  5. package/SECURITY.md +25 -3
  6. package/docs/AI-AGENTS.md +458 -0
  7. package/docs/CLI.md +421 -0
  8. package/docs/CONFIGURATION.md +340 -0
  9. package/docs/DEVELOPMENT.md +350 -0
  10. package/docs/GITHUB-ACTION.md +281 -0
  11. package/docs/JAVASCRIPT-API.md +355 -0
  12. package/docs/OUTPUTS.md +409 -0
  13. package/docs/README.md +27 -0
  14. package/examples/org-scan.yml +42 -0
  15. package/examples/upgrade-pr.yml +57 -0
  16. package/llms.txt +38 -0
  17. package/package.json +32 -10
  18. package/skills/actions-warden/SKILL.md +140 -38
  19. package/src/action.js +317 -0
  20. package/src/cli.js +267 -22
  21. package/src/commands/audit.js +189 -36
  22. package/src/commands/org-scan.js +549 -0
  23. package/src/commands/pin.js +59 -56
  24. package/src/commands/report.js +122 -10
  25. package/src/commands/upgrade.js +102 -62
  26. package/src/commands/verify.js +193 -0
  27. package/src/index.js +21 -4
  28. package/src/lib/action-status.js +27 -0
  29. package/src/lib/agent-mode.js +175 -0
  30. package/src/lib/annotations.js +250 -0
  31. package/src/lib/baseline.js +103 -0
  32. package/src/lib/cache.js +47 -10
  33. package/src/lib/concurrency.js +27 -0
  34. package/src/lib/config.js +185 -0
  35. package/src/lib/execution.js +71 -0
  36. package/src/lib/formatter.js +127 -8
  37. package/src/lib/github-org.js +374 -0
  38. package/src/lib/identity.js +62 -0
  39. package/src/lib/ignore.js +7 -6
  40. package/src/lib/org-checkpoint.js +378 -0
  41. package/src/lib/org-progress.js +60 -0
  42. package/src/lib/parser.js +326 -52
  43. package/src/lib/patcher.js +199 -0
  44. package/src/lib/paths.js +35 -12
  45. package/src/lib/redact.js +65 -4
  46. package/src/lib/resolver.js +225 -43
  47. package/src/lib/targets.js +28 -0
  48. package/src/lib/triggers.js +12 -0
  49. package/src/lib/writer.js +45 -8
  50. package/src/rules/excessive-permissions.js +24 -33
  51. package/src/rules/index.js +19 -1
  52. package/src/rules/pull-request-target-checkout.js +149 -18
  53. package/src/rules/reusable-workflow-secrets.js +32 -0
  54. package/src/rules/script-injection.js +77 -12
  55. package/src/rules/secrets-in-env.js +101 -18
  56. package/src/rules/unpinned-action.js +3 -2
  57. package/src/rules/unpinned-container-image.js +39 -0
  58. package/src/rules/unpinned-docker-action.js +30 -0
  59. package/src/rules/untrusted-self-hosted-runner.js +109 -0
  60. package/src/rules/workflow-run-artifact-execution.js +122 -0
  61. package/src/rules/workflow-structure.js +396 -0
  62. package/src/version.js +3 -0
@@ -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 an inline comment so upgrades can find
7
- * the human-readable version later.
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 { discoverWorkflows, resolveWorkflowArg } from '../lib/paths.js';
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
- planned.push({ ref, sha: resolved.sha, type: resolved.type });
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
- errors.push({ file, action: ref.raw, error: String(err.message ?? err) });
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
- let newSource = source;
86
- for (const { ref, sha, type } of planned) {
110
+ const patches = [];
111
+ const fileChanges = [];
112
+ for (const { id, ref, sha, type } of planned) {
87
113
  const change = {
88
- id: changeId(file, ref.raw),
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
- if (fix && fix !== change.id) continue;
97
- newSource = rewriteUses(newSource, ref, sha);
98
- changes.push(change);
122
+ patches.push(...planUsesPatches(source, ref, sha, ref.ref));
123
+ fileChanges.push(change);
99
124
  }
100
- if (newSource !== source) {
101
- await writeFileGuarded({ path: file, content: newSource, dryRun, cwd });
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
- const left = ref.subpath ? `${ref.owner}/${ref.repo}/${ref.subpath}` : `${ref.owner}/${ref.repo}`;
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
- if (p && p.startsWith(cwd)) return p.slice(cwd.length + 1);
198
- return p;
201
+ return p ? canonicalPath(p, cwd) : p;
199
202
  }
@@ -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({ cwd, workflows, explain: true });
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
- pinResult = await pin({ cwd, workflows, dryRun: true, token });
32
- upgradeResult = await upgrade({ cwd, workflows, dryRun: true, token, mode, minAgeDays });
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 { audit: auditResult, pin: pinResult, upgrade: upgradeResult, status };
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
- audit: { files: result.audit.files.map(f => rel(f, cwd)), findings: result.audit.findings, summary: result.audit.summary },
50
- pin: { changes: result.pin.changes.map(c => ({ ...c, file: rel(c.file, cwd) })) },
51
- upgrade: { changes: result.upgrade.changes.map(c => ({ ...c, file: rel(c.file, cwd) })), mode: opts.mode },
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
- if (p && p.startsWith(cwd)) return p.slice(cwd.length + 1);
92
- return p;
204
+ return p ? canonicalPath(p, cwd) : p;
93
205
  }
@@ -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`). If absent, the action is reported as `unknown`
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 { discoverWorkflows, resolveWorkflowArg } from '../lib/paths.js';
15
- import { listTags, pickLatestTag, resolveRefToSha, resolveToken, getCommitDate } from '../lib/resolver.js';
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 usesLine = lines[ref.line - 1] ?? '';
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
- let newSource = source;
133
- for (const { ref, latest, sha, level, currentVersion } of planned) {
156
+ const patches = [];
157
+ const fileChanges = [];
158
+ for (const { id, ref, latest, sha, level, currentVersion } of planned) {
134
159
  const change = {
135
- id: changeId(file, ref.raw, latest.name),
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
- if (fix && fix !== change.id) continue;
146
- newSource = rewriteUses(newSource, ref, sha);
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
- if (newSource !== source) {
151
- await writeFileGuarded({ path: file, content: newSource, dryRun, cwd });
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({ tags, currentRef, mode, cooldownMs, owner, repo, token, cwd, skipped, file, ref }) {
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 dateMs;
216
+ let evidence;
171
217
  try {
172
- dateMs = await getCommitDate({ owner, repo, sha: candidate.sha, token, cwd });
173
- } catch {
174
- return candidate;
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({ label: 'SKIP', fields: { file: rel(s.file ?? '', cwd), action: s.action, tag: s.tag, reason: s.reason, age_days: s.ageDays } });
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
- if (p && p.startsWith(cwd)) return p.slice(cwd.length + 1);
280
- return p;
320
+ return p ? canonicalPath(p, cwd) : p;
281
321
  }