actions-up 1.1.0 → 1.2.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.
@@ -20,19 +20,47 @@ async function checkUpdates(actions, token) {
20
20
  actionName,
21
21
  sha: null
22
22
  }];
23
- let [owner, repo] = actionName.split("/");
23
+ let segments = actionName.split("/");
24
+ if (segments.length < 2) return [...results, {
25
+ version: null,
26
+ actionName,
27
+ sha: null
28
+ }];
29
+ let [owner, repo] = segments;
24
30
  if (!owner || !repo) return [...results, {
25
31
  version: null,
26
32
  actionName,
27
33
  sha: null
28
34
  }];
29
35
  try {
36
+ let currentVersions = uniqueActions.get(actionName);
37
+ let firstVersion = currentVersions[0]?.version;
38
+ if (firstVersion) {
39
+ let referenceType = await client.getRefType(owner, repo, firstVersion);
40
+ if (referenceType === "branch") return [...results, {
41
+ version: null,
42
+ actionName,
43
+ sha: null
44
+ }];
45
+ }
30
46
  let release = await client.getLatestRelease(owner, repo);
31
47
  if (!release) {
32
48
  let allReleases = await client.getAllReleases(owner, repo, 10);
33
49
  let stableRelease = allReleases.find((currentRelease) => !currentRelease.isPrerelease);
34
50
  release = stableRelease ?? allReleases[0] ?? null;
35
51
  }
52
+ if (!release) {
53
+ let tags = await client.getAllTags(owner, repo, 30);
54
+ if (tags.length > 0) {
55
+ let semverTag = tags.find((tag) => /^v?\d+(?:\.\d+){0,2}/u.test(tag.tag));
56
+ let latestTag = semverTag ?? tags[0];
57
+ if (latestTag) return [...results, {
58
+ version: latestTag.tag,
59
+ sha: latestTag.sha,
60
+ actionName
61
+ }];
62
+ }
63
+ }
36
64
  if (release) {
37
65
  let { version, sha } = release;
38
66
  if (!sha) try {
@@ -104,6 +132,10 @@ function createUpdate(action, latestVersion, latestSha) {
104
132
  let latestMajor = semver.major(latest);
105
133
  isBreaking = latestMajor > currentMajor;
106
134
  }
135
+ if (!hasUpdate && semver.eq(current, latest) && !isSha(action.version) && latestSha) {
136
+ hasUpdate = true;
137
+ isBreaking = false;
138
+ }
107
139
  } else if (currentVersion !== normalized) hasUpdate = true;
108
140
  }
109
141
  return {
@@ -1,4 +1,3 @@
1
- /** Processed release information with normalized types. */
2
1
  interface ReleaseInfo {
3
2
  /** Release description or null if not provided. */
4
3
  description: string | null;
@@ -19,18 +18,19 @@ interface ReleaseInfo {
19
18
  interface TagInfo {
20
19
  /** Tag or commit message, null if not provided. */
21
20
  message: string | null;
21
+ /** Git commit SHA that this tag points to. */
22
+ sha: string | null;
22
23
  /** Date when the tag was created or committed. */
23
24
  date: Date | null;
24
25
  /** Tag name (e.g., 'v1.2.3'). */
25
26
  tag: string;
26
- /** Git commit SHA that this tag points to. */
27
- sha: string;
28
27
  }
29
28
  /** GitHub REST API client with optional authentication. */
30
29
  export declare class Client {
30
+ private readonly baseUrl;
31
+ private readonly token;
31
32
  private rateLimitReset;
32
33
  private rateLimitRemaining;
33
- private readonly octokit;
34
34
  /**
35
35
  * Creates a new GitHub API client.
36
36
  *
@@ -64,6 +64,8 @@ export declare class Client {
64
64
  * @returns Latest release information or null if not found.
65
65
  */
66
66
  getLatestRelease(owner: string, repo: string): Promise<ReleaseInfo | null>;
67
+ getAllTags(owner: string, repo: string, limit?: number): Promise<TagInfo[]>;
68
+ getRefType(owner: string, repo: string, reference: string): Promise<'branch' | 'tag' | null>;
67
69
  getRateLimitStatus(): {
68
70
  remaining: number;
69
71
  resetAt: Date;
@@ -75,6 +77,22 @@ export declare class Client {
75
77
  * @returns True if rate limit is below threshold.
76
78
  */
77
79
  shouldWaitForRateLimit(threshold?: number): boolean;
80
+ private makeRequest;
78
81
  private updateRateLimitInfo;
79
82
  }
83
+ /**
84
+ * Resolve GitHub token from multiple sources with descending priority.
85
+ *
86
+ * Priority:
87
+ *
88
+ * 1. Env GITHUB_TOKEN
89
+ * 2. Env GH_TOKEN
90
+ * 3. Gh auth token
91
+ * 4. .git/config keys (github.token, github.oauth-token, hub.oauthtoken).
92
+ *
93
+ * This is a synchronous best-effort resolver without throwing on failures.
94
+ *
95
+ * @returns Token string or undefined when not found.
96
+ */
97
+ export declare function resolveGitHubTokenSync(): undefined | string;
80
98
  export {};
@@ -1,4 +1,6 @@
1
- import { Octokit } from "@octokit/rest";
1
+ import { join } from "node:path";
2
+ import { execFileSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
2
4
  var GitHubRateLimitError = class extends Error {
3
5
  constructor(resetAt) {
4
6
  let resetTime = resetAt.toLocaleTimeString();
@@ -7,14 +9,13 @@ var GitHubRateLimitError = class extends Error {
7
9
  }
8
10
  };
9
11
  var Client = class Client {
12
+ baseUrl = "https://api.github.com";
13
+ token;
10
14
  rateLimitReset = /* @__PURE__ */ new Date();
11
15
  rateLimitRemaining = 60;
12
- octokit;
13
16
  constructor(token) {
14
- let authToken = token ?? process.env["GITHUB_TOKEN"];
15
- this.octokit = new Octokit({ auth: authToken ?? void 0 });
16
- if (!authToken) console.warn("No GitHub token found. API rate limits will be restricted.");
17
- this.rateLimitRemaining = authToken ? 5e3 : 60;
17
+ this.token = token ?? process.env["GITHUB_TOKEN"] ?? resolveGitHubTokenSync();
18
+ this.rateLimitRemaining = this.token ? 5e3 : 60;
18
19
  }
19
20
  static isRateLimitError(error) {
20
21
  if (error && typeof error === "object") {
@@ -29,19 +30,12 @@ var Client = class Client {
29
30
  try {
30
31
  let displayTag = tag.replace(/^refs\/tags\//u, "");
31
32
  try {
32
- let { headers: releaseHeaders, data: releaseData } = await this.octokit.repos.getReleaseByTag({
33
- tag: displayTag,
34
- owner,
35
- repo
36
- });
37
- this.updateRateLimitInfo(releaseHeaders);
33
+ let releaseResp = await this.makeRequest(`/repos/${owner}/${repo}/releases/tags/${displayTag}`);
34
+ let releaseData = releaseResp.data;
38
35
  let sha = null;
39
36
  if (releaseData.target_commitish) try {
40
- let { data: commitData } = await this.octokit.repos.getCommit({
41
- ref: releaseData.target_commitish,
42
- owner,
43
- repo
44
- });
37
+ let commitResp = await this.makeRequest(`/repos/${owner}/${repo}/commits/${releaseData.target_commitish}`);
38
+ let commitData = commitResp.data;
45
39
  ({sha} = commitData);
46
40
  } catch {
47
41
  sha = releaseData.target_commitish;
@@ -53,32 +47,22 @@ var Client = class Client {
53
47
  tag: displayTag
54
48
  };
55
49
  } catch (releaseError) {
56
- if (releaseError instanceof Error && "status" in releaseError && releaseError.status === 404) try {
57
- let { headers: referenceHeaders, data: referenceData } = await this.octokit.git.getRef({
58
- ref: `tags/${displayTag}`,
59
- owner,
60
- repo
61
- });
62
- this.updateRateLimitInfo(referenceHeaders);
50
+ if (releaseError && typeof releaseError === "object" && "status" in releaseError && releaseError.status === 404) try {
51
+ let referenceResp = await this.makeRequest(`/repos/${owner}/${repo}/git/refs/tags/${displayTag}`);
52
+ let referenceData = referenceResp.data;
63
53
  let { sha } = referenceData.object;
64
54
  let message = null;
65
55
  let date = null;
66
56
  if (referenceData.object.type === "tag") try {
67
- let { data: tagData } = await this.octokit.git.getTag({
68
- tag_sha: sha,
69
- owner,
70
- repo
71
- });
57
+ let tagResp = await this.makeRequest(`/repos/${owner}/${repo}/git/tags/${sha}`);
58
+ let tagData = tagResp.data;
72
59
  ({sha} = tagData.object);
73
- message = tagData.message || null;
60
+ ({message} = tagData);
74
61
  date = tagData.tagger.date ? new Date(tagData.tagger.date) : null;
75
62
  } catch {}
76
- else if (referenceData.object.type === "commit") try {
77
- let { data: commitData } = await this.octokit.git.getCommit({
78
- commit_sha: sha,
79
- owner,
80
- repo
81
- });
63
+ else try {
64
+ let commitResp = await this.makeRequest(`/repos/${owner}/${repo}/git/commits/${sha}`);
65
+ let commitData = commitResp.data;
82
66
  ({message} = commitData);
83
67
  date = commitData.author.date ? new Date(commitData.author.date) : null;
84
68
  } catch {}
@@ -89,7 +73,7 @@ var Client = class Client {
89
73
  sha
90
74
  };
91
75
  } catch (tagError) {
92
- if (tagError instanceof Error && "status" in tagError && tagError.status === 404) return null;
76
+ if (tagError && typeof tagError === "object" && "status" in tagError && tagError.status === 404) return null;
93
77
  throw tagError;
94
78
  }
95
79
  throw releaseError;
@@ -101,12 +85,8 @@ var Client = class Client {
101
85
  }
102
86
  async getAllReleases(owner, repo, limit = 10) {
103
87
  try {
104
- let { data: releases, headers } = await this.octokit.repos.listReleases({
105
- per_page: limit,
106
- owner,
107
- repo
108
- });
109
- this.updateRateLimitInfo(headers);
88
+ let releasesResp = await this.makeRequest(`/repos/${owner}/${repo}/releases?per_page=${limit}`);
89
+ let releases = releasesResp.data;
110
90
  let releaseInfos = [];
111
91
  await Promise.all(releases.map(async (release) => {
112
92
  let sha = null;
@@ -114,7 +94,7 @@ var Client = class Client {
114
94
  let tagInfo = await this.getTagInfo(owner, repo, release.tag_name);
115
95
  if (tagInfo) ({sha} = tagInfo);
116
96
  } catch {
117
- sha = release.target_commitish || null;
97
+ sha = release.target_commitish;
118
98
  }
119
99
  releaseInfos.push({
120
100
  publishedAt: new Date(release.published_at),
@@ -134,17 +114,14 @@ var Client = class Client {
134
114
  }
135
115
  async getLatestRelease(owner, repo) {
136
116
  try {
137
- let { data: release, headers } = await this.octokit.repos.getLatestRelease({
138
- owner,
139
- repo
140
- });
141
- this.updateRateLimitInfo(headers);
117
+ let releaseResp = await this.makeRequest(`/repos/${owner}/${repo}/releases/latest`);
118
+ let release = releaseResp.data;
142
119
  let sha = null;
143
120
  if (release.tag_name) try {
144
121
  let tagInfo = await this.getTagInfo(owner, repo, release.tag_name);
145
122
  if (tagInfo) ({sha} = tagInfo);
146
123
  } catch {
147
- sha = release.target_commitish || null;
124
+ sha = release.target_commitish;
148
125
  }
149
126
  return {
150
127
  publishedAt: new Date(release.published_at),
@@ -156,11 +133,39 @@ var Client = class Client {
156
133
  sha
157
134
  };
158
135
  } catch (error) {
159
- if (error instanceof Error && "status" in error && error.status === 404) return null;
136
+ if (error && typeof error === "object" && "status" in error && error.status === 404) return null;
160
137
  if (Client.isRateLimitError(error)) throw new GitHubRateLimitError(this.rateLimitReset);
161
138
  throw error;
162
139
  }
163
140
  }
141
+ async getAllTags(owner, repo, limit = 30) {
142
+ try {
143
+ let tagsResp = await this.makeRequest(`/repos/${owner}/${repo}/tags?per_page=${limit}`);
144
+ let tags = tagsResp.data;
145
+ return tags.map((tag) => ({
146
+ sha: tag.commit.sha,
147
+ tag: tag.name,
148
+ message: null,
149
+ date: null
150
+ }));
151
+ } catch (error) {
152
+ if (Client.isRateLimitError(error)) throw new GitHubRateLimitError(this.rateLimitReset);
153
+ throw error;
154
+ }
155
+ }
156
+ async getRefType(owner, repo, reference) {
157
+ try {
158
+ await this.makeRequest(`/repos/${owner}/${repo}/git/refs/tags/${reference}`);
159
+ return "tag";
160
+ } catch {
161
+ try {
162
+ await this.makeRequest(`/repos/${owner}/${repo}/git/refs/heads/${reference}`);
163
+ return "branch";
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+ }
164
169
  getRateLimitStatus() {
165
170
  return {
166
171
  remaining: this.rateLimitRemaining,
@@ -170,6 +175,35 @@ var Client = class Client {
170
175
  shouldWaitForRateLimit(threshold = 100) {
171
176
  return this.rateLimitRemaining < threshold;
172
177
  }
178
+ async makeRequest(path$1, options = {}) {
179
+ let headers = {
180
+ Accept: "application/vnd.github.v3+json",
181
+ "User-Agent": "actions-up",
182
+ ...options.headers
183
+ };
184
+ if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
185
+ let response = await fetch(`${this.baseUrl}${path$1}`, {
186
+ ...options,
187
+ headers
188
+ });
189
+ let responseHeaders = {};
190
+ for (let [key, value] of response.headers.entries()) responseHeaders[key] = value;
191
+ this.updateRateLimitInfo(responseHeaders);
192
+ if (!response.ok) {
193
+ let error = /* @__PURE__ */ new Error(`GitHub API error: ${response.status} ${response.statusText}`);
194
+ error.status = response.status;
195
+ if (response.status === 403) {
196
+ let text = await response.text();
197
+ if (text.includes("rate limit") || text.includes("API rate limit")) error.message = "API rate limit exceeded";
198
+ }
199
+ throw error;
200
+ }
201
+ let data = await response.json();
202
+ return {
203
+ headers: responseHeaders,
204
+ data
205
+ };
206
+ }
173
207
  updateRateLimitInfo(headers) {
174
208
  let remaining = headers["x-ratelimit-remaining"];
175
209
  if (remaining !== void 0) this.rateLimitRemaining = typeof remaining === "string" ? Number.parseInt(remaining, 10) : remaining;
@@ -180,4 +214,48 @@ var Client = class Client {
180
214
  }
181
215
  }
182
216
  };
217
+ function resolveGitHubTokenSync() {
218
+ let fromGithubToken = process.env["GITHUB_TOKEN"];
219
+ if (fromGithubToken && fromGithubToken.trim() !== "") return fromGithubToken.trim();
220
+ let fromGhToken = process.env["GH_TOKEN"];
221
+ if (fromGhToken && fromGhToken.trim() !== "") return fromGhToken.trim();
222
+ try {
223
+ let output = execFileSync("gh", ["auth", "token"], {
224
+ stdio: [
225
+ "ignore",
226
+ "pipe",
227
+ "ignore"
228
+ ],
229
+ encoding: "utf8",
230
+ timeout: 500
231
+ });
232
+ let token = output.trim();
233
+ if (token) return token;
234
+ } catch {}
235
+ try {
236
+ let gitConfigPath = join(process.cwd(), ".git", "config");
237
+ let content = readFileSync(gitConfigPath, "utf8");
238
+ let directMatch = content.match(/^\s*(?:github\.(?:oauth-token|token)|hub\.oauthtoken)\s*=\s*(?<token>\S[^\n\r]*)$/mu);
239
+ let directToken = directMatch?.groups?.["token"]?.trim();
240
+ if (directToken) return directToken;
241
+ let currentSection = null;
242
+ for (let rawLine of content.split(/\r?\n/u)) {
243
+ let line = rawLine.trim();
244
+ let sectionMatch = line.match(/^\[(?<name>[^\]]+)\]$/u);
245
+ if (sectionMatch?.groups) {
246
+ currentSection = sectionMatch.groups["name"].toLowerCase();
247
+ continue;
248
+ }
249
+ if (currentSection === "github") {
250
+ let tokenMatch = line.match(/^(?:oauth-token|token)\s*=\s*(?<val>\S[^\n\r]*)$/u);
251
+ if (tokenMatch?.groups?.["val"]) return tokenMatch.groups["val"].trim();
252
+ }
253
+ if (currentSection === "hub") {
254
+ let oauthMatch = line.match(/^oauthtoken\s*=\s*(?<val>\S[^\n\r]*)$/u);
255
+ if (oauthMatch?.groups?.["val"]) return oauthMatch.groups["val"].trim();
256
+ }
257
+ }
258
+ } catch {}
259
+ return void 0;
260
+ }
183
261
  export { Client };
@@ -3,7 +3,7 @@ import { scanWorkflowFile } from "./scan-workflow-file.js";
3
3
  import { scanActionFile } from "./scan-action-file.js";
4
4
  import { isYamlFile } from "./fs/is-yaml-file.js";
5
5
  import { isAbsolute, join, relative, resolve } from "node:path";
6
- import { readdir, stat } from "node:fs/promises";
6
+ import { readFile, readdir, stat } from "node:fs/promises";
7
7
  async function scanGitHubActions(rootPath = process.cwd()) {
8
8
  let result = {
9
9
  compositeActions: /* @__PURE__ */ new Map(),
@@ -111,6 +111,84 @@ async function scanGitHubActions(rootPath = process.cwd()) {
111
111
  }
112
112
  }
113
113
  } catch {}
114
+ try {
115
+ let repoSlug = await getCurrentRepoSlug(normalizedRoot);
116
+ if (repoSlug) {
117
+ if (process.env["ACTIONS_UP_TEST_THROW"] === "1") throw new Error("test");
118
+ let seenCompositeDirectories = /* @__PURE__ */ new Set();
119
+ let queue = [];
120
+ for (let action of result.actions) {
121
+ if (action.type !== "external") continue;
122
+ let segs = action.name.split("/");
123
+ if (segs.length < 3) continue;
124
+ let candidateSlug = `${segs[0]}/${segs[1]}`;
125
+ if (candidateSlug !== repoSlug) continue;
126
+ let compositeDirectory = join(normalizedRoot, ...segs.slice(2));
127
+ if (!isWithin(normalizedRoot, compositeDirectory)) continue;
128
+ if (seenCompositeDirectories.has(compositeDirectory)) continue;
129
+ seenCompositeDirectories.add(compositeDirectory);
130
+ queue.push(compositeDirectory);
131
+ }
132
+ async function processQueue() {
133
+ if (queue.length === 0) return;
134
+ let batch = queue.splice(0);
135
+ let discoveredNext = await Promise.all(batch.map(async (directory) => {
136
+ try {
137
+ let ymlPath = join(directory, "action.yml");
138
+ let yamlPath = join(directory, "action.yaml");
139
+ let filePath = ymlPath;
140
+ try {
141
+ let fileInfo = await stat(ymlPath);
142
+ if (!fileInfo.isFile()) throw new Error("not a file");
143
+ } catch {
144
+ let yamlInfo = await stat(yamlPath);
145
+ if (!yamlInfo.isFile()) throw new Error("not a file");
146
+ filePath = yamlPath;
147
+ }
148
+ let nestedActions = await scanActionFile(filePath);
149
+ if (nestedActions.length > 0) result.actions.push(...nestedActions);
150
+ let nextDirectories = [];
151
+ for (let nestedAction of nestedActions) {
152
+ if (nestedAction.type !== "external") continue;
153
+ let nameSegments = nestedAction.name.split("/");
154
+ if (nameSegments.length < 3) continue;
155
+ let nameSlug = `${nameSegments[0]}/${nameSegments[1]}`;
156
+ if (nameSlug !== repoSlug) continue;
157
+ let nextDirectory = join(normalizedRoot, ...nameSegments.slice(2));
158
+ if (!isWithin(normalizedRoot, nextDirectory)) continue;
159
+ if (seenCompositeDirectories.has(nextDirectory)) continue;
160
+ seenCompositeDirectories.add(nextDirectory);
161
+ nextDirectories.push(nextDirectory);
162
+ }
163
+ return nextDirectories;
164
+ } catch {
165
+ return [];
166
+ }
167
+ }));
168
+ for (let list of discoveredNext) for (let directory of list) queue.push(directory);
169
+ await processQueue();
170
+ }
171
+ await processQueue();
172
+ }
173
+ } catch {}
114
174
  return result;
115
175
  }
176
+ async function getCurrentRepoSlug(root) {
177
+ let environmentSlug = process.env["GITHUB_REPOSITORY"];
178
+ if (environmentSlug && /^[^\s/]+\/[^\s/]+$/u.test(environmentSlug)) return environmentSlug;
179
+ try {
180
+ let gitConfigPath = join(root, ".git", "config");
181
+ let content = await readFile(gitConfigPath, "utf8");
182
+ let originUrlMatch = content.match(/\[remote "origin"\][\s\S]*?url\s*=\s*(?<url>.+)/u);
183
+ let url = originUrlMatch?.groups?.["url"]?.trim();
184
+ if (!url) {
185
+ let anyUrlMatch = content.match(/url\s*=\s*(?<url>.+)/u);
186
+ url = anyUrlMatch?.groups?.["url"]?.trim();
187
+ }
188
+ if (!url) return null;
189
+ let httpsMatch = url.match(/github\.com[/:](?<owner>[^/]+)\/(?<repo>[^./]+)(?:\.git)?$/u);
190
+ if (httpsMatch?.groups) return `${httpsMatch.groups["owner"]}/${httpsMatch.groups["repo"]}`;
191
+ } catch {}
192
+ return null;
193
+ }
116
194
  export { scanGitHubActions };
package/dist/package.js CHANGED
@@ -1,2 +1,2 @@
1
- const version = "1.1.0";
1
+ const version = "1.2.0";
2
2
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "actions-up",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Interactive CLI tool to update GitHub Actions to latest versions with SHA pinning",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -36,7 +36,6 @@
36
36
  "./dist"
37
37
  ],
38
38
  "dependencies": {
39
- "@octokit/rest": "^22.0.0",
40
39
  "cac": "^6.7.14",
41
40
  "enquirer": "^2.4.1",
42
41
  "nanospinner": "^1.2.2",
package/readme.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  Actions Up scans your workflows and composite actions to discover every referenced GitHub Action, then checks for newer releases.
16
16
 
17
- Interactively upgrade and pin actions to exact commit SHAs for secure, reproducible CI and lowfriction maintenance.
17
+ Interactively upgrade and pin actions to exact commit SHAs for secure, reproducible CI and low-friction maintenance.
18
18
 
19
19
  ## Features
20
20
 
@@ -24,6 +24,7 @@ Interactively upgrade and pin actions to exact commit SHAs for secure, reproduci
24
24
  - **Interactive Selection**: Choose which actions to update
25
25
  - **Breaking Changes Detection**: Warns about major version updates
26
26
  - **Fast & Efficient**: Parallel processing with optimized API calls
27
+ - **CI/CD Integration**: Can be used as a GitHub Action for automated PR checks
27
28
 
28
29
  ###
29
30
 
@@ -113,6 +114,371 @@ npx actions-up --yes
113
114
  npx actions-up -y
114
115
  ```
115
116
 
117
+ ### Dry Run Mode
118
+
119
+ Check for updates without making any changes:
120
+
121
+ ```bash
122
+ npx actions-up --dry-run
123
+ ```
124
+
125
+ ## GitHub Actions Integration
126
+
127
+ ### Automated PR Checks
128
+
129
+ You can integrate Actions Up into your CI/CD pipeline to automatically check for outdated actions on every pull request. This helps maintain security and ensures your team stays aware of available updates.
130
+
131
+ <details>
132
+ <summary>Create <code>.github/workflows/check-actions-updates.yml</code>.</summary>
133
+
134
+ ````yaml
135
+ name: Check for outdated GitHub Actions
136
+ on:
137
+ pull_request:
138
+ types: [edited, opened, synchronize, reopened]
139
+
140
+ jobs:
141
+ check-actions:
142
+ name: Check for GHA updates
143
+ runs-on: ubuntu-latest
144
+ steps:
145
+ - name: Checkout repository
146
+ uses: actions/checkout@v4
147
+
148
+ - name: Setup Node.js
149
+ uses: actions/setup-node@v4
150
+ with:
151
+ node-version: '20'
152
+
153
+ - name: Install actions-up
154
+ run: npm install -g actions-up
155
+
156
+ - name: Run actions-up check
157
+ id: actions-check
158
+ run: |
159
+ echo "## GitHub Actions Update Check" >> $GITHUB_STEP_SUMMARY
160
+ echo "" >> $GITHUB_STEP_SUMMARY
161
+
162
+ # Initialize variables
163
+ HAS_UPDATES=false
164
+ UPDATE_COUNT=0
165
+
166
+ # Run actions-up and capture output
167
+ echo "Running actions-up to check for updates..."
168
+ actions-up --dry-run > actions-up-raw.txt 2>&1 || true
169
+
170
+ # Strip ANSI color codes from the output
171
+ sed -i 's/\x1b\[[0-9;]*m//g' actions-up-raw.txt
172
+
173
+ # Also remove any other control characters
174
+ sed -i 's/\x1b\[[0-9;]*[a-zA-Z]//g' actions-up-raw.txt
175
+
176
+ # Parse the output to detect updates
177
+ # Look for patterns like "v3 → v4" or "would be updated"
178
+ if grep -E "(→|would be updated|Update available)" actions-up-raw.txt > /dev/null 2>&1; then
179
+ HAS_UPDATES=true
180
+ # Count the number of updates (lines with arrows)
181
+ UPDATE_COUNT=$(grep -c "→" actions-up-raw.txt || echo "0")
182
+ fi
183
+
184
+ # Create formatted output
185
+ if [ "$HAS_UPDATES" = true ]; then
186
+ echo "Found $UPDATE_COUNT GitHub Actions with available updates" >> $GITHUB_STEP_SUMMARY
187
+ echo "" >> $GITHUB_STEP_SUMMARY
188
+ echo "<details>" >> $GITHUB_STEP_SUMMARY
189
+ echo "<summary>Click to see details</summary>" >> $GITHUB_STEP_SUMMARY
190
+ echo "" >> $GITHUB_STEP_SUMMARY
191
+ echo '```' >> $GITHUB_STEP_SUMMARY
192
+ cat actions-up-raw.txt >> $GITHUB_STEP_SUMMARY
193
+ echo '```' >> $GITHUB_STEP_SUMMARY
194
+ echo "</details>" >> $GITHUB_STEP_SUMMARY
195
+
196
+ # Create detailed markdown report with better formatting
197
+ {
198
+ echo "## GitHub Actions Update Report"
199
+ echo ""
200
+
201
+ # Extract summary information
202
+ TOTAL_ACTIONS=$(grep -oP 'Found \K[0-9]+(?= actions)' actions-up-raw.txt | head -1 || echo "0")
203
+ BREAKING_UPDATES=$(grep -oP '\(([0-9]+) breaking\)' actions-up-raw.txt | grep -oP '[0-9]+' || echo "0")
204
+
205
+ echo "### Summary"
206
+ echo "- **Total actions scanned:** $TOTAL_ACTIONS"
207
+ echo "- **Updates available:** $UPDATE_COUNT"
208
+ if [ "$BREAKING_UPDATES" != "0" ]; then
209
+ echo "- **Breaking changes:** $BREAKING_UPDATES"
210
+ fi
211
+ echo ""
212
+
213
+ echo "### Available Updates"
214
+ echo ""
215
+
216
+ # Format the updates in a table
217
+ echo "| Workflow File | Action | Current | Available | Type | Release Notes |"
218
+ echo "|--------------|--------|---------|-----------|------|---------------|"
219
+
220
+ # Parse each update line
221
+ grep "→" actions-up-raw.txt | while IFS= read -r line; do
222
+ # Extract workflow file path (remove leading path)
223
+ if echo "$line" | grep -q "\.github/workflows/"; then
224
+ PREV_FILE=$(echo "$line" | grep -oP '\.github/workflows/[^:]+' | head -1)
225
+ fi
226
+
227
+ # Skip file path lines, process only action updates
228
+ if echo "$line" | grep -q ": .* → "; then
229
+ # Extract action name and versions
230
+ ACTION=$(echo "$line" | cut -d: -f1 | xargs)
231
+ CURRENT=$(echo "$line" | grep -oP 'v[0-9]+(\.[0-9]+)*' | head -1)
232
+ NEW=$(echo "$line" | grep -oP '→ \Kv[0-9]+(\.[0-9]+)*' | head -1)
233
+
234
+ # Determine if it's a breaking change
235
+ CURRENT_MAJOR=$(echo "$CURRENT" | grep -oP 'v\K[0-9]+' || echo "0")
236
+ NEW_MAJOR=$(echo "$NEW" | grep -oP 'v\K[0-9]+' || echo "0")
237
+
238
+ if [ "$CURRENT_MAJOR" != "$NEW_MAJOR" ]; then
239
+ TYPE="Breaking"
240
+ # Generate release URL
241
+ # Handle both owner/repo and just repo formats
242
+ if echo "$ACTION" | grep -q "/"; then
243
+ REPO_PATH="$ACTION"
244
+ else
245
+ # For actions without owner, assume it's under 'actions' org
246
+ REPO_PATH="actions/$ACTION"
247
+ fi
248
+ RELEASE_URL="https://github.com/${REPO_PATH}/releases/tag/${NEW}"
249
+ RELEASE_LINK="[Release](${RELEASE_URL})"
250
+ else
251
+ TYPE="Minor"
252
+ RELEASE_LINK="-"
253
+ fi
254
+
255
+ # Output table row
256
+ WORKFLOW_NAME=$(basename "$PREV_FILE" 2>/dev/null || echo "workflow.yml")
257
+ echo "| \`$WORKFLOW_NAME\` | $ACTION | $CURRENT | **$NEW** | $TYPE | $RELEASE_LINK |"
258
+ fi
259
+ done
260
+
261
+ echo ""
262
+ echo "### How to Update"
263
+ echo ""
264
+ echo "You have several options to update these actions:"
265
+ echo ""
266
+ echo "#### Option 1: Automatic Update (Recommended)"
267
+ echo '```bash'
268
+ echo "# Run this command locally in your repository"
269
+ echo "npx actions-up"
270
+ echo '```'
271
+ echo ""
272
+ echo "#### Option 2: Manual Update"
273
+ echo "1. Review each update in the table above"
274
+ echo "2. For breaking changes, click the Release Notes link to review changes"
275
+ echo "3. Edit the workflow files and update the version numbers"
276
+ echo "4. Test the changes in your CI/CD pipeline"
277
+ echo ""
278
+ echo "#### Option 3: Selective Update"
279
+ echo '```bash'
280
+ echo "# Update only non-breaking changes"
281
+ echo "npx actions-up --breaking false"
282
+ echo '```'
283
+ echo ""
284
+
285
+ if [ "$BREAKING_UPDATES" != "0" ]; then
286
+ echo "### Breaking Changes Warning"
287
+ echo ""
288
+ echo "This update includes **$BREAKING_UPDATES breaking change(s)**. Please review the release notes before updating:"
289
+ echo ""
290
+ grep "→" actions-up-raw.txt | while IFS= read -r line; do
291
+ if echo "$line" | grep -q ": .* → "; then
292
+ ACTION=$(echo "$line" | cut -d: -f1 | xargs)
293
+ CURRENT=$(echo "$line" | grep -oP 'v[0-9]+' | head -1)
294
+ NEW=$(echo "$line" | grep -oP '→ \Kv[0-9]+(\.[0-9]+)*' | head -1)
295
+ CURRENT_MAJOR=$(echo "$CURRENT" | grep -oP '[0-9]+' || echo "0")
296
+ NEW_MAJOR=$(echo "$NEW" | grep -oP '[0-9]+' || echo "0")
297
+ if [ "$CURRENT_MAJOR" != "$NEW_MAJOR" ]; then
298
+ # Generate release URL
299
+ if echo "$ACTION" | grep -q "/"; then
300
+ REPO_PATH="$ACTION"
301
+ else
302
+ REPO_PATH="actions/$ACTION"
303
+ fi
304
+ RELEASE_URL="https://github.com/${REPO_PATH}/releases/tag/${NEW}"
305
+ echo "- **$ACTION**: $CURRENT → $NEW - [View Release Notes](${RELEASE_URL})"
306
+ fi
307
+ fi
308
+ done
309
+ echo ""
310
+ echo "**Important:** Breaking changes may require modifications to your workflow configuration. Always review the release notes and test thoroughly."
311
+ echo ""
312
+ fi
313
+
314
+ echo "---"
315
+ echo ""
316
+ echo "<details>"
317
+ echo "<summary>Raw actions-up output</summary>"
318
+ echo ""
319
+ echo '```'
320
+ cat actions-up-raw.txt
321
+ echo '```'
322
+ echo "</details>"
323
+ } > actions-up-report.md
324
+
325
+ echo "has-updates=true" >> $GITHUB_OUTPUT
326
+ echo "update-count=$UPDATE_COUNT" >> $GITHUB_OUTPUT
327
+ else
328
+ echo "All GitHub Actions are up to date!" >> $GITHUB_STEP_SUMMARY
329
+
330
+ {
331
+ echo "## GitHub Actions Update Report"
332
+ echo ""
333
+ echo "### All GitHub Actions in this repository are up to date!"
334
+ echo ""
335
+ echo "No action required. Your workflow files are using the latest versions of all GitHub Actions."
336
+ } > actions-up-report.md
337
+
338
+ echo "has-updates=false" >> $GITHUB_OUTPUT
339
+ echo "update-count=0" >> $GITHUB_OUTPUT
340
+ fi
341
+
342
+ - name: Comment PR with updates
343
+ if: github.event_name == 'pull_request'
344
+ uses: actions/github-script@v7
345
+ with:
346
+ script: |
347
+ const fs = require('fs');
348
+ const report = fs.readFileSync('actions-up-report.md', 'utf8');
349
+ const hasUpdates = '${{ steps.actions-check.outputs.has-updates }}' === 'true';
350
+ const updateCount = '${{ steps.actions-check.outputs.update-count }}';
351
+
352
+ // Check if we already commented
353
+ const comments = await github.rest.issues.listComments({
354
+ owner: context.repo.owner,
355
+ repo: context.repo.repo,
356
+ issue_number: context.issue.number
357
+ });
358
+
359
+ const botComment = comments.data.find(comment =>
360
+ comment.user.type === 'Bot' &&
361
+ comment.body.includes('GitHub Actions Update Report')
362
+ );
363
+
364
+ const commentBody = `${report}
365
+
366
+ ---
367
+ *Generated by [actions-up](https://github.com/azat-io/actions-up) | Last check: ${new Date().toISOString()}*`;
368
+
369
+ // Only comment if there are updates or if we previously commented
370
+ if (hasUpdates || botComment) {
371
+ if (botComment) {
372
+ // Update existing comment
373
+ await github.rest.issues.updateComment({
374
+ owner: context.repo.owner,
375
+ repo: context.repo.repo,
376
+ comment_id: botComment.id,
377
+ body: commentBody
378
+ });
379
+ console.log('Updated existing comment');
380
+ } else {
381
+ // Create new comment only if there are updates
382
+ await github.rest.issues.createComment({
383
+ owner: context.repo.owner,
384
+ repo: context.repo.repo,
385
+ issue_number: context.issue.number,
386
+ body: commentBody
387
+ });
388
+ console.log('Created new comment');
389
+ }
390
+ } else {
391
+ console.log('No updates found and no previous comment exists - skipping comment');
392
+ }
393
+
394
+ // Add or update PR labels based on status
395
+ const labels = await github.rest.issues.listLabelsOnIssue({
396
+ owner: context.repo.owner,
397
+ repo: context.repo.repo,
398
+ issue_number: context.issue.number
399
+ });
400
+
401
+ const hasOutdatedLabel = labels.data.some(label => label.name === 'outdated-actions');
402
+
403
+ if (hasUpdates && !hasOutdatedLabel) {
404
+ // Add label if updates are found
405
+ try {
406
+ await github.rest.issues.addLabels({
407
+ owner: context.repo.owner,
408
+ repo: context.repo.repo,
409
+ issue_number: context.issue.number,
410
+ labels: ['outdated-actions']
411
+ });
412
+ console.log('Added outdated-actions label');
413
+ } catch (error) {
414
+ console.log('Could not add label (might not exist in repo):', error.message);
415
+ }
416
+ } else if (!hasUpdates && hasOutdatedLabel) {
417
+ // Remove label if no updates
418
+ try {
419
+ await github.rest.issues.removeLabel({
420
+ owner: context.repo.owner,
421
+ repo: context.repo.repo,
422
+ issue_number: context.issue.number,
423
+ name: 'outdated-actions'
424
+ });
425
+ console.log('Removed outdated-actions label');
426
+ } catch (error) {
427
+ console.log('Could not remove label:', error.message);
428
+ }
429
+ }
430
+
431
+ - name: Fail if outdated actions found
432
+ if: steps.actions-check.outputs.has-updates == 'true'
433
+ run: |
434
+ echo "::error:: Found ${{ steps.actions-check.outputs.update-count }} outdated GitHub Actions. Please update them before merging."
435
+ echo ""
436
+ echo "You can update them by running: npx actions-up"
437
+ echo "Or manually update the versions in your workflow files."
438
+ exit 1
439
+ ````
440
+
441
+ </details>
442
+
443
+ ### Advanced PR Integration with Comments
444
+
445
+ For a more sophisticated integration that comments directly on PRs with detailed update information, check out our [example workflow with PR comments](https://github.com/azat-io/actions-up/blob/main/examples/workflows/check-with-comments.yml).
446
+
447
+ This advanced workflow:
448
+
449
+ - Comments on PRs with a formatted table of available updates
450
+ - Adds labels to PRs with outdated actions
451
+ - Includes links to release notes for breaking changes
452
+ - Updates existing comments instead of creating duplicates
453
+
454
+ ### Scheduled Checks
455
+
456
+ You can also set up scheduled checks to stay informed about updates:
457
+
458
+ ```yaml
459
+ name: Weekly Actions Update Check
460
+
461
+ on:
462
+ schedule:
463
+ - cron: '0 9 * * 1' # Every Monday at 9 AM
464
+ workflow_dispatch: # Allow manual triggers
465
+
466
+ jobs:
467
+ check-updates:
468
+ runs-on: ubuntu-latest
469
+ steps:
470
+ - uses: actions/checkout@v4
471
+ - uses: actions/setup-node@v4
472
+ with:
473
+ node-version: '20'
474
+ - run: npm install -g actions-up
475
+ - run: |
476
+ if actions-up --dry-run | grep -q "→"; then
477
+ echo "Updates available! Run 'npx actions-up' to update."
478
+ exit 1
479
+ fi
480
+ ```
481
+
116
482
  ## Example
117
483
 
118
484
  ```yaml
@@ -136,6 +502,38 @@ While Actions Up works without authentication, providing a GitHub token increase
136
502
  - For public repositories: Select `public_repo` scope
137
503
  - For private repositories: Select `repo` scope
138
504
 
505
+ Set the token as an environment variable:
506
+
507
+ ```bash
508
+ export GITHUB_TOKEN=your_token_here
509
+ npx actions-up
510
+ ```
511
+
512
+ Or in GitHub Actions:
513
+
514
+ ```yaml
515
+ - name: Check for updates
516
+ env:
517
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
518
+ run: npx actions-up --dry-run
519
+ ```
520
+
521
+ ### Command Line Options
522
+
523
+ ```bash
524
+ # Update all actions without prompts
525
+ npx actions-up --yes
526
+
527
+ # Check for updates without making changes
528
+ npx actions-up --dry-run
529
+
530
+ # Update only non-breaking changes
531
+ npx actions-up --breaking false
532
+
533
+ # Specify custom workflow directory
534
+ npx actions-up --workflows ./custom/workflows
535
+ ```
536
+
139
537
  ## Security
140
538
 
141
539
  Actions Up promotes security best practices:
@@ -143,6 +541,16 @@ Actions Up promotes security best practices:
143
541
  - **SHA Pinning**: Uses commit SHA instead of mutable tags
144
542
  - **Version Comments**: Adds version as comment for readability
145
543
  - **No Auto-Updates**: Full control over what gets updated
544
+ - **Breaking Change Warnings**: Alerts you to major version updates that may require configuration changes
545
+
546
+ ## CI/CD Best Practices
547
+
548
+ When using Actions Up in your CI/CD pipeline:
549
+
550
+ 1. **Start with warnings**: Begin by running checks without failing builds to gauge the update frequency
551
+ 2. **Regular updates**: Schedule weekly or monthly update PRs rather than blocking every PR
552
+ 3. **Team education**: Ensure your team understands the security benefits of keeping actions updated
553
+ 4. **Gradual adoption**: Roll out to a few repositories first before organization-wide deployment
146
554
 
147
555
  ## Contributing
148
556
 
@@ -1,8 +0,0 @@
1
- import { CompositeActionStep } from '../../../types/composite-action-step';
2
- /**
3
- * Type guard to check if a value conforms to the CompositeActionStep interface.
4
- *
5
- * @param value - The value to check.
6
- * @returns True if the value is a valid composite action step.
7
- */
8
- export declare function isCompositeActionStep(value: unknown): value is CompositeActionStep;
@@ -1,8 +0,0 @@
1
- import { WorkflowJob } from '../../../types/workflow-job';
2
- /**
3
- * Type guard to check if a value conforms to the WorkflowJob interface.
4
- *
5
- * @param value - The value to check.
6
- * @returns True if the value is a valid workflow job.
7
- */
8
- export declare function isWorkflowJob(value: unknown): value is WorkflowJob;
@@ -1,8 +0,0 @@
1
- import { WorkflowStep } from '../../../types/workflow-step';
2
- /**
3
- * Type guard to check if a value conforms to the WorkflowStep interface.
4
- *
5
- * @param value - The value to check.
6
- * @returns True if the value is a valid workflow step.
7
- */
8
- export declare function isWorkflowStep(value: unknown): value is WorkflowStep;