actions-up 1.4.2 → 1.6.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/dist/cli/index.js CHANGED
@@ -11,11 +11,11 @@ import pc from "picocolors";
11
11
  import cac from "cac";
12
12
  function run() {
13
13
  let l = cac("actions-up");
14
- l.help().version(version).option("--dry-run", "Preview changes without applying them").option("--exclude <regex>", "Exclude actions by regex (repeatable)").option("--yes, -y", "Skip all confirmations").command("", "Update GitHub Actions").action(async (s) => {
14
+ l.help().version(version).option("--dir <directory>", "Custom directory name (default: .github)").option("--dry-run", "Preview changes without applying them").option("--exclude <regex>", "Exclude actions by regex (repeatable)").option("--min-age <days>", "Minimum age in days for updates (default: 0)", { default: 0 }).option("--yes, -y", "Skip all confirmations").command("", "Update GitHub Actions").action(async (s) => {
15
15
  console.info(pc.cyan("\nšŸš€ Actions Up!\n"));
16
16
  let c = createSpinner("Scanning GitHub Actions...").start();
17
17
  try {
18
- let l = await scanGitHubActions(process.cwd()), u = l.actions.length, d = l.workflows.size, f = l.compositeActions.size;
18
+ let l = await scanGitHubActions(process.cwd(), s.dir), u = l.actions.length, d = l.workflows.size, f = l.compositeActions.size;
19
19
  if (c.success(`Found ${pc.yellow(u)} actions in ${pc.yellow(d)} workflows and ${pc.yellow(f)} composite actions`), u === 0) {
20
20
  console.info(pc.green("\n✨ No GitHub Actions found in this repository"));
21
21
  return;
@@ -39,12 +39,14 @@ function run() {
39
39
  await Promise.all(g.map(async (e) => {
40
40
  await shouldIgnore(e.action.file, e.action.line) || _.push(e);
41
41
  }));
42
- let v = _.filter((e) => e.hasUpdate), y = v.filter((e) => e.isBreaking);
42
+ let v = _.filter((e) => e.hasUpdate), y = s.minAge * 24 * 60 * 60 * 1e3, b = Date.now();
43
+ v = v.filter((e) => e.publishedAt ? b - e.publishedAt.getTime() >= y : !0);
44
+ let x = v.filter((e) => e.isBreaking);
43
45
  if (v.length === 0) {
44
46
  c.success("All actions are up to date!"), console.info(pc.green("\n✨ Everything is already at the latest version!\n"));
45
47
  return;
46
48
  }
47
- if (c.success(`Found ${pc.yellow(v.length)} updates available${y.length > 0 ? ` (${pc.redBright(y.length)} breaking)` : ""}`), s.dryRun) {
49
+ if (c.success(`Found ${pc.yellow(v.length)} updates available${x.length > 0 ? ` (${pc.redBright(x.length)} breaking)` : ""}`), s.dryRun) {
48
50
  console.info(pc.yellow("\nšŸ“‹ Dry Run - No changes will be made\n"));
49
51
  for (let e of v) console.info(`${pc.cyan(e.action.file ?? "unknown")}:\n${e.action.name}: ${pc.redBright(e.currentVersion)} → ${pc.green(e.latestVersion)} ${e.latestSha ? pc.gray(`(${e.latestSha.slice(0, 7)})`) : ""}\n`);
50
52
  console.info(pc.gray(`\n${v.length} actions would be updated\n`));
@@ -58,7 +60,7 @@ function run() {
58
60
  }
59
61
  console.info(pc.yellow(`\nšŸ”„ Updating ${e.length} actions...\n`)), await applyUpdates(e), console.info(pc.green("\nāœ“ Updates applied successfully!"));
60
62
  } else {
61
- let o = await promptUpdateSelection(_);
63
+ let o = await promptUpdateSelection(_, { showAge: s.minAge > 0 });
62
64
  if (!o || o.length === 0) {
63
65
  console.info(pc.gray("\nNo updates applied"));
64
66
  return;
@@ -1,187 +1,206 @@
1
1
  import { createGitHubClient } from "./create-github-client.js";
2
2
  import semver from "semver";
3
- async function checkUpdates(r, a) {
4
- let c = createGitHubClient(a), l = r.filter((e) => e.type === "external");
3
+ async function checkUpdates(n, i) {
4
+ let c = createGitHubClient(i), l = n.filter((e) => e.type === "external" || e.type === "reusable-workflow");
5
5
  if (l.length === 0) return [];
6
6
  let u = /* @__PURE__ */ new Map();
7
7
  for (let e of l) {
8
- let n = u.get(e.name) ?? [];
9
- n.push(e), u.set(e.name, n);
8
+ let t = u.get(e.name) ?? [];
9
+ t.push(e), u.set(e.name, t);
10
10
  }
11
11
  let d = {
12
12
  rateLimitError: null,
13
13
  rateLimitHit: !1
14
- }, f = await [...u.keys()].reduce((e, r) => e.then(async (e) => {
14
+ }, f = await [...u.keys()].reduce((e, n) => e.then(async (e) => {
15
15
  if (d.rateLimitHit) return [...e, {
16
+ publishedAt: null,
16
17
  version: null,
17
- actionName: r,
18
+ actionName: n,
18
19
  sha: null
19
20
  }];
20
- let i = r.split("/");
21
- if (i.length < 2) return [...e, {
21
+ let r = n.split("/");
22
+ if (r.length < 2) return [...e, {
23
+ publishedAt: null,
22
24
  version: null,
23
- actionName: r,
25
+ actionName: n,
24
26
  sha: null
25
27
  }];
26
- let [a, l] = i;
27
- if (!a || !l) return [...e, {
28
+ let [i, l] = r;
29
+ if (!i || !l) return [...e, {
30
+ publishedAt: null,
28
31
  version: null,
29
- actionName: r,
32
+ actionName: n,
30
33
  sha: null
31
34
  }];
32
35
  try {
33
- let i = u.get(r)[0]?.version;
34
- if (i && !isSha(i) && !isSemverLike(i) && await c.getRefType(a, l, i) === "branch") return [...e, {
36
+ let r = u.get(n)[0]?.version;
37
+ if (r && !isSha(r) && !isSemverLike(r) && await c.getRefType(i, l, r) === "branch") return [...e, {
38
+ publishedAt: null,
35
39
  version: null,
36
- actionName: r,
40
+ actionName: n,
37
41
  sha: null
38
42
  }];
39
- let d = await c.getLatestRelease(a, l);
43
+ let d = await c.getLatestRelease(i, l);
40
44
  if (!d) {
41
- let e = await c.getAllReleases(a, l, 1);
45
+ let e = await c.getAllReleases(i, l, 1);
42
46
  d = e.find((e) => !e.isPrerelease) ?? e[0] ?? null;
43
47
  }
44
48
  if (d) {
45
- let { version: i, sha: o } = d, u = !1;
49
+ let { publishedAt: r, version: o, sha: u } = d, f = !1;
46
50
  {
47
- let e = normalizeVersion(i), r = !!(i && i.trim() !== ""), a = r && /^v?\d+$/u.test(i.trim()), o = semver.valid(e);
48
- u = !r || a || !o;
51
+ let e = normalizeVersion(o), n = !!(o && o.trim() !== ""), r = n && /^v?\d+$/u.test(o.trim()), i = semver.valid(e);
52
+ f = !n || r || !i;
49
53
  }
50
- if (u) {
51
- let o = await c.getAllTags(a, l, 30);
52
- if (o.length > 0) {
53
- let u = o.filter((e) => isSemverLike(e.tag)).map((e) => ({
54
+ if (f) {
55
+ let r = await c.getAllTags(i, l, 30);
56
+ if (r.length > 0) {
57
+ let u = r.filter((e) => isSemverLike(e.tag)).map((e) => ({
54
58
  v: semver.valid(normalizeVersion(e.tag)),
55
59
  raw: e
56
60
  }));
57
61
  if (u.length > 0) {
58
- u.sort((e, r) => {
59
- let i = semver.rcompare(e.v, r.v);
60
- if (i !== 0) return i;
61
- let a = /\d+\.\d+/u.test(e.raw.tag) ? 1 : 0;
62
- return (/\d+\.\d+/u.test(r.raw.tag) ? 1 : 0) - a;
62
+ u.sort((e, n) => {
63
+ let r = semver.rcompare(e.v, n.v);
64
+ if (r !== 0) return r;
65
+ let i = /\d+\.\d+/u.test(e.raw.tag) ? 1 : 0;
66
+ return (/\d+\.\d+/u.test(n.raw.tag) ? 1 : 0) - i;
63
67
  });
64
- let o = u[0].raw, s = semver.valid(normalizeVersion(i) ?? void 0);
65
- if (!s || semver.gt(u[0].v, s) || semver.eq(u[0].v, s) && /\d+\.\d+/u.test(o.tag)) {
66
- let n = o.tag, i = o.sha?.length ? o.sha : null;
67
- if (!i && n) try {
68
- i = await c.getTagSha(a, l, n);
68
+ let r = u[0].raw, s = semver.valid(normalizeVersion(o) ?? void 0);
69
+ if (!s || semver.gt(u[0].v, s) || semver.eq(u[0].v, s) && /\d+\.\d+/u.test(r.tag)) {
70
+ let t = r.tag, a = r.sha?.length ? r.sha : null;
71
+ if (!a && t) try {
72
+ a = await c.getTagSha(i, l, t);
69
73
  } catch {}
70
74
  return [...e, {
71
- version: n,
72
- sha: i,
73
- actionName: r
75
+ version: t,
76
+ publishedAt: null,
77
+ sha: a,
78
+ actionName: n
74
79
  }];
75
80
  }
76
81
  }
77
82
  }
78
83
  }
79
- if (!o && i) try {
80
- o = await c.getTagSha(a, l, i);
84
+ if (!u && o) try {
85
+ u = await c.getTagSha(i, l, o);
81
86
  } catch {}
82
87
  return [...e, {
83
- actionName: r,
84
- version: i,
85
- sha: o
88
+ publishedAt: r,
89
+ actionName: n,
90
+ version: o,
91
+ sha: u
86
92
  }];
87
93
  }
88
- let f = await c.getAllTags(a, l, 30);
94
+ let f = await c.getAllTags(i, l, 30);
89
95
  if (f.length > 0) {
90
- let i = f.filter((e) => isSemverLike(e.tag)).map((e) => ({
96
+ let r = f.filter((e) => isSemverLike(e.tag)).map((e) => ({
91
97
  v: semver.valid(normalizeVersion(e.tag)),
92
98
  raw: e
93
99
  })), o;
94
- i.length > 0 ? (i.sort((e, r) => {
95
- let i = semver.rcompare(e.v, r.v);
96
- if (i !== 0) return i;
97
- let a = /\d+\.\d+/u.test(e.raw.tag) ? 1 : 0;
98
- return (/\d+\.\d+/u.test(r.raw.tag) ? 1 : 0) - a;
99
- }), o = i[0].raw) : o = f[0];
100
+ r.length > 0 ? (r.sort((e, n) => {
101
+ let r = semver.rcompare(e.v, n.v);
102
+ if (r !== 0) return r;
103
+ let i = /\d+\.\d+/u.test(e.raw.tag) ? 1 : 0;
104
+ return (/\d+\.\d+/u.test(n.raw.tag) ? 1 : 0) - i;
105
+ }), o = r[0].raw) : o = f[0];
100
106
  let u = o.tag, d = o.sha?.length ? o.sha : null;
101
107
  if (!d && u) try {
102
- d = await c.getTagSha(a, l, u);
108
+ d = await c.getTagSha(i, l, u);
103
109
  } catch {}
104
110
  return [...e, {
105
- actionName: r,
111
+ publishedAt: null,
112
+ actionName: n,
106
113
  version: u,
107
114
  sha: d
108
115
  }];
109
116
  }
110
117
  return [...e, {
118
+ publishedAt: null,
111
119
  version: null,
112
- actionName: r,
120
+ actionName: n,
113
121
  sha: null
114
122
  }];
115
- } catch (n) {
116
- return n instanceof Error && n.name === "GitHubRateLimitError" ? (d.rateLimitHit = !0, d.rateLimitError = n, [...e, {
123
+ } catch (t) {
124
+ return t instanceof Error && t.name === "GitHubRateLimitError" ? (d.rateLimitHit = !0, d.rateLimitError = t, [...e, {
125
+ publishedAt: null,
117
126
  version: null,
118
- actionName: r,
127
+ actionName: n,
119
128
  sha: null
120
- }]) : (console.warn(`Failed to check ${r}:`, n), [...e, {
129
+ }]) : (console.warn(`Failed to check ${n}:`, t), [...e, {
130
+ publishedAt: null,
121
131
  version: null,
122
- actionName: r,
132
+ actionName: n,
123
133
  sha: null
124
134
  }]);
125
135
  }
126
136
  }), Promise.resolve([]));
127
137
  if (d.rateLimitError) {
128
- let e = !!(a ?? process.env.GITHUB_TOKEN), n = `${d.rateLimitError.message || "GitHub API rate limit exceeded."}\n${e ? "Wait for reset or reduce request rate." : "Please set GITHUB_TOKEN environment variable to increase the limit.\nSee: https://github.com/azat-io/actions-up?tab=readme-ov-file#using-github-token-for-higher-rate-limits"}`, r = Error(n);
129
- throw r.name = "GitHubRateLimitError", r;
138
+ let e = !!(i ?? process.env.GITHUB_TOKEN), t = `${d.rateLimitError.message || "GitHub API rate limit exceeded."}\n${e ? "Wait for reset or reduce request rate." : "Please set GITHUB_TOKEN environment variable to increase the limit.\nSee: https://github.com/azat-io/actions-up?tab=readme-ov-file#using-github-token-for-higher-rate-limits"}`, n = Error(t);
139
+ throw n.name = "GitHubRateLimitError", n;
130
140
  }
131
141
  let p = /* @__PURE__ */ new Map();
132
142
  for (let e of f) p.set(e.actionName, {
143
+ publishedAt: e.publishedAt,
133
144
  version: e.version,
134
145
  sha: e.sha
135
146
  });
136
147
  let m = [];
137
148
  for (let e of l) {
138
- let n = p.get(e.name);
139
- n ? m.push(createUpdate(e, n.version, n.sha)) : m.push(createUpdate(e, null, null));
149
+ let t = p.get(e.name);
150
+ t ? m.push(createUpdate(e, {
151
+ publishedAt: t.publishedAt,
152
+ version: t.version,
153
+ sha: t.sha
154
+ })) : m.push(createUpdate(e, {
155
+ publishedAt: null,
156
+ version: null,
157
+ sha: null
158
+ }));
140
159
  }
141
160
  return m;
142
161
  }
143
- function createUpdate(e, r, i) {
144
- let s = normalizeVersion(e.version ?? ""), c = r ? normalizeVersion(r) : null, l = !1, u = !1;
145
- if (s && isSha(s)) i ? l = !compareSha(s, i) : c && (l = !0);
146
- else if (s && c) {
147
- let r = semver.valid(s), a = semver.valid(c);
148
- if (r && a) {
149
- if (l = semver.lt(r, a), l) {
150
- let e = semver.major(r);
151
- u = semver.major(a) > e;
162
+ function createUpdate(e, n) {
163
+ let { version: r, sha: s, publishedAt: c } = n, l = normalizeVersion(e.version ?? ""), u = r ? normalizeVersion(r) : null, d = !1, f = !1;
164
+ if (l && isSha(l)) s ? d = !compareSha(l, s) : u && (d = !0);
165
+ else if (l && u) {
166
+ let n = semver.valid(l), r = semver.valid(u);
167
+ if (n && r) {
168
+ if (d = semver.lt(n, r), d) {
169
+ let e = semver.major(n);
170
+ f = semver.major(r) > e;
152
171
  }
153
- !l && semver.eq(r, a) && !isSha(e.version) && i && (l = !0, u = !1);
154
- } else s !== c && (l = !0);
172
+ !d && semver.eq(n, r) && !isSha(e.version) && s && (d = !0, f = !1);
173
+ } else l !== u && (d = !0);
155
174
  }
156
175
  return {
157
176
  currentVersion: e.version ?? "unknown",
158
177
  latestVersion: r,
159
- isBreaking: u,
160
- latestSha: i,
161
- hasUpdate: l,
178
+ publishedAt: c,
179
+ isBreaking: f,
180
+ latestSha: s,
181
+ hasUpdate: d,
162
182
  action: e
163
183
  };
164
184
  }
165
- function compareSha(e, n) {
166
- if (!e || !n) return !1;
167
- let r = e.replace(/^v/u, ""), i = n.replace(/^v/u, ""), a = Math.min(r.length, i.length);
168
- return a < 7 ? !1 : r.slice(0, Math.max(0, a)).toLowerCase() === i.slice(0, Math.max(0, a)).toLowerCase();
185
+ function compareSha(e, t) {
186
+ let n = e.replace(/^v/u, ""), r = t.replace(/^v/u, ""), i = Math.min(n.length, r.length);
187
+ return i < 7 ? !1 : n.slice(0, Math.max(0, i)).toLowerCase() === r.slice(0, Math.max(0, i)).toLowerCase();
169
188
  }
170
189
  function normalizeVersion(e) {
171
190
  if (!e) return null;
172
- let r = e.replace(/^v/u, "");
173
- if (/^[0-9a-f]{7,40}$/iu.test(r)) return e;
174
- let i = semver.coerce(r);
175
- return i ? i.version : e;
191
+ let n = e.replace(/^v/u, "");
192
+ if (/^[0-9a-f]{7,40}$/iu.test(n)) return e;
193
+ let r = semver.coerce(n);
194
+ return r ? r.version : e;
176
195
  }
177
196
  function isSha(e) {
178
197
  if (!e) return !1;
179
- let n = e.replace(/^v/u, "");
180
- return /^[0-9a-f]{7,40}$/iu.test(n);
198
+ let t = e.replace(/^v/u, "");
199
+ return /^[0-9a-f]{7,40}$/iu.test(t);
181
200
  }
182
201
  function isSemverLike(e) {
183
202
  if (!e) return !1;
184
- let n = e.trim();
185
- return /^v?\d+(?:\.\d+){0,2}$/u.test(n);
203
+ let t = e.trim();
204
+ return /^v?\d+(?:\.\d+){0,2}$/u.test(t);
186
205
  }
187
206
  export { checkUpdates };
@@ -11,6 +11,10 @@ function scanCompositeActionAst(a, o, s) {
11
11
  let u = c.runs;
12
12
  if (!u || !isCompositeActionRuns(u) || !u.steps || !Array.isArray(u.steps)) return [];
13
13
  let d = findMapPair(l.value, "steps");
14
- return d?.value ? extractUsesFromSteps(d.value, s, o) : [];
14
+ return d?.value ? extractUsesFromSteps({
15
+ stepsNode: d.value,
16
+ filePath: s,
17
+ content: o
18
+ }) : [];
15
19
  }
16
20
  export { scanCompositeActionAst };
@@ -4,7 +4,8 @@ import { GitHubAction } from '../../../types/github-action';
4
4
  * Scans a parsed workflow YAML document for action references.
5
5
  *
6
6
  * Navigates AST structure `jobs -> <job> -> steps` and extracts `uses` entries
7
- * with corresponding line numbers.
7
+ * with corresponding line numbers. Also scans for job-level `uses` fields that
8
+ * indicate Reusable Workflows.
8
9
  *
9
10
  * @param document - Parsed YAML document of a workflow file.
10
11
  * @param content - Original file content.
@@ -1,19 +1,32 @@
1
1
  import { isWorkflowStructure } from "../../schema/workflow/is-workflow-structure.js";
2
+ import { parseActionReference } from "../../parsing/parse-action-reference.js";
3
+ import { getLineNumberForKey } from "../utils/get-line-number.js";
2
4
  import { isYAMLMap } from "../guards/is-yaml-map.js";
5
+ import { isScalar } from "../guards/is-scalar.js";
3
6
  import { isNode } from "../guards/is-node.js";
4
7
  import { isPair } from "../guards/is-pair.js";
5
8
  import { extractUsesFromSteps } from "../utils/extract-uses-from-steps.js";
6
9
  import { findMapPair } from "../utils/find-map-pair.js";
7
- function scanWorkflowAst(o, s, c) {
8
- if (!isWorkflowStructure(o.toJSON()) || !o.contents || !isYAMLMap(o.contents)) return [];
9
- let l = findMapPair(o.contents, "jobs");
10
- if (!l?.value || !isYAMLMap(l.value)) return [];
11
- let u = [];
12
- for (let e of l.value.items) {
10
+ function scanWorkflowAst(l, u, d) {
11
+ if (!isWorkflowStructure(l.toJSON()) || !l.contents || !isYAMLMap(l.contents)) return [];
12
+ let f = findMapPair(l.contents, "jobs");
13
+ if (!f?.value || !isYAMLMap(f.value)) return [];
14
+ let p = [];
15
+ for (let e of f.value.items) {
13
16
  if (!isPair(e) || !e.value || !isNode(e.value) || !isYAMLMap(e.value)) continue;
14
- let o = findMapPair(e.value, "steps");
15
- o?.value && u.push(...extractUsesFromSteps(o.value, c, s));
17
+ let l = isScalar(e.key) ? String(e.key.value) : void 0, f = findMapPair(e.value, "uses");
18
+ if (f?.value && f.key && isScalar(f.value)) {
19
+ let e = parseActionReference(String(f.value.value), d, getLineNumberForKey(u, f.key));
20
+ e && (l && (e.job = l), p.push(e));
21
+ }
22
+ let m = findMapPair(e.value, "steps");
23
+ m?.value && p.push(...extractUsesFromSteps({
24
+ stepsNode: m.value,
25
+ filePath: d,
26
+ content: u,
27
+ jobName: l
28
+ }));
16
29
  }
17
- return u;
30
+ return p;
18
31
  }
19
32
  export { scanWorkflowAst };
@@ -27,7 +27,7 @@ async function applyUpdates(n) {
27
27
  console.error(`Invalid SHA format: ${e.latestSha}`);
28
28
  continue;
29
29
  }
30
- let a = r ? String.raw`(?=[^\S\r\n]|$|#)` : "", o = RegExp(`(^\\s*-?\\s*uses:\\s*)(['"]?)(${n})@${r}\\2${a}([^\\S\\r\\n]*#[^\\r\\n]*)?`, "gm"), s = `$1$2$3@${e.latestSha}$2 # ${e.latestVersion}`;
30
+ let a = r ? String.raw`(?=[^\S\r\n]|$|#)` : "", o = new RegExp(String.raw`(^\s*-?\s*uses:\s*)(['"]?)(${n})@${r}\2${a}([^\S\r\n]*#[^\r\n]*)?`, "gm"), s = `$1$2$3@${e.latestSha}$2 # ${e.latestVersion}`;
31
31
  i = i.replace(o, s);
32
32
  }
33
33
  await writeFile(n, i, "utf8");
@@ -1,13 +1,22 @@
1
1
  import { GitHubAction } from '../../../types/github-action';
2
+ interface ExtractUsesOptions {
3
+ /** YAML sequence node containing workflow/action steps. */
4
+ stepsNode: unknown;
5
+ /** Path of the file being scanned (for metadata). */
6
+ filePath: string;
7
+ /** Name of the job containing these steps (for workflows). */
8
+ jobName?: string;
9
+ /** Original YAML file content (for line number calculation). */
10
+ content: string;
11
+ }
2
12
  /**
3
13
  * Extracts GitHub Action references from a steps YAML sequence.
4
14
  *
5
15
  * Uses the AST to locate the 'uses' key for precise line numbers and the JSON
6
16
  * representation to validate the presence and type of the 'uses' field.
7
17
  *
8
- * @param stepsNode - YAML sequence node containing workflow/action steps.
9
- * @param filePath - Path of the file being scanned (for metadata).
10
- * @param content - Original YAML file content (for line number calculation).
18
+ * @param options - Options for extraction.
11
19
  * @returns List of discovered GitHub actions.
12
20
  */
13
- export declare function extractUsesFromSteps(stepsNode: unknown, filePath: string, content: string): GitHubAction[];
21
+ export declare function extractUsesFromSteps(options: ExtractUsesOptions): GitHubAction[];
22
+ export {};
@@ -5,18 +5,19 @@ import { isYAMLMap } from "../guards/is-yaml-map.js";
5
5
  import { isScalar } from "../guards/is-scalar.js";
6
6
  import { isNode } from "../guards/is-node.js";
7
7
  import { isPair } from "../guards/is-pair.js";
8
- function extractUsesFromSteps(s, c, l) {
9
- if (!isYAMLSequence(s)) return [];
10
- let u = [];
11
- for (let o of s.items) {
8
+ function extractUsesFromSteps(s) {
9
+ let { stepsNode: c, filePath: l, content: u, jobName: d } = s;
10
+ if (!isYAMLSequence(c)) return [];
11
+ let f = [];
12
+ for (let o of c.items) {
12
13
  if (!isYAMLMap(o) || !isNode(o)) continue;
13
14
  let s = o.toJSON();
14
15
  if (typeof s != "object" || !s || Array.isArray(s)) continue;
15
- let d = s;
16
- if (typeof d.uses != "string") continue;
17
- let f = o.items.find((e) => isPair(e) && isScalar(e.key) && e.key.value === "uses"), p = f?.key ? getLineNumberForKey(l, f.key) : 0, m = parseActionReference(d.uses, c, p);
18
- m && u.push(m);
16
+ let c = s;
17
+ if (typeof c.uses != "string") continue;
18
+ let p = o.items.find((e) => isPair(e) && isScalar(e.key) && e.key.value === "uses"), m = p?.key ? getLineNumberForKey(u, p.key) : 0, h = parseActionReference(c.uses, l, m);
19
+ h && (d && (h.job = d), f.push(h));
19
20
  }
20
- return u;
21
+ return f;
21
22
  }
22
23
  export { extractUsesFromSteps };
@@ -1,2 +1,7 @@
1
1
  import { ActionUpdate } from '../../types/action-update';
2
- export declare function promptUpdateSelection(updates: ActionUpdate[]): Promise<ActionUpdate[] | null>;
2
+ interface PromptUpdateSelectionOptions {
3
+ /** Whether to show the Age column. */
4
+ showAge?: boolean;
5
+ }
6
+ export declare function promptUpdateSelection(updates: ActionUpdate[], options?: PromptUpdateSelectionOptions): Promise<ActionUpdate[] | null>;
7
+ export {};
@@ -7,99 +7,118 @@ import pc from "picocolors";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import enquirer from "enquirer";
9
9
  import path from "node:path";
10
- var MIN_ACTION_WIDTH = 56, MIN_CURRENT_WIDTH = 16;
11
- async function promptUpdateSelection(o) {
12
- if (o.length === 0) return null;
13
- let c = o.filter((e) => e.hasUpdate);
14
- if (c.length === 0) return console.info(pc.green("āœ“ All actions are up to date!")), null;
15
- let p = /* @__PURE__ */ new Map();
16
- for (let [e, a] of c.entries()) {
17
- let o = a.action.file ?? "unknown file", s = path.relative(path.join(process.cwd(), GITHUB_DIRECTORY), o);
18
- s === "" && (s = o);
19
- let c = p.get(s) ?? [];
20
- c.push({
21
- update: a,
10
+ var MIN_ACTION_WIDTH = 40, MIN_JOB_WIDTH = 4, MIN_CURRENT_WIDTH = 16, MAX_VERSION_WIDTH = 7;
11
+ async function promptUpdateSelection(l, g = {}) {
12
+ let { showAge: y = !1 } = g;
13
+ if (l.length === 0) return null;
14
+ let b = l.filter((e) => e.hasUpdate);
15
+ if (b.length === 0) return console.info(pc.green("āœ“ All actions are up to date!")), null;
16
+ let x = /* @__PURE__ */ new Map();
17
+ for (let [e, o] of b.entries()) {
18
+ let s = o.action.file ?? "unknown file", c = path.relative(path.join(process.cwd(), GITHUB_DIRECTORY), s);
19
+ c === "" && (c = s);
20
+ let l = x.get(c) ?? [];
21
+ l.push({
22
+ update: o,
22
23
  index: e
23
- }), p.set(s, c);
24
+ }), x.set(c, l);
24
25
  }
25
- let g = await Promise.all(c.map(async (e) => {
26
- let i = formatVersionOrSha(e.currentVersion), a = e.currentVersion ?? void 0;
26
+ let S = await Promise.all(b.map(async (e) => {
27
+ let a = formatVersionOrSha(e.currentVersion), o = e.currentVersion ?? void 0, s = null, c = null;
27
28
  if (!e.currentVersion || !isSha(e.currentVersion)) return {
28
- effectiveForDiff: a,
29
- display: i
29
+ versionForPadding: s,
30
+ effectiveForDiff: o,
31
+ shortSha: c,
32
+ display: a
30
33
  };
31
- let o = await tryReadInlineVersionComment(e.action.file, e.action.line);
32
- if (o) {
33
- let c = e.currentVersion.slice(0, 7);
34
- i = `${formatVersionOrSha(o)} ${pc.gray(`(${c})`)}`, a = o;
35
- }
36
- return {
37
- effectiveForDiff: a,
38
- display: i
34
+ let l = await tryReadInlineVersionComment(e.action.file, e.action.line);
35
+ return l && (c = e.currentVersion.slice(0, 7), s = formatVersionOrSha(l), a = s, o = l), {
36
+ versionForPadding: s,
37
+ effectiveForDiff: o,
38
+ shortSha: c,
39
+ display: a
39
40
  };
40
- })), _ = [], v = stripAnsi("Action").length, y = stripAnsi("Current").length;
41
- for (let [e, i] of c.entries()) {
42
- let o = i.action.name, s = g[e].display;
43
- v = Math.max(v, o.length), y = Math.max(y, stripAnsi(s).length);
41
+ })), C = [], w = stripAnsi("Action").length, T = stripAnsi("Current").length, E = stripAnsi("Job").length, D = 0, O = !1;
42
+ for (let [a, s] of b.entries()) {
43
+ let c = s.action.name, l = S[a].display, u = s.action.job ?? "–";
44
+ if (w = Math.max(w, c.length), T = Math.max(T, stripAnsi(l).length), E = Math.max(E, u.length), s.latestVersion) {
45
+ let c = formatVersion(s.latestVersion, S[a]?.effectiveForDiff ?? s.currentVersion);
46
+ D = Math.max(D, stripAnsi(c).length);
47
+ }
48
+ let d = S[a]?.versionForPadding;
49
+ d && (D = Math.max(D, stripAnsi(d).length)), s.publishedAt && (O = !0);
44
50
  }
45
- let b = Math.max(v, MIN_ACTION_WIDTH), x = Math.max(y, MIN_CURRENT_WIDTH), S = [...p.keys()].toSorted();
46
- for (let [i, a] of S.entries()) {
47
- let o = p.get(a);
48
- if (!o) {
49
- console.warn(`Unexpected missing group for file: ${a}`);
51
+ let k = Math.max(w, MIN_ACTION_WIDTH), A = Math.max(T, MIN_CURRENT_WIDTH), j = Math.max(E, MIN_JOB_WIDTH), M = Math.min(D, MAX_VERSION_WIDTH), N = M + 1 + 9, P = y && O ? 6 : 0, F = [...x.keys()].toSorted();
52
+ for (let [a, o] of F.entries()) {
53
+ let l = x.get(o);
54
+ if (!l) {
55
+ console.warn(`Unexpected missing group for file: ${o}`);
50
56
  continue;
51
57
  }
52
- let c = [], l = o;
53
- c.push({
58
+ let u = [], d = l;
59
+ u.push({
54
60
  current: "Current",
55
61
  action: "Action",
56
62
  target: "Target",
57
- arrow: "āÆ"
63
+ arrow: "āÆ",
64
+ job: "Job",
65
+ age: "Age"
58
66
  });
59
- for (let { update: i, index: a } of l) {
60
- let o = !!i.latestSha, l = g[a].display, u = g[a]?.effectiveForDiff ?? i.currentVersion, d = formatVersion(i.latestVersion, u), f = i.action.name;
61
- if (i.latestSha) {
62
- let e = i.latestSha.slice(0, 7);
63
- d = `${d} ${pc.gray(`(${e})`)}`;
67
+ for (let { update: a, index: o } of d) {
68
+ let l = !!a.latestSha, d = S[o], f = d.display;
69
+ d.versionForPadding && d.shortSha && (f = `${padString(d.versionForPadding, M + 1)}${pc.gray(`(${d.shortSha})`)}`);
70
+ let p = d.effectiveForDiff ?? a.currentVersion, m = formatVersion(a.latestVersion, p), h = a.action.name;
71
+ if (a.latestSha) {
72
+ let e = a.latestSha.slice(0, 7);
73
+ m = `${padString(m, M + 1)}${pc.gray(`(${e})`)}`;
64
74
  }
65
- o || (d = pc.gray(d), l = pc.gray(l), f = pc.gray(f)), c.push({
66
- action: f,
67
- target: d,
75
+ l || (m = pc.gray(m), f = pc.gray(f), h = pc.gray(h));
76
+ let g = a.action.job ?? "–", _ = formatAge(a.publishedAt);
77
+ u.push({
78
+ job: l ? g : pc.gray(g),
79
+ age: l ? _ : pc.gray(_),
80
+ action: h,
81
+ target: m,
68
82
  arrow: "āÆ",
69
- current: l
83
+ current: f
70
84
  });
71
85
  }
72
- let u = Math.max(b, MIN_ACTION_WIDTH), m = Math.max(x, MIN_CURRENT_WIDTH), h = [];
73
- for (let [e, i] of c.entries()) {
74
- let a = e === 0, o = formatTableRow(i, u, m);
75
- if (a) h.push({
76
- message: pc.gray(` ā—‹ ${o}`),
86
+ let h = Math.max(k, MIN_ACTION_WIDTH), g = Math.max(A, MIN_CURRENT_WIDTH), _ = Math.max(j, MIN_JOB_WIDTH), v = [];
87
+ for (let [e, a] of u.entries()) {
88
+ let o = e === 0, s = formatTableRow({
89
+ targetWidth: N,
90
+ currentWidth: g,
91
+ actionWidth: h,
92
+ ageWidth: P,
93
+ jobWidth: _,
94
+ row: a
95
+ });
96
+ if (o) v.push({
97
+ message: pc.gray(` ā—‹ ${s}`),
77
98
  role: "separator",
78
99
  indent: "",
79
100
  name: ""
80
101
  });
81
102
  else {
82
- let i = l[e - 1];
83
- if (!i) continue;
84
- let { update: a, index: s } = i, c = !!a.latestSha, u = c && !a.isBreaking;
85
- h.push({
86
- message: o,
87
- value: String(s),
88
- name: String(s),
103
+ let { update: a, index: o } = d[e - 1], c = !!a.latestSha, l = c && !a.isBreaking;
104
+ v.push({
105
+ message: s,
106
+ value: String(o),
107
+ name: String(o),
89
108
  disabled: !c,
90
109
  indent: "",
91
- enabled: u
110
+ enabled: l
92
111
  });
93
112
  }
94
113
  }
95
- _.push({
96
- message: pc.gray(a),
97
- value: `label|${a}`,
98
- choices: h,
99
- name: `label|${a}`,
114
+ C.push({
115
+ message: pc.gray(o),
116
+ value: `label|${o}`,
117
+ choices: v,
118
+ name: `label|${o}`,
100
119
  isGroupLabel: !0,
101
120
  enabled: !1
102
- }), i < S.length - 1 && _.push({
121
+ }), a < F.length - 1 && C.push({
103
122
  role: "separator",
104
123
  message: " ",
105
124
  name: ""
@@ -107,12 +126,12 @@ async function promptUpdateSelection(o) {
107
126
  }
108
127
  try {
109
128
  let e = {
110
- indicator(e, i) {
111
- if (i.isGroupLabel) {
112
- let e = (i.choices ?? []).filter((e) => !("role" in e)), a = e.length, o = e.filter((e) => !!e.enabled).length === a ? "ā—" : "ā—‹";
113
- return ` ${pc.gray(o)}`;
129
+ indicator(e, a) {
130
+ if (a.isGroupLabel) {
131
+ let e = (a.choices ?? []).filter((e) => !("role" in e)), o = e.length, s = e.filter((e) => !!e.enabled).length === o ? "ā—" : "ā—‹";
132
+ return ` ${pc.gray(s)}`;
114
133
  }
115
- return ` ${i.enabled ? "ā—" : "ā—‹"}`;
134
+ return ` ${a.enabled ? "ā—" : "ā—‹"}`;
116
135
  },
117
136
  message: `Choose which actions to update (Press ${pc.cyan("<space>")} to select, ${pc.cyan("<a>")} to toggle all, ${pc.cyan("<i>")} to invert selection)`,
118
137
  styles: {
@@ -133,47 +152,53 @@ async function promptUpdateSelection(o) {
133
152
  type: "multiselect",
134
153
  name: "selected",
135
154
  pointer: "āÆ",
136
- choices: _
137
- }, { selected: i } = await enquirer.prompt(e), a = /* @__PURE__ */ new Set();
138
- for (let e of i) {
155
+ choices: C
156
+ }, { selected: a } = await enquirer.prompt(e), o = /* @__PURE__ */ new Set();
157
+ for (let e of a) {
139
158
  if (e.startsWith("label|")) {
140
- let i = e.slice(6), o = p.get(i) ?? [];
141
- for (let { update: e, index: i } of o) e.latestSha && a.add(i);
159
+ let a = e.slice(6), s = x.get(a) ?? [];
160
+ for (let { update: e, index: a } of s) e.latestSha && o.add(a);
142
161
  continue;
143
162
  }
144
- let i = Number.parseInt(e, 10);
145
- Number.isFinite(i) && a.add(i);
163
+ let a = Number.parseInt(e, 10);
164
+ Number.isFinite(a) && o.add(a);
146
165
  }
147
- let o = [];
148
- for (let [e, i] of c.entries()) a.has(e) && i.latestSha && o.push(i);
149
- return o.length === 0 ? (console.info(pc.yellow("\nNo actions selected")), null) : o;
166
+ let s = [];
167
+ for (let [e, a] of b.entries()) o.has(e) && a.latestSha && s.push(a);
168
+ return s.length === 0 ? (console.info(pc.yellow("\nNo actions selected")), null) : s;
150
169
  } catch (e) {
151
170
  if (e instanceof Error && (e.message.includes("cancelled") || e.message.includes("ESC") || e.name === "ExitPromptError")) return logSelectionCancelled(), null;
152
171
  throw console.error(pc.red("Unexpected error during selection:"), e), e;
153
172
  }
154
173
  }
155
- async function tryReadInlineVersionComment(e, i) {
174
+ async function tryReadInlineVersionComment(e, a) {
156
175
  try {
157
- if (!e || !i || i <= 0) return null;
158
- let a = (await readFile(e, "utf8")).split("\n"), o = i - 1;
159
- if (o < 0 || o >= a.length) return null;
160
- let s = a[o].match(/#\s*(?<version>[Vv]?\d+(?:\.\d+){0,2}(?:[+-][\w\-.]+)?)/u);
161
- if (s?.groups?.version) return s.groups.version;
176
+ if (!e || !a || a <= 0) return null;
177
+ let o = (await readFile(e, "utf8")).split("\n"), s = a - 1;
178
+ if (s < 0 || s >= o.length) return null;
179
+ let c = o[s].match(/#\s*(?<version>[Vv]?\d+(?:\.\d+){0,2}(?:[+-][\w\-.]+)?)/u);
180
+ if (c?.groups?.version) return c.groups.version;
162
181
  } catch {}
163
182
  return null;
164
183
  }
165
- function formatTableRow(e, i, a) {
166
- return [
167
- padString(e.action, i),
168
- padString(e.current, a),
169
- e.arrow,
170
- e.target
171
- ].join(" ").replace(/\s+$/u, "");
184
+ function formatAge(e) {
185
+ if (!e) return "";
186
+ let a = Date.now() - e.getTime(), o = Math.floor(a / (1e3 * 60 * 60)), s = Math.floor(o / 24), c = Math.floor(s / 7), l = s % 7;
187
+ return c >= 1 ? l > 0 ? `${c}w ${l}d` : `${c}w` : s >= 1 ? `${s}d` : `${o}h`;
188
+ }
189
+ function formatTableRow(e) {
190
+ let { currentWidth: a, actionWidth: o, targetWidth: c, jobWidth: l, ageWidth: u, row: d } = e, f = [
191
+ padString(d.action, o),
192
+ padString(d.job, l),
193
+ padString(d.current, a),
194
+ d.arrow,
195
+ padString(d.target, c)
196
+ ];
197
+ return u > 0 && f.push(d.age), f.join(" ").replace(/\s+$/u, "");
172
198
  }
173
199
  function isSha(e) {
174
- if (!e) return !1;
175
- let i = e.replace(/^v/u, "");
176
- return /^[0-9a-f]{7,40}$/iu.test(i);
200
+ let a = e.replace(/^v/u, "");
201
+ return /^[0-9a-f]{7,40}$/iu.test(a);
177
202
  }
178
203
  function formatVersionOrSha(e) {
179
204
  return e ? isSha(e) ? e.slice(0, 7) : e.replace(/^v/u, "") : pc.gray("unknown");
@@ -24,7 +24,7 @@ function parseActionReference(e, t, n) {
24
24
  if (!s || !c) return null;
25
25
  for (let e of o.slice(2)) if (!e) return null;
26
26
  return {
27
- type: "external",
27
+ type: o.length > 2 && (i.endsWith(".yml") || i.endsWith(".yaml")) ? "reusable-workflow" : "external",
28
28
  name: i,
29
29
  version: a,
30
30
  file: t,
@@ -8,10 +8,12 @@ import { ScanResult } from '../types/scan-result';
8
8
  *
9
9
  * @param rootPath - The root path of the repository to scan. Defaults to
10
10
  * current working directory.
11
+ * @param ciDirectory - The CI directory name (e.g., '.github' or '.gitea').
12
+ * Defaults to '.github'.
11
13
  * @returns A promise that resolves to a ScanResult containing:
12
14
  *
13
15
  * - Workflows: Map of workflow file paths to their referenced actions
14
16
  * - CompositeActions: Map of composite action names to their directory paths
15
17
  * - Actions: Flat array of all discovered GitHub Actions.
16
18
  */
17
- export declare function scanGitHubActions(rootPath?: string): Promise<ScanResult>;
19
+ export declare function scanGitHubActions(rootPath?: string, ciDirectory?: string): Promise<ScanResult>;
@@ -4,96 +4,85 @@ import { scanActionFile } from "./scan-action-file.js";
4
4
  import { isYamlFile } from "./fs/is-yaml-file.js";
5
5
  import { readFile, readdir, stat } from "node:fs/promises";
6
6
  import { isAbsolute, join, relative, resolve } from "node:path";
7
- async function scanGitHubActions(d = process.cwd()) {
8
- let m = {
7
+ async function scanGitHubActions(d = process.cwd(), m = GITHUB_DIRECTORY) {
8
+ let h = {
9
9
  compositeActions: /* @__PURE__ */ new Map(),
10
10
  workflows: /* @__PURE__ */ new Map(),
11
11
  actions: []
12
- }, h = resolve(d);
13
- function g(e, o) {
12
+ }, g = resolve(d);
13
+ function _(e, o) {
14
14
  let s = relative(e, o);
15
15
  return s !== "" && !s.startsWith("..") && !isAbsolute(s);
16
16
  }
17
- let _ = join(h, GITHUB_DIRECTORY);
18
- if (!g(h, _)) throw Error("Invalid path: detected path traversal attempt");
19
- function v(e) {
17
+ let v = join(g, m);
18
+ if (!_(g, v)) throw Error("Invalid path: detected path traversal attempt");
19
+ function y(e) {
20
20
  return e.includes("..") || e.includes("/") || e.includes("\\") ? (console.warn(`Skipping invalid name: ${e}`), !1) : !0;
21
21
  }
22
- let y = join(_, WORKFLOWS_DIRECTORY);
23
- if (!g(h, y)) return m;
22
+ let b = join(v, WORKFLOWS_DIRECTORY);
24
23
  try {
25
- if ((await stat(y)).isDirectory()) {
26
- let e = (await readdir(y)).filter((e) => v(e) ? isYamlFile(e) : !1).map(async (e) => {
27
- let l = join(y, e);
28
- if (!g(y, l)) return console.warn(`Skipping file outside workflows directory: ${e}`), {
29
- success: !1,
30
- actions: [],
31
- path: ""
32
- };
24
+ if ((await stat(b)).isDirectory()) {
25
+ let e = (await readdir(b)).filter((e) => y(e) ? isYamlFile(e) : !1).map(async (e) => {
26
+ let o = join(b, e);
33
27
  try {
34
- let u = await scanWorkflowFile(l);
28
+ let l = await scanWorkflowFile(o);
35
29
  return {
36
- path: `${GITHUB_DIRECTORY}/${WORKFLOWS_DIRECTORY}/${e}`,
30
+ path: `${m}/${WORKFLOWS_DIRECTORY}/${e}`,
37
31
  success: !0,
38
- actions: u
32
+ actions: l
39
33
  };
40
34
  } catch {
41
35
  return {
42
- path: `${GITHUB_DIRECTORY}/${WORKFLOWS_DIRECTORY}/${e}`,
36
+ path: `${m}/${WORKFLOWS_DIRECTORY}/${e}`,
43
37
  success: !1,
44
38
  actions: []
45
39
  };
46
40
  }
47
- }), l = await Promise.all(e);
48
- for (let e of l) e.success && e.path && (e.actions.length > 0 ? (m.workflows.set(e.path, e.actions), m.actions.push(...e.actions)) : m.workflows.set(e.path, []));
41
+ }), o = await Promise.all(e);
42
+ for (let e of o) e.success && e.path && (e.actions.length > 0 ? (h.workflows.set(e.path, e.actions), h.actions.push(...e.actions)) : h.workflows.set(e.path, []));
49
43
  }
50
44
  } catch {}
51
- let b = join(_, ACTIONS_DIRECTORY);
52
- if (!g(h, b)) return m;
45
+ let x = join(v, ACTIONS_DIRECTORY);
53
46
  try {
54
- if ((await stat(b)).isDirectory()) {
55
- let s = (await readdir(b)).map(async (s) => {
56
- if (!v(s)) return null;
57
- let c = join(b, s);
58
- if (!g(b, c)) return console.warn(`Skipping subdirectory outside actions path: ${s}`), null;
47
+ if ((await stat(x)).isDirectory()) {
48
+ let o = (await readdir(x)).map(async (o) => {
49
+ if (!y(o)) return null;
50
+ let s = join(x, o);
59
51
  try {
60
- if (!(await stat(c)).isDirectory()) return null;
61
- let u = join(c, "action.yml");
62
- if (!g(c, u)) return null;
63
- let d = [];
52
+ if (!(await stat(s)).isDirectory()) return null;
53
+ let c = join(s, "action.yml"), u = [];
64
54
  try {
65
- d = await scanActionFile(u);
55
+ u = await scanActionFile(c);
66
56
  } catch {
67
57
  try {
68
- if (u = join(c, "action.yaml"), !g(c, u)) return null;
69
- d = await scanActionFile(u);
58
+ c = join(s, "action.yaml"), u = await scanActionFile(c);
70
59
  } catch {
71
60
  return null;
72
61
  }
73
62
  }
74
63
  return {
75
- path: `${GITHUB_DIRECTORY}/${ACTIONS_DIRECTORY}/${s}`,
76
- name: s,
77
- actions: d
64
+ path: `${m}/${ACTIONS_DIRECTORY}/${o}`,
65
+ name: o,
66
+ actions: u
78
67
  };
79
68
  } catch {
80
69
  return null;
81
70
  }
82
- }), c = await Promise.all(s);
83
- for (let e of c) e && (m.compositeActions.set(e.name, e.path), m.actions.push(...e.actions));
71
+ }), s = await Promise.all(o);
72
+ for (let e of s) e && (h.compositeActions.set(e.name, e.path), h.actions.push(...e.actions));
84
73
  }
85
74
  } catch {}
86
75
  try {
87
- let e = await getCurrentRepoSlug(h);
76
+ let e = await getCurrentRepoSlug(g);
88
77
  if (e) {
89
78
  if (process.env.ACTIONS_UP_TEST_THROW === "1") throw Error("test");
90
79
  let o = /* @__PURE__ */ new Set(), s = [];
91
- for (let c of m.actions) {
80
+ for (let c of h.actions) {
92
81
  if (c.type !== "external") continue;
93
82
  let l = c.name.split("/");
94
83
  if (l.length < 3 || `${l[0]}/${l[1]}` !== e) continue;
95
- let u = join(h, ...l.slice(2));
96
- g(h, u) && (o.has(u) || (o.add(u), s.push(u)));
84
+ let u = join(g, ...l.slice(2));
85
+ _(g, u) && (o.has(u) || (o.add(u), s.push(u)));
97
86
  }
98
87
  async function c() {
99
88
  if (s.length === 0) return;
@@ -107,14 +96,14 @@ async function scanGitHubActions(d = process.cwd()) {
107
96
  d = u;
108
97
  }
109
98
  let f = await scanActionFile(d);
110
- f.length > 0 && m.actions.push(...f);
99
+ f.length > 0 && h.actions.push(...f);
111
100
  let p = [];
112
101
  for (let s of f) {
113
102
  if (s.type !== "external") continue;
114
103
  let c = s.name.split("/");
115
104
  if (c.length < 3 || `${c[0]}/${c[1]}` !== e) continue;
116
- let l = join(h, ...c.slice(2));
117
- g(h, l) && (o.has(l) || (o.add(l), p.push(l)));
105
+ let l = join(g, ...c.slice(2));
106
+ _(g, l) && (o.has(l) || (o.add(l), p.push(l)));
118
107
  }
119
108
  return p;
120
109
  } catch {
@@ -127,7 +116,7 @@ async function scanGitHubActions(d = process.cwd()) {
127
116
  await c();
128
117
  }
129
118
  } catch {}
130
- return m;
119
+ return h;
131
120
  }
132
121
  async function getCurrentRepoSlug(e) {
133
122
  let o = process.env.GITHUB_REPOSITORY;
package/dist/package.js CHANGED
@@ -1,2 +1,2 @@
1
- const version = "1.4.2";
1
+ const version = "1.6.0";
2
2
  export { version };
@@ -10,6 +10,9 @@ export interface ActionUpdate {
10
10
  /** SHA hash of the latest version. */
11
11
  latestSha: string | null
12
12
 
13
+ /** Publication date of the latest version (null if unknown). */
14
+ publishedAt: Date | null
15
+
13
16
  /** The original action from scanning. */
14
17
  action: GitHubAction
15
18
 
@@ -1,7 +1,7 @@
1
1
  /** Represents a GitHub Action used in workflows or composite actions. */
2
2
  export interface GitHubAction {
3
3
  /** Type of the GitHub Action. */
4
- type: 'composite' | 'external' | 'docker' | 'local'
4
+ type: 'reusable-workflow' | 'composite' | 'external' | 'docker' | 'local'
5
5
 
6
6
  /** Version or tag of the action (e.g., 'v1', 'main', commit SHA). */
7
7
  version?: string | null
@@ -15,6 +15,9 @@ export interface GitHubAction {
15
15
  /** Original `uses` string from workflow, if available. */
16
16
  uses?: string
17
17
 
18
+ /** Name of the job where this action is used (for workflows). */
19
+ job?: string
20
+
18
21
  /** Full name of the action (e.g., 'actions/checkout'). */
19
22
  name: string
20
23
 
@@ -1,6 +1,12 @@
1
1
  import { WorkflowStep } from './workflow-step';
2
2
  /** Represents a job in a GitHub Actions workflow. */
3
3
  export interface WorkflowJob {
4
+ /** Secrets passed to the reusable workflow ('inherit' or specific secrets). */
5
+ secrets?: Record<string, unknown> | 'inherit'
6
+
7
+ /** Input parameters passed to the reusable workflow. */
8
+ with?: Record<string, unknown>
9
+
4
10
  /** Runner environment(s) to execute this job on (e.g., 'ubuntu-latest'). */
5
11
  'runs-on'?: string[] | string
6
12
 
@@ -13,6 +19,9 @@ export interface WorkflowJob {
13
19
  /** Allow additional properties for job configuration. */
14
20
  [key: string]: unknown
15
21
 
22
+ /** Reusable workflow reference (mutually exclusive with 'steps'). */
23
+ uses?: string
24
+
16
25
  /** Conditional expression to determine if the job should run. */
17
26
  if?: string
18
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "actions-up",
3
- "version": "1.4.2",
3
+ "version": "1.6.0",
4
4
  "description": "Interactive CLI tool to update GitHub Actions to latest versions with SHA pinning",
5
5
  "keywords": [
6
6
  "github-actions",
package/readme.md CHANGED
@@ -19,6 +19,7 @@ Interactively upgrade and pin actions to exact commit SHAs for secure, reproduci
19
19
  ## Features
20
20
 
21
21
  - **Auto-discovery**: Scans all workflows (`.github/workflows/*.yml`) and composite actions (`.github/actions/*/action.yml`)
22
+ - **Reusable Workflows**: Detects and updates reusable workflow calls at the job level
22
23
  - **SHA pinning**: Updates actions to use commit SHA instead of tags for better security
23
24
  - **Batch Updates**: Update multiple actions at once
24
25
  - **Interactive Selection**: Choose which actions to update
@@ -122,6 +123,14 @@ Check for updates without making any changes:
122
123
  npx actions-up --dry-run
123
124
  ```
124
125
 
126
+ ### Custom Directory
127
+
128
+ By default, Actions Up scans the `.github` directory. You can specify a different directory (e.g., for Gitea):
129
+
130
+ ```bash
131
+ npx actions-up --dir .gitea
132
+ ```
133
+
125
134
  ## GitHub Actions Integration
126
135
 
127
136
  ### Automated PR Checks
@@ -371,6 +380,8 @@ jobs:
371
380
 
372
381
  ## Example
373
382
 
383
+ ### Regular Actions
384
+
374
385
  ```yaml
375
386
  # Before
376
387
  - uses: actions/checkout@v3
@@ -381,6 +392,26 @@ jobs:
381
392
  - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
382
393
  ```
383
394
 
395
+ ### Reusable Workflows
396
+
397
+ Actions Up also detects and updates reusable workflow calls:
398
+
399
+ ```yaml
400
+ # Before
401
+ jobs:
402
+ call-workflow:
403
+ uses: org/repo/.github/workflows/ci.yml@v1.0.0
404
+ with:
405
+ config: production
406
+
407
+ # After running actions-up
408
+ jobs:
409
+ call-workflow:
410
+ uses: org/repo/.github/workflows/ci.yml@a1b2c3d4e5f6 # v2.0.0
411
+ with:
412
+ config: production
413
+ ```
414
+
384
415
  ## Advanced Usage
385
416
 
386
417
  ### Using GitHub Token for Higher Rate Limits
@@ -431,6 +462,17 @@ npx actions-up --exclude ".*/internal-.*" --exclude "/^acme\/.+$/i"
431
462
  npx actions-up --exclude "my-org/.*, .*/internal-.*"
432
463
  ```
433
464
 
465
+ #### Filtering by Release Age
466
+
467
+ By default, Actions Up shows all available updates. You can filter out recently released updates to avoid updating to versions that haven't been battle-tested yet:
468
+
469
+ ```bash
470
+ # Only show updates released at least 7 days ago
471
+ npx actions-up --min-age 7
472
+ ```
473
+
474
+ When `--min-age` is set, an "Age" column appears showing how long ago each release was published (e.g., `3d`, `1w 2d`).
475
+
434
476
  #### Ignore Comments
435
477
 
436
478
  You can skip specific actions or files using YAML comments. Ignored items are hidden in dry-run and interactive modes and are not updated with `--yes`.