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.
@@ -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 {
@@ -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 {};
@@ -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
- if (releaseData.target_commitish) try {
35
- let commitResp = await this.makeRequest(`/repos/${owner}/${repo}/commits/${releaseData.target_commitish}`);
36
- let commitData = commitResp.data;
37
- ({sha} = commitData);
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
- date: releaseData.published_at ? new Date(releaseData.published_at) : null,
43
- sha: sha ?? releaseData.target_commitish,
44
- message: releaseData.body ?? null,
45
- tag: displayTag
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.1";
1
+ const version = "1.2.1";
2
2
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "actions-up",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Interactive CLI tool to update GitHub Actions to latest versions with SHA pinning",
5
5
  "keywords": [
6
6
  "github-actions",
package/readme.md CHANGED
@@ -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
 
@@ -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**: Parallel processing with optimized API calls
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;