actions-up 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -10,7 +10,7 @@ import pc from "picocolors";
10
10
  import cac from "cac";
11
11
  function run() {
12
12
  let cli = cac("actions-up");
13
- cli.help().version(version).option("--yes, -y", "Skip all confirmations").command("", "Update GitHub Actions").action(async (options) => {
13
+ cli.help().version(version).option("--yes, -y", "Skip all confirmations").option("--dry-run", "Preview changes without applying them").command("", "Update GitHub Actions").action(async (options) => {
14
14
  console.info(pc.cyan("\nšŸš€ Actions Up!\n"));
15
15
  let spinner = createSpinner("Scanning GitHub Actions...").start();
16
16
  try {
@@ -33,6 +33,12 @@ function run() {
33
33
  return;
34
34
  }
35
35
  spinner.success(`Found ${pc.yellow(outdated.length)} updates available${breaking.length > 0 ? ` (${pc.red(breaking.length)} breaking)` : ""}`);
36
+ if (options.dryRun) {
37
+ console.info(pc.yellow("\nšŸ“‹ Dry Run - No changes will be made\n"));
38
+ for (let update of outdated) console.info(`${pc.cyan(update.action.file ?? "unknown")}:\n${update.action.name}: ${pc.red(update.currentVersion)} → ${pc.green(update.latestVersion)} ${update.latestSha ? pc.gray(`(${update.latestSha.slice(0, 7)})`) : ""}\n`);
39
+ console.info(pc.gray(`\n${outdated.length} actions would be updated\n`));
40
+ return;
41
+ }
36
42
  if (options.yes) {
37
43
  let toUpdate = outdated.filter((update) => update.latestSha);
38
44
  if (toUpdate.length === 0) {
@@ -69,7 +69,7 @@ async function checkUpdates(actions, token) {
69
69
  }
70
70
  }), Promise.resolve([]));
71
71
  if (sharedState.rateLimitError) {
72
- let error = /* @__PURE__ */ new Error("GitHub API rate limit exceeded. Please set GITHUB_TOKEN environment variable to increase the limit.\nSee: https://github.com/azat-io/actions-up?tab=readme-ov-file#with-github-token");
72
+ let error = /* @__PURE__ */ new Error("GitHub API rate limit exceeded. Please set GITHUB_TOKEN environment variable to increase the limit.\nSee: https://github.com/azat-io/actions-up?tab=readme-ov-file#using-github-token-for-higher-rate-limits");
73
73
  error.name = "GitHubRateLimitError";
74
74
  throw error;
75
75
  }
@@ -26,11 +26,11 @@ interface TagInfo {
26
26
  /** Git commit SHA that this tag points to. */
27
27
  sha: string;
28
28
  }
29
- /** GitHub GraphQL client with optional authentication. */
29
+ /** GitHub REST API client with optional authentication. */
30
30
  export declare class Client {
31
- private readonly graphqlWithAuth;
32
- private rateLimitRemaining;
33
31
  private rateLimitReset;
32
+ private rateLimitRemaining;
33
+ private readonly octokit;
34
34
  /**
35
35
  * Creates a new GitHub API client.
36
36
  *
@@ -75,5 +75,6 @@ export declare class Client {
75
75
  * @returns True if rate limit is below threshold.
76
76
  */
77
77
  shouldWaitForRateLimit(threshold?: number): boolean;
78
+ private updateRateLimitInfo;
78
79
  }
79
80
  export {};
@@ -1,4 +1,4 @@
1
- import { GraphqlResponseError, graphql } from "@octokit/graphql";
1
+ import { Octokit } from "@octokit/rest";
2
2
  var GitHubRateLimitError = class extends Error {
3
3
  constructor(resetAt) {
4
4
  let resetTime = resetAt.toLocaleTimeString();
@@ -7,73 +7,93 @@ var GitHubRateLimitError = class extends Error {
7
7
  }
8
8
  };
9
9
  var Client = class Client {
10
- graphqlWithAuth;
11
- rateLimitRemaining = 5e3;
12
10
  rateLimitReset = /* @__PURE__ */ new Date();
11
+ rateLimitRemaining = 60;
12
+ octokit;
13
13
  constructor(token) {
14
14
  let authToken = token ?? process.env["GITHUB_TOKEN"];
15
- this.graphqlWithAuth = graphql.defaults({ headers: authToken ? { authorization: `token ${authToken}` } : {} });
15
+ this.octokit = new Octokit({ auth: authToken ?? void 0 });
16
16
  if (!authToken) console.warn("No GitHub token found. API rate limits will be restricted.");
17
+ this.rateLimitRemaining = authToken ? 5e3 : 60;
17
18
  }
18
19
  static isRateLimitError(error) {
19
- if (error instanceof GraphqlResponseError) return error.errors.some((graphQLError) => graphQLError.type === "RATE_LIMITED" || typeof graphQLError.message === "string" && /rate limit/iu.test(graphQLError.message));
20
- if (error instanceof Error) return /rate limit/iu.test(error.message);
20
+ if (error && typeof error === "object") {
21
+ let maybeAny = error;
22
+ let message = typeof maybeAny.message === "string" ? maybeAny.message.toLowerCase() : "";
23
+ let status = typeof maybeAny.status === "number" ? maybeAny.status : void 0;
24
+ return message.includes("rate limit") || message.includes("api rate limit") || status === 403;
25
+ }
21
26
  return false;
22
27
  }
23
28
  async getTagInfo(owner, repo, tag) {
24
29
  try {
25
- let qualifiedTag = tag.startsWith("refs/tags/") ? tag : `refs/tags/${tag}`;
26
30
  let displayTag = tag.replace(/^refs\/tags\//u, "");
27
- let query = `
28
- query getTagInfo($owner: String!, $repo: String!, $tag: String!) {
29
- repository(owner: $owner, name: $repo) {
30
- ref(qualifiedName: $tag) {
31
- target {
32
- oid
33
- ... on Commit {
34
- committedDate
35
- message
36
- }
37
- ... on Tag {
38
- tagger {
39
- date
40
- }
41
- message
42
- target {
43
- oid
44
- }
45
- }
46
- }
47
- }
48
- }
49
- rateLimit {
50
- remaining
51
- resetAt
52
- }
53
- }
54
- `;
55
- let response = await this.graphqlWithAuth(query, {
56
- tag: qualifiedTag,
57
- owner,
58
- repo
59
- });
60
- this.rateLimitRemaining = response.rateLimit.remaining;
61
- this.rateLimitReset = new Date(response.rateLimit.resetAt);
62
- if (!response.repository?.ref?.target) return null;
63
- let { target } = response.repository.ref;
64
- if ("committedDate" in target) return {
65
- date: target.committedDate ? new Date(target.committedDate) : null,
66
- message: target.message || null,
67
- sha: target.oid,
68
- tag: displayTag
69
- };
70
- let tagObject = target.target;
71
- return {
72
- date: target.tagger?.date ? new Date(target.tagger.date) : null,
73
- sha: tagObject?.oid ?? target.oid,
74
- message: target.message ?? null,
75
- tag: displayTag
76
- };
31
+ try {
32
+ let { headers: releaseHeaders, data: releaseData } = await this.octokit.repos.getReleaseByTag({
33
+ tag: displayTag,
34
+ owner,
35
+ repo
36
+ });
37
+ this.updateRateLimitInfo(releaseHeaders);
38
+ let sha = null;
39
+ if (releaseData.target_commitish) try {
40
+ let { data: commitData } = await this.octokit.repos.getCommit({
41
+ ref: releaseData.target_commitish,
42
+ owner,
43
+ repo
44
+ });
45
+ ({sha} = commitData);
46
+ } catch {
47
+ sha = releaseData.target_commitish;
48
+ }
49
+ return {
50
+ date: releaseData.published_at ? new Date(releaseData.published_at) : null,
51
+ sha: sha ?? releaseData.target_commitish,
52
+ message: releaseData.body ?? null,
53
+ tag: displayTag
54
+ };
55
+ } catch (releaseError) {
56
+ if (releaseError instanceof Error && "status" in releaseError && releaseError.status === 404) try {
57
+ let { headers: referenceHeaders, data: referenceData } = await this.octokit.git.getRef({
58
+ ref: `tags/${displayTag}`,
59
+ owner,
60
+ repo
61
+ });
62
+ this.updateRateLimitInfo(referenceHeaders);
63
+ let { sha } = referenceData.object;
64
+ let message = null;
65
+ let date = null;
66
+ if (referenceData.object.type === "tag") try {
67
+ let { data: tagData } = await this.octokit.git.getTag({
68
+ tag_sha: sha,
69
+ owner,
70
+ repo
71
+ });
72
+ ({sha} = tagData.object);
73
+ message = tagData.message || null;
74
+ date = tagData.tagger.date ? new Date(tagData.tagger.date) : null;
75
+ } catch {}
76
+ else if (referenceData.object.type === "commit") try {
77
+ let { data: commitData } = await this.octokit.git.getCommit({
78
+ commit_sha: sha,
79
+ owner,
80
+ repo
81
+ });
82
+ ({message} = commitData);
83
+ date = commitData.author.date ? new Date(commitData.author.date) : null;
84
+ } catch {}
85
+ return {
86
+ tag: displayTag,
87
+ message,
88
+ date,
89
+ sha
90
+ };
91
+ } catch (tagError) {
92
+ if (tagError instanceof Error && "status" in tagError && tagError.status === 404) return null;
93
+ throw tagError;
94
+ }
95
+ throw releaseError;
96
+ }
77
97
  } catch (error) {
78
98
  if (Client.isRateLimitError(error)) throw new GitHubRateLimitError(this.rateLimitReset);
79
99
  throw error;
@@ -81,49 +101,32 @@ var Client = class Client {
81
101
  }
82
102
  async getAllReleases(owner, repo, limit = 10) {
83
103
  try {
84
- let query = `
85
- query getAllReleases($owner: String!, $repo: String!, $limit: Int!) {
86
- repository(owner: $owner, name: $repo) {
87
- releases(
88
- first: $limit
89
- orderBy: { field: CREATED_AT, direction: DESC }
90
- ) {
91
- nodes {
92
- tagName
93
- tagCommit {
94
- oid
95
- }
96
- name
97
- description
98
- isPrerelease
99
- publishedAt
100
- url
101
- }
102
- }
103
- }
104
- rateLimit {
105
- remaining
106
- resetAt
107
- }
108
- }
109
- `;
110
- let response = await this.graphqlWithAuth(query, {
104
+ let { data: releases, headers } = await this.octokit.repos.listReleases({
105
+ per_page: limit,
111
106
  owner,
112
- limit,
113
107
  repo
114
108
  });
115
- this.rateLimitRemaining = response.rateLimit.remaining;
116
- this.rateLimitReset = new Date(response.rateLimit.resetAt);
117
- if (!response.repository?.releases?.nodes) return [];
118
- return response.repository.releases.nodes.map((release) => ({
119
- sha: release.tagCommit?.oid ?? null,
120
- publishedAt: new Date(release.publishedAt),
121
- description: release.description ?? null,
122
- name: release.name ?? release.tagName,
123
- isPrerelease: release.isPrerelease,
124
- url: release.url,
125
- version: release.tagName
109
+ this.updateRateLimitInfo(headers);
110
+ let releaseInfos = [];
111
+ await Promise.all(releases.map(async (release) => {
112
+ let sha = null;
113
+ if (release.tag_name) try {
114
+ let tagInfo = await this.getTagInfo(owner, repo, release.tag_name);
115
+ if (tagInfo) ({sha} = tagInfo);
116
+ } catch {
117
+ sha = release.target_commitish || null;
118
+ }
119
+ releaseInfos.push({
120
+ publishedAt: new Date(release.published_at),
121
+ name: release.name ?? release.tag_name,
122
+ description: release.body ?? null,
123
+ isPrerelease: release.prerelease,
124
+ version: release.tag_name,
125
+ url: release.html_url,
126
+ sha
127
+ });
126
128
  }));
129
+ return releaseInfos;
127
130
  } catch (error) {
128
131
  if (Client.isRateLimitError(error)) throw new GitHubRateLimitError(this.rateLimitReset);
129
132
  throw error;
@@ -131,45 +134,29 @@ var Client = class Client {
131
134
  }
132
135
  async getLatestRelease(owner, repo) {
133
136
  try {
134
- let query = `
135
- query getLatestRelease($owner: String!, $repo: String!) {
136
- repository(owner: $owner, name: $repo) {
137
- latestRelease {
138
- tagName
139
- tagCommit {
140
- oid
141
- }
142
- name
143
- description
144
- isPrerelease
145
- publishedAt
146
- url
147
- }
148
- }
149
- rateLimit {
150
- remaining
151
- resetAt
152
- }
153
- }
154
- `;
155
- let response = await this.graphqlWithAuth(query, {
137
+ let { data: release, headers } = await this.octokit.repos.getLatestRelease({
156
138
  owner,
157
139
  repo
158
140
  });
159
- this.rateLimitRemaining = response.rateLimit.remaining;
160
- this.rateLimitReset = new Date(response.rateLimit.resetAt);
161
- if (!response.repository?.latestRelease) return null;
162
- let release = response.repository.latestRelease;
141
+ this.updateRateLimitInfo(headers);
142
+ let sha = null;
143
+ if (release.tag_name) try {
144
+ let tagInfo = await this.getTagInfo(owner, repo, release.tag_name);
145
+ if (tagInfo) ({sha} = tagInfo);
146
+ } catch {
147
+ sha = release.target_commitish || null;
148
+ }
163
149
  return {
164
- sha: release.tagCommit?.oid ?? null,
165
- publishedAt: new Date(release.publishedAt),
166
- description: release.description ?? null,
167
- name: release.name ?? release.tagName,
168
- isPrerelease: release.isPrerelease,
169
- url: release.url,
170
- version: release.tagName
150
+ publishedAt: new Date(release.published_at),
151
+ name: release.name ?? release.tag_name,
152
+ description: release.body ?? null,
153
+ isPrerelease: release.prerelease,
154
+ version: release.tag_name,
155
+ url: release.html_url,
156
+ sha
171
157
  };
172
158
  } catch (error) {
159
+ if (error instanceof Error && "status" in error && error.status === 404) return null;
173
160
  if (Client.isRateLimitError(error)) throw new GitHubRateLimitError(this.rateLimitReset);
174
161
  throw error;
175
162
  }
@@ -183,5 +170,14 @@ var Client = class Client {
183
170
  shouldWaitForRateLimit(threshold = 100) {
184
171
  return this.rateLimitRemaining < threshold;
185
172
  }
173
+ updateRateLimitInfo(headers) {
174
+ let remaining = headers["x-ratelimit-remaining"];
175
+ if (remaining !== void 0) this.rateLimitRemaining = typeof remaining === "string" ? Number.parseInt(remaining, 10) : remaining;
176
+ let reset = headers["x-ratelimit-reset"];
177
+ if (reset !== void 0) {
178
+ let resetTime = typeof reset === "string" ? Number.parseInt(reset, 10) : reset;
179
+ this.rateLimitReset = /* @__PURE__ */ new Date(resetTime * 1e3);
180
+ }
181
+ }
186
182
  };
187
183
  export { Client };
@@ -12,9 +12,24 @@ async function applyUpdates(updates) {
12
12
  let content = await readFile(filePath, "utf8");
13
13
  for (let update of fileUpdates) {
14
14
  if (!update.latestSha) continue;
15
- let escapedName = update.action.name.replaceAll(/[$()*+.?[\\\]^{|}]/gu, String.raw`\$&`);
16
- let escapedVersion = update.currentVersion?.replaceAll(/[$()*+.?[\\\]^{|}]/gu, String.raw`\$&`);
17
- let pattern = new RegExp(String.raw`(^\s*-?\s*uses:\s*)(['"]?)(${escapedName})@${escapedVersion}\2(\s*#[^\n]*)?`, "gm");
15
+ function escapeRegExp(string_) {
16
+ return string_.replaceAll(/[$()*+\-./?[\\\]^{|}]/gu, String.raw`\$&`);
17
+ }
18
+ let escapedName = escapeRegExp(update.action.name);
19
+ let escapedVersion = update.currentVersion ? escapeRegExp(update.currentVersion) : "";
20
+ if (escapedName.includes("\n") || escapedName.includes("\r")) {
21
+ console.error(`Invalid action name: ${update.action.name}`);
22
+ continue;
23
+ }
24
+ if (escapedVersion && (escapedVersion.includes("\n") || escapedVersion.includes("\r"))) {
25
+ console.error(`Invalid version: ${update.currentVersion}`);
26
+ continue;
27
+ }
28
+ if (!/^[\da-f]{40}$/iu.test(update.latestSha)) {
29
+ console.error(`Invalid SHA format: ${update.latestSha}`);
30
+ continue;
31
+ }
32
+ let pattern = new RegExp(`(^\\s*-?\\s*uses:\\s*)(['"]?)(${escapedName})@${escapedVersion}\\2(\\s*#[^\\n]*)?`, "gm");
18
33
  let replacement = `$1$2$3@${update.latestSha}$2 # ${update.latestVersion}`;
19
34
  content = content.replace(pattern, replacement);
20
35
  }
@@ -2,7 +2,7 @@ import { ACTIONS_DIRECTORY, GITHUB_DIRECTORY, WORKFLOWS_DIRECTORY } from "./cons
2
2
  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
- import { join } from "node:path";
5
+ import { isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { readdir, stat } from "node:fs/promises";
7
7
  async function scanGitHubActions(rootPath = process.cwd()) {
8
8
  let result = {
@@ -10,19 +10,39 @@ async function scanGitHubActions(rootPath = process.cwd()) {
10
10
  workflows: /* @__PURE__ */ new Map(),
11
11
  actions: []
12
12
  };
13
- let githubPath = join(rootPath, GITHUB_DIRECTORY);
14
- try {
15
- await stat(githubPath);
16
- } catch {
17
- return result;
13
+ let normalizedRoot = resolve(rootPath);
14
+ function isWithin(root, candidate) {
15
+ let relativePath = relative(root, candidate);
16
+ return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
17
+ }
18
+ let githubPath = join(normalizedRoot, GITHUB_DIRECTORY);
19
+ if (!isWithin(normalizedRoot, githubPath)) throw new Error("Invalid path: detected path traversal attempt");
20
+ function isValidName(name) {
21
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
22
+ console.warn(`Skipping invalid name: ${name}`);
23
+ return false;
24
+ }
25
+ return true;
18
26
  }
19
27
  let workflowsPath = join(githubPath, WORKFLOWS_DIRECTORY);
28
+ if (!isWithin(normalizedRoot, workflowsPath)) return result;
20
29
  try {
21
30
  let workflowsStat = await stat(workflowsPath);
22
31
  if (workflowsStat.isDirectory()) {
23
32
  let files = await readdir(workflowsPath);
24
- let workflowPromises = files.filter((file) => isYamlFile(file)).map(async (file) => {
33
+ let workflowPromises = files.filter((file) => {
34
+ if (!isValidName(file)) return false;
35
+ return isYamlFile(file);
36
+ }).map(async (file) => {
25
37
  let filePath = join(workflowsPath, file);
38
+ if (!isWithin(workflowsPath, filePath)) {
39
+ console.warn(`Skipping file outside workflows directory: ${file}`);
40
+ return {
41
+ success: false,
42
+ actions: [],
43
+ path: ""
44
+ };
45
+ }
26
46
  try {
27
47
  let actions = await scanWorkflowFile(filePath);
28
48
  return {
@@ -39,29 +59,37 @@ async function scanGitHubActions(rootPath = process.cwd()) {
39
59
  }
40
60
  });
41
61
  let workflowResults = await Promise.all(workflowPromises);
42
- for (let workflow of workflowResults) if (workflow.success) if (workflow.actions.length > 0) {
62
+ for (let workflow of workflowResults) if (workflow.success && workflow.path) if (workflow.actions.length > 0) {
43
63
  result.workflows.set(workflow.path, workflow.actions);
44
64
  result.actions.push(...workflow.actions);
45
65
  } else result.workflows.set(workflow.path, []);
46
66
  }
47
67
  } catch {}
48
- let actionsPath = join(githubPath, "actions");
68
+ let actionsPath = join(githubPath, ACTIONS_DIRECTORY);
69
+ if (!isWithin(normalizedRoot, actionsPath)) return result;
49
70
  try {
50
71
  let actionsStat = await stat(actionsPath);
51
72
  if (actionsStat.isDirectory()) {
52
73
  let subdirectories = await readdir(actionsPath);
53
74
  let actionPromises = subdirectories.map(async (subdir) => {
75
+ if (!isValidName(subdir)) return null;
54
76
  let subdirPath = join(actionsPath, subdir);
77
+ if (!isWithin(actionsPath, subdirPath)) {
78
+ console.warn(`Skipping subdirectory outside actions path: ${subdir}`);
79
+ return null;
80
+ }
55
81
  try {
56
82
  let subdirectoryStat = await stat(subdirPath);
57
83
  if (!subdirectoryStat.isDirectory()) return null;
58
84
  let actionFilePath = join(subdirPath, "action.yml");
85
+ if (!isWithin(subdirPath, actionFilePath)) return null;
59
86
  let actions = [];
60
87
  try {
61
88
  actions = await scanActionFile(actionFilePath);
62
89
  } catch {
63
90
  try {
64
91
  actionFilePath = join(subdirPath, "action.yaml");
92
+ if (!isWithin(subdirPath, actionFilePath)) return null;
65
93
  actions = await scanActionFile(actionFilePath);
66
94
  } catch {
67
95
  return null;
package/dist/package.js CHANGED
@@ -1,2 +1,2 @@
1
- const version = "0.1.0";
1
+ const version = "1.1.0";
2
2
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "actions-up",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "description": "Interactive CLI tool to update GitHub Actions to latest versions with SHA pinning",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -36,7 +36,7 @@
36
36
  "./dist"
37
37
  ],
38
38
  "dependencies": {
39
- "@octokit/graphql": "^9.0.1",
39
+ "@octokit/rest": "^22.0.0",
40
40
  "cac": "^6.7.14",
41
41
  "enquirer": "^2.4.1",
42
42
  "nanospinner": "^1.2.2",
package/readme.md CHANGED
@@ -18,12 +18,12 @@ Interactively upgrade and pin actions to exact commit SHAs for secure, reproduci
18
18
 
19
19
  ## Features
20
20
 
21
- - **Auto-discovery** - Scans all workflows (`.github/workflows/*.yml`) and composite actions (`.github/actions/*/action.yml`)
22
- - **SHA Pinning** - Updates actions to use commit SHA instead of tags for better security
23
- - **Batch Updates** - Update multiple actions at once
24
- - **Interactive Selection** - Choose which actions to update
25
- - **Breaking Changes Detection** - Warns about major version updates
26
- - **Fast & Efficient** - Parallel processing with optimized API calls
21
+ - **Auto-discovery**: Scans all workflows (`.github/workflows/*.yml`) and composite actions (`.github/actions/*/action.yml`)
22
+ - **SHA Pinning**: Updates actions to use commit SHA instead of tags for better security
23
+ - **Batch Updates**: Update multiple actions at once
24
+ - **Interactive Selection**: Choose which actions to update
25
+ - **Breaking Changes Detection**: Warns about major version updates
26
+ - **Fast & Efficient**: Parallel processing with optimized API calls
27
27
 
28
28
  ###
29
29
 
@@ -40,21 +40,50 @@ Interactively upgrade and pin actions to exact commit SHAs for secure, reproduci
40
40
  />
41
41
  <img
42
42
  src="https://raw.githubusercontent.com/azat-io/actions-up/main/assets/example-light.webp"
43
- alt="Token Limit CLI Example"
44
- width="600"
43
+ alt="Actions Up interactive example"
44
+ width="820"
45
45
  />
46
46
  </picture>
47
47
 
48
+ ## Why
49
+
50
+ ### The Problem
51
+
52
+ Keeping GitHub Actions updated is a critical but tedious task:
53
+
54
+ - **Security Risk**: Using outdated actions with known vulnerabilities
55
+ - **Manual Hell**: Checking dozens of actions across multiple workflows by hand
56
+ - **Version Tags Are Mutable**: v1 or v2 tags can change without notice, breaking reproducibility
57
+ - **Time Sink**: Hours spent on maintenance that could be used for actual development
58
+
59
+ ### The Solution
60
+
61
+ Actions Up transforms a painful manual process into a delightful experience:
62
+
63
+ | Without Actions Up | With Actions Up |
64
+ | :----------------------------- | :------------------------------- |
65
+ | Check each action manually | Scan all workflows in seconds |
66
+ | Risk using vulnerable versions | SHA pinning for maximum security |
67
+ | 30+ minutes per repository | Under 1 minute total |
68
+
48
69
  ## Installation
49
70
 
71
+ Quick use (no installation)
72
+
73
+ ```bash
74
+ npx actions-up
75
+ ```
76
+
77
+ Global installation
78
+
50
79
  ```bash
51
80
  npm install -g actions-up
52
81
  ```
53
82
 
54
- Or use directly with npx:
83
+ Per-project
55
84
 
56
85
  ```bash
57
- npx actions-up
86
+ npm install --save-dev actions-up
58
87
  ```
59
88
 
60
89
  ## Usage
@@ -64,7 +93,7 @@ npx actions-up
64
93
  Run in your repository root:
65
94
 
66
95
  ```bash
67
- actions-up
96
+ npx actions-up
68
97
  ```
69
98
 
70
99
  This will:
@@ -79,17 +108,9 @@ This will:
79
108
  Skip all prompts and update everything:
80
109
 
81
110
  ```bash
82
- actions-up --yes
111
+ npx actions-up --yes
83
112
  # or
84
- actions-up -y
85
- ```
86
-
87
- ### With GitHub Token
88
-
89
- To avoid rate limits [create a GitHub personal access token](https://github.com/settings/tokens/new?scopes=public_repo&description=actions-up) and set it as an environment variable:
90
-
91
- ```bash
92
- GITHUB_TOKEN=ghp_xxxx actions-up
113
+ npx actions-up -y
93
114
  ```
94
115
 
95
116
  ## Example
@@ -104,11 +125,16 @@ GITHUB_TOKEN=ghp_xxxx actions-up
104
125
  - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
105
126
  ```
106
127
 
107
- ## Configuration
128
+ ## Advanced Usage
129
+
130
+ ### Using GitHub Token for Higher Rate Limits
131
+
132
+ While Actions Up works without authentication, providing a GitHub token increases API rate limits from 60 to 5000 requests per hour, useful for large projects:
108
133
 
109
- ### Environment Variables
134
+ [Create a GitHub Personal Access Token](https://github.com/settings/tokens/new?scopes=public_repo&description=actions-up).
110
135
 
111
- - `GITHUB_TOKEN` - GitHub personal access token for API requests (optional but recommended)
136
+ - For public repositories: Select `public_repo` scope
137
+ - For private repositories: Select `repo` scope
112
138
 
113
139
  ## Security
114
140