actions-up 1.1.1 → 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.
- package/dist/core/api/check-updates.js +33 -1
- package/dist/core/api/client.d.ts +17 -0
- package/dist/core/api/client.js +78 -4
- package/dist/core/scan-github-actions.js +79 -1
- package/dist/package.js +1 -1
- package/package.json +1 -1
- package/readme.md +409 -1
- 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) {
|
|
@@ -136,6 +138,34 @@ var Client = class Client {
|
|
|
136
138
|
throw error;
|
|
137
139
|
}
|
|
138
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
|
+
}
|
|
139
169
|
getRateLimitStatus() {
|
|
140
170
|
return {
|
|
141
171
|
remaining: this.rateLimitRemaining,
|
|
@@ -145,14 +175,14 @@ var Client = class Client {
|
|
|
145
175
|
shouldWaitForRateLimit(threshold = 100) {
|
|
146
176
|
return this.rateLimitRemaining < threshold;
|
|
147
177
|
}
|
|
148
|
-
async makeRequest(path, options = {}) {
|
|
178
|
+
async makeRequest(path$1, options = {}) {
|
|
149
179
|
let headers = {
|
|
150
180
|
Accept: "application/vnd.github.v3+json",
|
|
151
181
|
"User-Agent": "actions-up",
|
|
152
182
|
...options.headers
|
|
153
183
|
};
|
|
154
184
|
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
|
155
|
-
let response = await fetch(`${this.baseUrl}${path}`, {
|
|
185
|
+
let response = await fetch(`${this.baseUrl}${path$1}`, {
|
|
156
186
|
...options,
|
|
157
187
|
headers
|
|
158
188
|
});
|
|
@@ -184,4 +214,48 @@ var Client = class Client {
|
|
|
184
214
|
}
|
|
185
215
|
}
|
|
186
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
|
+
}
|
|
187
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
|
+
const version = "1.2.0";
|
|
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
|
|
|
@@ -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;
|