actions-up 1.5.0 → 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,7 +11,7 @@ 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("--dir <directory>", "Custom directory name (default: .github)").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 {
@@ -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,186 +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
- let r = e.replace(/^v/u, ""), i = n.replace(/^v/u, ""), a = Math.min(r.length, i.length);
167
- 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();
168
188
  }
169
189
  function normalizeVersion(e) {
170
190
  if (!e) return null;
171
- let r = e.replace(/^v/u, "");
172
- if (/^[0-9a-f]{7,40}$/iu.test(r)) return e;
173
- let i = semver.coerce(r);
174
- 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;
175
195
  }
176
196
  function isSha(e) {
177
197
  if (!e) return !1;
178
- let n = e.replace(/^v/u, "");
179
- 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);
180
200
  }
181
201
  function isSemverLike(e) {
182
202
  if (!e) return !1;
183
- let n = e.trim();
184
- return /^v?\d+(?:\.\d+){0,2}$/u.test(n);
203
+ let t = e.trim();
204
+ return /^v?\d+(?:\.\d+){0,2}$/u.test(t);
185
205
  }
186
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 };
@@ -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,97 +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 { update: i, index: a } = l[e - 1], s = !!i.latestSha, c = s && !i.isBreaking;
83
- h.push({
84
- message: o,
85
- value: String(a),
86
- name: String(a),
87
- disabled: !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),
108
+ disabled: !c,
88
109
  indent: "",
89
- enabled: c
110
+ enabled: l
90
111
  });
91
112
  }
92
113
  }
93
- _.push({
94
- message: pc.gray(a),
95
- value: `label|${a}`,
96
- choices: h,
97
- name: `label|${a}`,
114
+ C.push({
115
+ message: pc.gray(o),
116
+ value: `label|${o}`,
117
+ choices: v,
118
+ name: `label|${o}`,
98
119
  isGroupLabel: !0,
99
120
  enabled: !1
100
- }), i < S.length - 1 && _.push({
121
+ }), a < F.length - 1 && C.push({
101
122
  role: "separator",
102
123
  message: " ",
103
124
  name: ""
@@ -105,12 +126,12 @@ async function promptUpdateSelection(o) {
105
126
  }
106
127
  try {
107
128
  let e = {
108
- indicator(e, i) {
109
- if (i.isGroupLabel) {
110
- let e = (i.choices ?? []).filter((e) => !("role" in e)), a = e.length, o = e.filter((e) => !!e.enabled).length === a ? "ā—" : "ā—‹";
111
- 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)}`;
112
133
  }
113
- return ` ${i.enabled ? "ā—" : "ā—‹"}`;
134
+ return ` ${a.enabled ? "ā—" : "ā—‹"}`;
114
135
  },
115
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)`,
116
137
  styles: {
@@ -131,46 +152,53 @@ async function promptUpdateSelection(o) {
131
152
  type: "multiselect",
132
153
  name: "selected",
133
154
  pointer: "āÆ",
134
- choices: _
135
- }, { selected: i } = await enquirer.prompt(e), a = /* @__PURE__ */ new Set();
136
- 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) {
137
158
  if (e.startsWith("label|")) {
138
- let i = e.slice(6), o = p.get(i) ?? [];
139
- 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);
140
161
  continue;
141
162
  }
142
- let i = Number.parseInt(e, 10);
143
- Number.isFinite(i) && a.add(i);
163
+ let a = Number.parseInt(e, 10);
164
+ Number.isFinite(a) && o.add(a);
144
165
  }
145
- let o = [];
146
- for (let [e, i] of c.entries()) a.has(e) && i.latestSha && o.push(i);
147
- 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;
148
169
  } catch (e) {
149
170
  if (e instanceof Error && (e.message.includes("cancelled") || e.message.includes("ESC") || e.name === "ExitPromptError")) return logSelectionCancelled(), null;
150
171
  throw console.error(pc.red("Unexpected error during selection:"), e), e;
151
172
  }
152
173
  }
153
- async function tryReadInlineVersionComment(e, i) {
174
+ async function tryReadInlineVersionComment(e, a) {
154
175
  try {
155
- if (!e || !i || i <= 0) return null;
156
- let a = (await readFile(e, "utf8")).split("\n"), o = i - 1;
157
- if (o < 0 || o >= a.length) return null;
158
- let s = a[o].match(/#\s*(?<version>[Vv]?\d+(?:\.\d+){0,2}(?:[+-][\w\-.]+)?)/u);
159
- 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;
160
181
  } catch {}
161
182
  return null;
162
183
  }
163
- function formatTableRow(e, i, a) {
164
- return [
165
- padString(e.action, i),
166
- padString(e.current, a),
167
- e.arrow,
168
- e.target
169
- ].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, "");
170
198
  }
171
199
  function isSha(e) {
172
- let i = e.replace(/^v/u, "");
173
- 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);
174
202
  }
175
203
  function formatVersionOrSha(e) {
176
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,
package/dist/package.js CHANGED
@@ -1,2 +1,2 @@
1
- const version = "1.5.0";
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.5.0",
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
@@ -379,6 +380,8 @@ jobs:
379
380
 
380
381
  ## Example
381
382
 
383
+ ### Regular Actions
384
+
382
385
  ```yaml
383
386
  # Before
384
387
  - uses: actions/checkout@v3
@@ -389,6 +392,26 @@ jobs:
389
392
  - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
390
393
  ```
391
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
+
392
415
  ## Advanced Usage
393
416
 
394
417
  ### Using GitHub Token for Higher Rate Limits
@@ -439,6 +462,17 @@ npx actions-up --exclude ".*/internal-.*" --exclude "/^acme\/.+$/i"
439
462
  npx actions-up --exclude "my-org/.*, .*/internal-.*"
440
463
  ```
441
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
+
442
476
  #### Ignore Comments
443
477
 
444
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`.