actions-up 1.1.1 → 1.2.1
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/core/api/check-updates.js +33 -1
- package/dist/core/api/client.d.ts +17 -0
- package/dist/core/api/client.js +120 -15
- package/dist/core/scan-github-actions.js +79 -1
- package/dist/package.js +1 -1
- package/package.json +1 -1
- package/readme.md +284 -2
- package/dist/core/schema/composite/is-composite-action-step.d.ts +0 -8
- package/dist/core/schema/workflow/is-workflow-job.d.ts +0 -8
- package/dist/core/schema/workflow/is-workflow-step.d.ts +0 -8
|
@@ -20,19 +20,47 @@ async function checkUpdates(actions, token) {
|
|
|
20
20
|
actionName,
|
|
21
21
|
sha: null
|
|
22
22
|
}];
|
|
23
|
-
let
|
|
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 {
|
|
@@ -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;
|
|
@@ -78,4 +80,19 @@ export declare class Client {
|
|
|
78
80
|
private makeRequest;
|
|
79
81
|
private updateRateLimitInfo;
|
|
80
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;
|
|
81
98
|
export {};
|
package/dist/core/api/client.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
1
4
|
var GitHubRateLimitError = class extends Error {
|
|
2
5
|
constructor(resetAt) {
|
|
3
6
|
let resetTime = resetAt.toLocaleTimeString();
|
|
@@ -11,8 +14,7 @@ var Client = class Client {
|
|
|
11
14
|
rateLimitReset = /* @__PURE__ */ new Date();
|
|
12
15
|
rateLimitRemaining = 60;
|
|
13
16
|
constructor(token) {
|
|
14
|
-
this.token = token ?? process.env["GITHUB_TOKEN"];
|
|
15
|
-
if (!this.token) console.warn("No GitHub token found. API rate limits will be restricted.");
|
|
17
|
+
this.token = token ?? process.env["GITHUB_TOKEN"] ?? resolveGitHubTokenSync();
|
|
16
18
|
this.rateLimitRemaining = this.token ? 5e3 : 60;
|
|
17
19
|
}
|
|
18
20
|
static isRateLimitError(error) {
|
|
@@ -30,19 +32,45 @@ var Client = class Client {
|
|
|
30
32
|
try {
|
|
31
33
|
let releaseResp = await this.makeRequest(`/repos/${owner}/${repo}/releases/tags/${displayTag}`);
|
|
32
34
|
let releaseData = releaseResp.data;
|
|
35
|
+
let date = releaseData.published_at ? new Date(releaseData.published_at) : null;
|
|
36
|
+
let message = releaseData.body ?? null;
|
|
33
37
|
let sha = null;
|
|
34
|
-
|
|
35
|
-
let
|
|
36
|
-
let
|
|
37
|
-
|
|
38
|
+
try {
|
|
39
|
+
let referenceResp = await this.makeRequest(`/repos/${owner}/${repo}/git/refs/tags/${displayTag}`);
|
|
40
|
+
let referenceData = referenceResp.data;
|
|
41
|
+
let objectSha = referenceData.object.sha;
|
|
42
|
+
let objectType = referenceData.object.type;
|
|
43
|
+
if (objectSha && objectType === "tag") try {
|
|
44
|
+
let tagResp = await this.makeRequest(`/repos/${owner}/${repo}/git/tags/${objectSha}`);
|
|
45
|
+
let tagData = tagResp.data;
|
|
46
|
+
let tagObject = tagData.object;
|
|
47
|
+
sha = tagObject?.sha ?? null;
|
|
48
|
+
let taggerDate = tagData.tagger?.date;
|
|
49
|
+
if (!date && taggerDate) date = new Date(taggerDate);
|
|
50
|
+
let tagMessage = tagData.message;
|
|
51
|
+
if (!message && typeof tagMessage === "string") message = tagMessage;
|
|
52
|
+
} catch {
|
|
53
|
+
sha = objectSha;
|
|
54
|
+
}
|
|
55
|
+
else if (objectSha && objectType === "commit") {
|
|
56
|
+
sha = objectSha;
|
|
57
|
+
if (!date || !message) try {
|
|
58
|
+
let commitResp = await this.makeRequest(`/repos/${owner}/${repo}/git/commits/${objectSha}`);
|
|
59
|
+
let commitData = commitResp.data;
|
|
60
|
+
let { message: commitMessage } = commitData;
|
|
61
|
+
if (!message && typeof commitMessage === "string") message = commitMessage;
|
|
62
|
+
let authorDate = commitData.author?.date;
|
|
63
|
+
if (!date && authorDate) date = new Date(authorDate);
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
38
66
|
} catch {
|
|
39
|
-
sha = releaseData.target_commitish;
|
|
67
|
+
if (isLikelySha(releaseData.target_commitish)) sha = releaseData.target_commitish;
|
|
40
68
|
}
|
|
41
69
|
return {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
tag: displayTag,
|
|
71
|
+
message,
|
|
72
|
+
date,
|
|
73
|
+
sha
|
|
46
74
|
};
|
|
47
75
|
} catch (releaseError) {
|
|
48
76
|
if (releaseError && typeof releaseError === "object" && "status" in releaseError && releaseError.status === 404) try {
|
|
@@ -92,7 +120,7 @@ var Client = class Client {
|
|
|
92
120
|
let tagInfo = await this.getTagInfo(owner, repo, release.tag_name);
|
|
93
121
|
if (tagInfo) ({sha} = tagInfo);
|
|
94
122
|
} catch {
|
|
95
|
-
sha = release.target_commitish;
|
|
123
|
+
sha = isLikelySha(release.target_commitish) ? release.target_commitish : null;
|
|
96
124
|
}
|
|
97
125
|
releaseInfos.push({
|
|
98
126
|
publishedAt: new Date(release.published_at),
|
|
@@ -119,7 +147,7 @@ var Client = class Client {
|
|
|
119
147
|
let tagInfo = await this.getTagInfo(owner, repo, release.tag_name);
|
|
120
148
|
if (tagInfo) ({sha} = tagInfo);
|
|
121
149
|
} catch {
|
|
122
|
-
sha = release.target_commitish;
|
|
150
|
+
sha = isLikelySha(release.target_commitish) ? release.target_commitish : null;
|
|
123
151
|
}
|
|
124
152
|
return {
|
|
125
153
|
publishedAt: new Date(release.published_at),
|
|
@@ -136,6 +164,34 @@ var Client = class Client {
|
|
|
136
164
|
throw error;
|
|
137
165
|
}
|
|
138
166
|
}
|
|
167
|
+
async getAllTags(owner, repo, limit = 30) {
|
|
168
|
+
try {
|
|
169
|
+
let tagsResp = await this.makeRequest(`/repos/${owner}/${repo}/tags?per_page=${limit}`);
|
|
170
|
+
let tags = tagsResp.data;
|
|
171
|
+
return tags.map((tag) => ({
|
|
172
|
+
sha: tag.commit.sha,
|
|
173
|
+
tag: tag.name,
|
|
174
|
+
message: null,
|
|
175
|
+
date: null
|
|
176
|
+
}));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (Client.isRateLimitError(error)) throw new GitHubRateLimitError(this.rateLimitReset);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async getRefType(owner, repo, reference) {
|
|
183
|
+
try {
|
|
184
|
+
await this.makeRequest(`/repos/${owner}/${repo}/git/refs/tags/${reference}`);
|
|
185
|
+
return "tag";
|
|
186
|
+
} catch {
|
|
187
|
+
try {
|
|
188
|
+
await this.makeRequest(`/repos/${owner}/${repo}/git/refs/heads/${reference}`);
|
|
189
|
+
return "branch";
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
139
195
|
getRateLimitStatus() {
|
|
140
196
|
return {
|
|
141
197
|
remaining: this.rateLimitRemaining,
|
|
@@ -145,14 +201,14 @@ var Client = class Client {
|
|
|
145
201
|
shouldWaitForRateLimit(threshold = 100) {
|
|
146
202
|
return this.rateLimitRemaining < threshold;
|
|
147
203
|
}
|
|
148
|
-
async makeRequest(path, options = {}) {
|
|
204
|
+
async makeRequest(path$1, options = {}) {
|
|
149
205
|
let headers = {
|
|
150
206
|
Accept: "application/vnd.github.v3+json",
|
|
151
207
|
"User-Agent": "actions-up",
|
|
152
208
|
...options.headers
|
|
153
209
|
};
|
|
154
210
|
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
|
155
|
-
let response = await fetch(`${this.baseUrl}${path}`, {
|
|
211
|
+
let response = await fetch(`${this.baseUrl}${path$1}`, {
|
|
156
212
|
...options,
|
|
157
213
|
headers
|
|
158
214
|
});
|
|
@@ -184,4 +240,53 @@ var Client = class Client {
|
|
|
184
240
|
}
|
|
185
241
|
}
|
|
186
242
|
};
|
|
243
|
+
function resolveGitHubTokenSync() {
|
|
244
|
+
let fromGithubToken = process.env["GITHUB_TOKEN"];
|
|
245
|
+
if (fromGithubToken && fromGithubToken.trim() !== "") return fromGithubToken.trim();
|
|
246
|
+
let fromGhToken = process.env["GH_TOKEN"];
|
|
247
|
+
if (fromGhToken && fromGhToken.trim() !== "") return fromGhToken.trim();
|
|
248
|
+
try {
|
|
249
|
+
let output = execFileSync("gh", ["auth", "token"], {
|
|
250
|
+
stdio: [
|
|
251
|
+
"ignore",
|
|
252
|
+
"pipe",
|
|
253
|
+
"ignore"
|
|
254
|
+
],
|
|
255
|
+
encoding: "utf8",
|
|
256
|
+
timeout: 500
|
|
257
|
+
});
|
|
258
|
+
let token = output.trim();
|
|
259
|
+
if (token) return token;
|
|
260
|
+
} catch {}
|
|
261
|
+
try {
|
|
262
|
+
let gitConfigPath = join(process.cwd(), ".git", "config");
|
|
263
|
+
let content = readFileSync(gitConfigPath, "utf8");
|
|
264
|
+
let directMatch = content.match(/^\s*(?:github\.(?:oauth-token|token)|hub\.oauthtoken)\s*=\s*(?<token>\S[^\n\r]*)$/mu);
|
|
265
|
+
let directToken = directMatch?.groups?.["token"]?.trim();
|
|
266
|
+
if (directToken) return directToken;
|
|
267
|
+
let currentSection = null;
|
|
268
|
+
for (let rawLine of content.split(/\r?\n/u)) {
|
|
269
|
+
let line = rawLine.trim();
|
|
270
|
+
let sectionMatch = line.match(/^\[(?<name>[^\]]+)\]$/u);
|
|
271
|
+
if (sectionMatch?.groups) {
|
|
272
|
+
currentSection = sectionMatch.groups["name"].toLowerCase();
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (currentSection === "github") {
|
|
276
|
+
let tokenMatch = line.match(/^(?:oauth-token|token)\s*=\s*(?<val>\S[^\n\r]*)$/u);
|
|
277
|
+
if (tokenMatch?.groups?.["val"]) return tokenMatch.groups["val"].trim();
|
|
278
|
+
}
|
|
279
|
+
if (currentSection === "hub") {
|
|
280
|
+
let oauthMatch = line.match(/^oauthtoken\s*=\s*(?<val>\S[^\n\r]*)$/u);
|
|
281
|
+
if (oauthMatch?.groups?.["val"]) return oauthMatch.groups["val"].trim();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} catch {}
|
|
285
|
+
return void 0;
|
|
286
|
+
}
|
|
287
|
+
function isLikelySha(value) {
|
|
288
|
+
if (typeof value !== "string" || value.trim() === "") return false;
|
|
289
|
+
let normalized = value.replace(/^v/u, "");
|
|
290
|
+
return /^[0-9a-f]{7,40}$/iu.test(normalized);
|
|
291
|
+
}
|
|
187
292
|
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
|
+
const version = "1.2.1";
|
|
2
2
|
export { version };
|
package/package.json
CHANGED
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 low
|
|
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
|
|
|
@@ -23,7 +23,8 @@ Interactively upgrade and pin actions to exact commit SHAs for secure, reproduci
|
|
|
23
23
|
- **Batch Updates**: Update multiple actions at once
|
|
24
24
|
- **Interactive Selection**: Choose which actions to update
|
|
25
25
|
- **Breaking Changes Detection**: Warns about major version updates
|
|
26
|
-
- **Fast & Efficient**:
|
|
26
|
+
- **Fast & Efficient**: Optimized API usage with deduped lookups
|
|
27
|
+
- **CI/CD Integration**: Use in GitHub Actions workflows for automated PR checks
|
|
27
28
|
|
|
28
29
|
###
|
|
29
30
|
|
|
@@ -113,6 +114,261 @@ 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
|
+
# Parse the output to detect updates
|
|
171
|
+
if grep -q "→" actions-up-raw.txt; then
|
|
172
|
+
HAS_UPDATES=true
|
|
173
|
+
# Count the number of updates (lines with arrows)
|
|
174
|
+
UPDATE_COUNT=$(grep -c "→" actions-up-raw.txt || echo "0")
|
|
175
|
+
fi
|
|
176
|
+
|
|
177
|
+
# Create formatted output
|
|
178
|
+
if [ "$HAS_UPDATES" = true ]; then
|
|
179
|
+
echo "Found $UPDATE_COUNT GitHub Actions with available updates" >> $GITHUB_STEP_SUMMARY
|
|
180
|
+
echo "" >> $GITHUB_STEP_SUMMARY
|
|
181
|
+
echo "<details>" >> $GITHUB_STEP_SUMMARY
|
|
182
|
+
echo "<summary>Click to see details</summary>" >> $GITHUB_STEP_SUMMARY
|
|
183
|
+
echo "" >> $GITHUB_STEP_SUMMARY
|
|
184
|
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
|
185
|
+
cat actions-up-raw.txt >> $GITHUB_STEP_SUMMARY
|
|
186
|
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
|
187
|
+
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
|
188
|
+
|
|
189
|
+
# Create detailed markdown report with better formatting
|
|
190
|
+
{
|
|
191
|
+
echo "## GitHub Actions Update Report"
|
|
192
|
+
echo ""
|
|
193
|
+
|
|
194
|
+
echo "### Summary"
|
|
195
|
+
echo "- **Updates available:** $UPDATE_COUNT"
|
|
196
|
+
echo ""
|
|
197
|
+
|
|
198
|
+
# See the raw output above for details.
|
|
199
|
+
echo "### How to Update"
|
|
200
|
+
echo ""
|
|
201
|
+
echo "You have several options to update these actions:"
|
|
202
|
+
echo ""
|
|
203
|
+
echo "#### Option 1: Automatic Update (Recommended)"
|
|
204
|
+
echo '```bash'
|
|
205
|
+
echo "# Run this command locally in your repository"
|
|
206
|
+
echo "npx actions-up"
|
|
207
|
+
echo '```'
|
|
208
|
+
echo ""
|
|
209
|
+
echo "#### Option 2: Manual Update"
|
|
210
|
+
echo "1. Review each update in the table above"
|
|
211
|
+
echo "2. For breaking changes, click the Release Notes link to review changes"
|
|
212
|
+
echo "3. Edit the workflow files and update the version numbers"
|
|
213
|
+
echo "4. Test the changes in your CI/CD pipeline"
|
|
214
|
+
echo ""
|
|
215
|
+
echo "---"
|
|
216
|
+
echo ""
|
|
217
|
+
echo "<details>"
|
|
218
|
+
echo "<summary>Raw actions-up output</summary>"
|
|
219
|
+
echo ""
|
|
220
|
+
echo '```'
|
|
221
|
+
cat actions-up-raw.txt
|
|
222
|
+
echo '```'
|
|
223
|
+
echo "</details>"
|
|
224
|
+
} > actions-up-report.md
|
|
225
|
+
|
|
226
|
+
echo "has-updates=true" >> $GITHUB_OUTPUT
|
|
227
|
+
echo "update-count=$UPDATE_COUNT" >> $GITHUB_OUTPUT
|
|
228
|
+
else
|
|
229
|
+
echo "All GitHub Actions are up to date!" >> $GITHUB_STEP_SUMMARY
|
|
230
|
+
|
|
231
|
+
{
|
|
232
|
+
echo "## GitHub Actions Update Report"
|
|
233
|
+
echo ""
|
|
234
|
+
echo "### All GitHub Actions in this repository are up to date!"
|
|
235
|
+
echo ""
|
|
236
|
+
echo "No action required. Your workflow files are using the latest versions of all GitHub Actions."
|
|
237
|
+
} > actions-up-report.md
|
|
238
|
+
|
|
239
|
+
echo "has-updates=false" >> $GITHUB_OUTPUT
|
|
240
|
+
echo "update-count=0" >> $GITHUB_OUTPUT
|
|
241
|
+
fi
|
|
242
|
+
|
|
243
|
+
- name: Comment PR with updates
|
|
244
|
+
if: github.event_name == 'pull_request'
|
|
245
|
+
uses: actions/github-script@v7
|
|
246
|
+
with:
|
|
247
|
+
script: |
|
|
248
|
+
const fs = require('fs');
|
|
249
|
+
const report = fs.readFileSync('actions-up-report.md', 'utf8');
|
|
250
|
+
const hasUpdates = '${{ steps.actions-check.outputs.has-updates }}' === 'true';
|
|
251
|
+
const updateCount = '${{ steps.actions-check.outputs.update-count }}';
|
|
252
|
+
|
|
253
|
+
// Check if we already commented
|
|
254
|
+
const comments = await github.rest.issues.listComments({
|
|
255
|
+
owner: context.repo.owner,
|
|
256
|
+
repo: context.repo.repo,
|
|
257
|
+
issue_number: context.issue.number
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const botComment = comments.data.find(comment =>
|
|
261
|
+
comment.user.type === 'Bot' &&
|
|
262
|
+
comment.body.includes('GitHub Actions Update Report')
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const commentBody = `${report}
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
*Generated by [actions-up](https://github.com/azat-io/actions-up) | Last check: ${new Date().toISOString()}*`;
|
|
269
|
+
|
|
270
|
+
// Only comment if there are updates or if we previously commented
|
|
271
|
+
if (hasUpdates || botComment) {
|
|
272
|
+
if (botComment) {
|
|
273
|
+
// Update existing comment
|
|
274
|
+
await github.rest.issues.updateComment({
|
|
275
|
+
owner: context.repo.owner,
|
|
276
|
+
repo: context.repo.repo,
|
|
277
|
+
comment_id: botComment.id,
|
|
278
|
+
body: commentBody
|
|
279
|
+
});
|
|
280
|
+
console.log('Updated existing comment');
|
|
281
|
+
} else {
|
|
282
|
+
// Create new comment only if there are updates
|
|
283
|
+
await github.rest.issues.createComment({
|
|
284
|
+
owner: context.repo.owner,
|
|
285
|
+
repo: context.repo.repo,
|
|
286
|
+
issue_number: context.issue.number,
|
|
287
|
+
body: commentBody
|
|
288
|
+
});
|
|
289
|
+
console.log('Created new comment');
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
console.log('No updates found and no previous comment exists - skipping comment');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Add or update PR labels based on status
|
|
296
|
+
const labels = await github.rest.issues.listLabelsOnIssue({
|
|
297
|
+
owner: context.repo.owner,
|
|
298
|
+
repo: context.repo.repo,
|
|
299
|
+
issue_number: context.issue.number
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const hasOutdatedLabel = labels.data.some(label => label.name === 'outdated-actions');
|
|
303
|
+
|
|
304
|
+
if (hasUpdates && !hasOutdatedLabel) {
|
|
305
|
+
// Add label if updates are found
|
|
306
|
+
try {
|
|
307
|
+
await github.rest.issues.addLabels({
|
|
308
|
+
owner: context.repo.owner,
|
|
309
|
+
repo: context.repo.repo,
|
|
310
|
+
issue_number: context.issue.number,
|
|
311
|
+
labels: ['outdated-actions']
|
|
312
|
+
});
|
|
313
|
+
console.log('Added outdated-actions label');
|
|
314
|
+
} catch (error) {
|
|
315
|
+
console.log('Could not add label (might not exist in repo):', error.message);
|
|
316
|
+
}
|
|
317
|
+
} else if (!hasUpdates && hasOutdatedLabel) {
|
|
318
|
+
// Remove label if no updates
|
|
319
|
+
try {
|
|
320
|
+
await github.rest.issues.removeLabel({
|
|
321
|
+
owner: context.repo.owner,
|
|
322
|
+
repo: context.repo.repo,
|
|
323
|
+
issue_number: context.issue.number,
|
|
324
|
+
name: 'outdated-actions'
|
|
325
|
+
});
|
|
326
|
+
console.log('Removed outdated-actions label');
|
|
327
|
+
} catch (error) {
|
|
328
|
+
console.log('Could not remove label:', error.message);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
- name: Fail if outdated actions found
|
|
333
|
+
if: steps.actions-check.outputs.has-updates == 'true'
|
|
334
|
+
run: |
|
|
335
|
+
echo "::error:: Found ${{ steps.actions-check.outputs.update-count }} outdated GitHub Actions. Please update them before merging."
|
|
336
|
+
echo ""
|
|
337
|
+
echo "You can update them by running: npx actions-up"
|
|
338
|
+
echo "Or manually update the versions in your workflow files."
|
|
339
|
+
exit 1
|
|
340
|
+
````
|
|
341
|
+
|
|
342
|
+
</details>
|
|
343
|
+
|
|
344
|
+
### Scheduled Checks
|
|
345
|
+
|
|
346
|
+
You can also set up scheduled checks to stay informed about updates:
|
|
347
|
+
|
|
348
|
+
```yaml
|
|
349
|
+
name: Weekly Actions Update Check
|
|
350
|
+
|
|
351
|
+
on:
|
|
352
|
+
schedule:
|
|
353
|
+
- cron: '0 9 * * 1' # Every Monday at 9 AM
|
|
354
|
+
workflow_dispatch: # Allow manual triggers
|
|
355
|
+
|
|
356
|
+
jobs:
|
|
357
|
+
check-updates:
|
|
358
|
+
runs-on: ubuntu-latest
|
|
359
|
+
steps:
|
|
360
|
+
- uses: actions/checkout@v4
|
|
361
|
+
- uses: actions/setup-node@v4
|
|
362
|
+
with:
|
|
363
|
+
node-version: '20'
|
|
364
|
+
- run: npm install -g actions-up
|
|
365
|
+
- run: |
|
|
366
|
+
if actions-up --dry-run | grep -q "→"; then
|
|
367
|
+
echo "Updates available! Run 'npx actions-up' to update."
|
|
368
|
+
exit 1
|
|
369
|
+
fi
|
|
370
|
+
```
|
|
371
|
+
|
|
116
372
|
## Example
|
|
117
373
|
|
|
118
374
|
```yaml
|
|
@@ -136,6 +392,22 @@ While Actions Up works without authentication, providing a GitHub token increase
|
|
|
136
392
|
- For public repositories: Select `public_repo` scope
|
|
137
393
|
- For private repositories: Select `repo` scope
|
|
138
394
|
|
|
395
|
+
Set the token as an environment variable:
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
export GITHUB_TOKEN=your_token_here
|
|
399
|
+
npx actions-up
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Or in GitHub Actions:
|
|
403
|
+
|
|
404
|
+
```yaml
|
|
405
|
+
- name: Check for updates
|
|
406
|
+
env:
|
|
407
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
408
|
+
run: npx actions-up --dry-run
|
|
409
|
+
```
|
|
410
|
+
|
|
139
411
|
## Security
|
|
140
412
|
|
|
141
413
|
Actions Up promotes security best practices:
|
|
@@ -143,6 +415,16 @@ Actions Up promotes security best practices:
|
|
|
143
415
|
- **SHA Pinning**: Uses commit SHA instead of mutable tags
|
|
144
416
|
- **Version Comments**: Adds version as comment for readability
|
|
145
417
|
- **No Auto-Updates**: Full control over what gets updated
|
|
418
|
+
- **Breaking Change Warnings**: Alerts you to major version updates that may require configuration changes
|
|
419
|
+
|
|
420
|
+
## CI/CD Best Practices
|
|
421
|
+
|
|
422
|
+
When using Actions Up in your CI/CD pipeline:
|
|
423
|
+
|
|
424
|
+
1. **Start with warnings**: Begin by running checks without failing builds to gauge the update frequency
|
|
425
|
+
2. **Regular updates**: Schedule weekly or monthly update PRs rather than blocking every PR
|
|
426
|
+
3. **Team education**: Ensure your team understands the security benefits of keeping actions updated
|
|
427
|
+
4. **Gradual adoption**: Roll out to a few repositories first before organization-wide deployment
|
|
146
428
|
|
|
147
429
|
## Contributing
|
|
148
430
|
|
|
@@ -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;
|