@secureci/cli 0.5.1 → 0.6.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.
@@ -38,9 +38,11 @@ const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const job_plan_1 = require("../runner/job-plan");
40
40
  const workflow_parser_1 = require("./workflow-parser");
41
+ const dangerous_triggers_1 = require("./dangerous-triggers");
41
42
  const repository_1 = require("../utils/repository");
42
43
  const action_fetcher_1 = require("../runner/action-fetcher");
43
44
  const github_lockfile_1 = require("../github-lockfile");
45
+ const permissions_1 = require("./permissions");
44
46
  /** Maximum nesting of composite actions / reusable workflows. */
45
47
  const MAX_DEPTH = 10;
46
48
  /**
@@ -51,13 +53,13 @@ const MAX_DEPTH = 10;
51
53
  * Composite actions (`uses: ./path`) and reusable workflows
52
54
  * (`jobs.<id>.uses: ./path.yml`) are resolved recursively, so wrapping an
53
55
  * action doesn't hide it: local ones by default, remote ones too with
54
- * `recurse: 'all'`. Ordering follows
56
+ * `recurse: 'all'` (`offline`: only from the cache). Ordering follows
55
57
  * what GitHub guarantees: steps of a job run in order, and a job runs after
56
58
  * the jobs it (transitively) `needs:`; jobs with no such relationship may
57
59
  * run in parallel, so neither is "before" the other.
58
60
  */
59
61
  async function validatePipelineContract(parsed, spec, options = {}) {
60
- const graph = await buildGraph(parsed, options.recurse ?? 'local');
62
+ const graph = await buildGraph(parsed, options.recurse ?? 'local', options.offline ?? false);
61
63
  const { sequence, nodes } = graph;
62
64
  const errors = [...graph.errors];
63
65
  const ordering = spec.ordering ?? 'relative';
@@ -199,6 +201,16 @@ async function validatePipelineContract(parsed, spec, options = {}) {
199
201
  if (spec.lockfile?.required) {
200
202
  errors.push(...checkLockfile(parsed, sequence.filter(isRemote)));
201
203
  }
204
+ // The workflow file's own permissions; called workflows are validated as
205
+ // files of their own.
206
+ if (spec.permissions) {
207
+ errors.push(...(0, permissions_1.contractPermissionErrors)(parsed.file, spec.permissions));
208
+ }
209
+ // Only the workflow's own on: matters: a called reusable workflow runs
210
+ // under its caller's event.
211
+ if (spec.forbiddenTriggers) {
212
+ errors.push(...(0, dangerous_triggers_1.forbiddenTriggerErrors)(parsed.file, spec.forbiddenTriggers));
213
+ }
202
214
  return {
203
215
  valid: errors.every((e) => e.severity !== 'error'),
204
216
  errors
@@ -260,7 +272,7 @@ function needsTransitively(nodes, from, to) {
260
272
  }
261
273
  return false;
262
274
  }
263
- async function buildGraph(parsed, recurse) {
275
+ async function buildGraph(parsed, recurse, offline) {
264
276
  const graph = { sequence: [], nodes: new Map(), errors: [] };
265
277
  const root = (0, repository_1.repositoryRoot)(parsed.path);
266
278
  const isLocal = (uses) => uses.startsWith('./') || uses.startsWith('../');
@@ -295,7 +307,9 @@ async function buildGraph(parsed, recurse) {
295
307
  }
296
308
  if (recurse !== 'all' || uses.startsWith('docker://'))
297
309
  return undefined;
298
- const file = kind === 'action' ? await (0, action_fetcher_1.fetchAction)(uses) : await (0, action_fetcher_1.fetchRepoFile)(uses);
310
+ const file = kind === 'action'
311
+ ? await (0, action_fetcher_1.fetchAction)(uses, { offline })
312
+ : await (0, action_fetcher_1.fetchRepoFile)(uses, { offline });
299
313
  if (!file) {
300
314
  graph.errors.push({
301
315
  path: at,
@@ -3,9 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.workflowExpressionErrors = workflowExpressionErrors;
4
4
  exports.actionExpressionErrors = actionExpressionErrors;
5
5
  exports.validateWorkflow = validateWorkflow;
6
+ exports.mergeValidationResults = mergeValidationResults;
6
7
  exports.validateAction = validateAction;
7
8
  const workflow_parser_1 = require("./workflow-parser");
8
9
  const expression_1 = require("../runner/expression");
10
+ const injection_1 = require("./injection");
11
+ const permissions_1 = require("./permissions");
12
+ const dangerous_triggers_1 = require("./dangerous-triggers");
9
13
  /**
10
14
  * Invalid `if:` expressions in a workflow: syntax errors, or contexts and
11
15
  * functions not available where they're used. GitHub rejects such a
@@ -120,6 +124,9 @@ function validateWorkflow(filePath) {
120
124
  });
121
125
  }
122
126
  errors.push(...workflowExpressionErrors(workflow));
127
+ errors.push(...(0, injection_1.workflowInjectionErrors)(workflow, filePath));
128
+ errors.push(...(0, permissions_1.workflowPermissionErrors)(workflow));
129
+ errors.push(...(0, dangerous_triggers_1.dangerousTriggerErrors)(workflow));
123
130
  }
124
131
  catch (error) {
125
132
  errors.push({
@@ -129,10 +136,27 @@ function validateWorkflow(filePath) {
129
136
  });
130
137
  }
131
138
  return {
132
- valid: errors.length === 0,
139
+ valid: errors.every((e) => e.severity !== 'error'),
133
140
  errors
134
141
  };
135
142
  }
143
+ /**
144
+ * Combine validation results (e.g. syntax checks and a contract). A warning
145
+ * is dropped when an error with the same rule and path supersedes it, such
146
+ * as a default permissions warning the contract turns into an error.
147
+ */
148
+ function mergeValidationResults(...results) {
149
+ const all = results.flatMap((r) => r.errors);
150
+ const superseding = new Set(all
151
+ .filter((e) => e.severity === 'error' && e.rule)
152
+ .map((e) => `${e.rule}\0${e.path}`));
153
+ return {
154
+ valid: results.every((r) => r.valid),
155
+ errors: all.filter((e) => e.severity !== 'warning' ||
156
+ !e.rule ||
157
+ !superseding.has(`${e.rule}\0${e.path}`))
158
+ };
159
+ }
136
160
  /**
137
161
  * Validate an action file
138
162
  */
@@ -201,6 +225,7 @@ function validateAction(filePath) {
201
225
  }
202
226
  });
203
227
  errors.push(...actionExpressionErrors(action));
228
+ errors.push(...(0, injection_1.actionInjectionErrors)(action, filePath));
204
229
  }
205
230
  }
206
231
  }
package/dist/pin.js ADDED
@@ -0,0 +1,311 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.findUsesSites = findUsesSites;
37
+ exports.isPinAllowed = isPinAllowed;
38
+ exports.pinContent = pinContent;
39
+ exports.localPinTargets = localPinTargets;
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const YAML = __importStar(require("yaml"));
43
+ const repository_1 = require("./utils/repository");
44
+ const FULL_SHA = /^[0-9a-f]{40}$/i;
45
+ /**
46
+ * Every `uses:` a runner reads: `jobs.<id>.uses` (reusable workflows),
47
+ * `jobs.<id>.steps[*].uses` and a composite action's `runs.steps[*].uses`.
48
+ * Read from the YAML syntax tree, so `uses:` inside a script or an input
49
+ * isn't mistaken for one, with source offsets for in-place edits.
50
+ */
51
+ function findUsesSites(content) {
52
+ const doc = parseYaml(content);
53
+ const root = doc.contents;
54
+ if (!YAML.isMap(root))
55
+ return [];
56
+ const nodes = [];
57
+ const usesOf = (node) => {
58
+ if (!YAML.isMap(node))
59
+ return;
60
+ const uses = node.get('uses', true);
61
+ if (YAML.isScalar(uses))
62
+ nodes.push(uses);
63
+ };
64
+ const stepsOf = (node) => {
65
+ if (!YAML.isMap(node))
66
+ return;
67
+ const steps = node.get('steps', true);
68
+ if (YAML.isSeq(steps))
69
+ steps.items.forEach(usesOf);
70
+ };
71
+ const jobs = root.get('jobs', true);
72
+ if (YAML.isMap(jobs)) {
73
+ for (const pair of jobs.items) {
74
+ usesOf(pair.value);
75
+ stepsOf(pair.value);
76
+ }
77
+ }
78
+ stepsOf(root.get('runs', true));
79
+ return nodes
80
+ .filter((node) => node.range && typeof node.value === 'string')
81
+ .map((node) => {
82
+ const [start, end] = node.range;
83
+ return {
84
+ line: content.slice(0, start).split('\n').length,
85
+ uses: node.value,
86
+ start,
87
+ end,
88
+ style: scalarStyle(node.type)
89
+ };
90
+ })
91
+ .sort((a, b) => a.start - b.start);
92
+ }
93
+ function parseYaml(content) {
94
+ const doc = YAML.parseDocument(content);
95
+ if (doc.errors.length > 0) {
96
+ throw new Error(`File is not valid YAML: ${doc.errors[0].message}`);
97
+ }
98
+ return doc;
99
+ }
100
+ function scalarStyle(type) {
101
+ if (type === 'QUOTE_SINGLE')
102
+ return 'single';
103
+ if (type === 'QUOTE_DOUBLE')
104
+ return 'double';
105
+ if (type === 'BLOCK_LITERAL' || type === 'BLOCK_FOLDED')
106
+ return 'block';
107
+ return 'plain';
108
+ }
109
+ /**
110
+ * Whether a contract's `pins.allow` exempts a reference from pinning: an
111
+ * owner, or the exact action name, case-insensitively for owner and repo.
112
+ * This mirrors the contract's own pins check, so `pin` never leaves a
113
+ * reference on a tag that `validate --contract` would then reject.
114
+ */
115
+ function isPinAllowed(uses, allow) {
116
+ const at = uses.lastIndexOf('@');
117
+ const name = normalizeName(at === -1 ? uses : uses.slice(0, at));
118
+ const owner = name.split('/')[0];
119
+ const allowed = new Set(allow.map(normalizeName));
120
+ return allowed.has(name) || allowed.has(owner);
121
+ }
122
+ function normalizeName(name) {
123
+ const parts = name.split('/');
124
+ return [
125
+ ...parts.slice(0, 2).map((p) => p.toLowerCase()),
126
+ ...parts.slice(2)
127
+ ].join('/');
128
+ }
129
+ /**
130
+ * Pin every remote `uses:` reference in a workflow or action file to the
131
+ * commit SHA `resolve` returns, keeping the tag in a trailing comment
132
+ * (`owner/repo@<sha> # v4`) so Dependabot and Renovate can still update
133
+ * it. The text is edited in place (only the value and its comment change),
134
+ * never re-serialised, so formatting and other comments survive.
135
+ *
136
+ * Left alone: local (`./`) and `docker://` references, references already
137
+ * pinned to a full SHA, and those `allow` exempts. A reference that can't
138
+ * be resolved is reported in `unresolved` and not changed.
139
+ */
140
+ async function pinContent(content, resolve, options = {}) {
141
+ const sites = findUsesSites(content);
142
+ const result = {
143
+ content,
144
+ changes: [],
145
+ unresolved: [],
146
+ allowed: []
147
+ };
148
+ const lineCounts = new Map();
149
+ sites.forEach((s) => lineCounts.set(s.line, (lineCounts.get(s.line) ?? 0) + 1));
150
+ const resolved = new Map();
151
+ const edits = [];
152
+ for (const site of sites) {
153
+ const { uses, line } = site;
154
+ if (/^(\.\.?\/|docker:\/\/)/.test(uses))
155
+ continue;
156
+ const at = uses.lastIndexOf('@');
157
+ const name = at === -1 ? '' : uses.slice(0, at);
158
+ const ref = uses.slice(at + 1);
159
+ const parts = name.split('/');
160
+ if (at === -1 || !ref || parts.length < 2 || parts.some((p) => !p)) {
161
+ result.unresolved.push({
162
+ line,
163
+ uses,
164
+ reason: 'is not an owner/repo@ref reference'
165
+ });
166
+ continue;
167
+ }
168
+ if (FULL_SHA.test(ref))
169
+ continue;
170
+ if (isPinAllowed(uses, options.allow ?? [])) {
171
+ result.allowed.push({ line, uses });
172
+ continue;
173
+ }
174
+ if (site.style === 'block') {
175
+ result.unresolved.push({
176
+ line,
177
+ uses,
178
+ reason: 'is written as a block scalar; pin it by hand'
179
+ });
180
+ continue;
181
+ }
182
+ if (!resolved.has(uses))
183
+ resolved.set(uses, resolve(uses));
184
+ const sha = (await resolved.get(uses))?.toLowerCase();
185
+ if (!sha || !FULL_SHA.test(sha)) {
186
+ result.unresolved.push({
187
+ line,
188
+ uses,
189
+ reason: 'could not be resolved to a commit'
190
+ });
191
+ continue;
192
+ }
193
+ const pinned = `${name}@${sha}`;
194
+ const quote = site.style === 'single' ? "'" : site.style === 'double' ? '"' : '';
195
+ edits.push({
196
+ start: site.start,
197
+ end: site.end,
198
+ text: `${quote}${pinned}${quote}`
199
+ });
200
+ // Several references on one line (a flow sequence) can't each have a
201
+ // trailing comment, so none is written rather than a wrong one.
202
+ if (lineCounts.get(line) === 1) {
203
+ edits.push(commentEdit(content, site.end, ref));
204
+ }
205
+ result.changes.push({ line, uses, ref, sha, pinned });
206
+ }
207
+ result.content = edits
208
+ .sort((a, b) => b.start - a.start)
209
+ .reduce((text, e) => text.slice(0, e.start) + e.text + text.slice(e.end), content);
210
+ return result;
211
+ }
212
+ /**
213
+ * The edit that leaves `# <ref>` as the comment of the line a value ends
214
+ * on: an old version comment is replaced, any other comment kept after
215
+ * the ref, and with no comment one is added at the end of the line.
216
+ */
217
+ function commentEdit(content, valueEnd, ref) {
218
+ let lineEnd = content.indexOf('\n', valueEnd);
219
+ if (lineEnd === -1)
220
+ lineEnd = content.length;
221
+ if (content[lineEnd - 1] === '\r')
222
+ lineEnd--;
223
+ const tail = content.slice(valueEnd, lineEnd);
224
+ const hash = commentStart(tail);
225
+ if (hash === -1) {
226
+ const at = valueEnd + tail.trimEnd().length;
227
+ return { start: at, end: at, text: ` # ${ref}` };
228
+ }
229
+ const body = tail.slice(hash + 1).trim();
230
+ return {
231
+ start: valueEnd + hash,
232
+ end: lineEnd,
233
+ text: `# ${ref}${commentRest(body, ref)}`
234
+ };
235
+ }
236
+ /** Where a YAML comment starts in the rest of a line, or -1. */
237
+ function commentStart(tail) {
238
+ let quote;
239
+ for (let i = 0; i < tail.length; i++) {
240
+ const c = tail[i];
241
+ if (quote) {
242
+ if (c === '\\' && quote === '"')
243
+ i++;
244
+ else if (c === quote)
245
+ quote = undefined;
246
+ }
247
+ else if (c === "'" || c === '"') {
248
+ quote = c;
249
+ }
250
+ else if (c === '#' && i > 0 && /\s/.test(tail[i - 1])) {
251
+ return i;
252
+ }
253
+ }
254
+ return -1;
255
+ }
256
+ /**
257
+ * What to keep of an existing comment after the new `# <ref>`: the text
258
+ * after an old version (`v3`, `3.2.1`, `tag=v3`, `pin @v3`, or the old
259
+ * ref itself), else the whole comment.
260
+ */
261
+ function commentRest(body, ref) {
262
+ if (!body)
263
+ return '';
264
+ const version = body.match(/^(?:tag\s*=\s*|pin\s*@?\s*)?@?v?\d[\w.+-]*(.*)$/i);
265
+ const rest = version
266
+ ? version[1]
267
+ : body === ref || body.startsWith(`${ref} `)
268
+ ? body.slice(ref.length)
269
+ : undefined;
270
+ if (rest === undefined)
271
+ return ` - ${body}`;
272
+ return rest.trim() ? ` ${rest.trim()}` : '';
273
+ }
274
+ /**
275
+ * The given files plus the local composite actions (`./path`) and local
276
+ * reusable workflows they use, recursively, so a whole repository is
277
+ * pinned in one go. `./` resolves against the repository root, as on
278
+ * GitHub. Files are listed once; one that can't be read is kept (for the
279
+ * caller to report) but not followed.
280
+ */
281
+ function localPinTargets(files) {
282
+ const seen = new Set(files.map((f) => path.resolve(f)));
283
+ const result = [...files];
284
+ const follow = (file, root) => {
285
+ let sites;
286
+ try {
287
+ sites = findUsesSites(fs.readFileSync(file, 'utf8'));
288
+ }
289
+ catch {
290
+ return;
291
+ }
292
+ for (const { uses } of sites) {
293
+ if (!uses.startsWith('./') && !uses.startsWith('../'))
294
+ continue;
295
+ const target = path.resolve(root, uses);
296
+ const local = [
297
+ target,
298
+ ...['action.yml', 'action.yaml'].map((n) => path.join(target, n))
299
+ ].find((c) => fs.existsSync(c) && fs.statSync(c).isFile());
300
+ if (!local || seen.has(local))
301
+ continue;
302
+ seen.add(local);
303
+ result.push(local);
304
+ follow(local, root);
305
+ }
306
+ };
307
+ for (const file of files)
308
+ follow(file, (0, repository_1.repositoryRoot)(file));
309
+ return result;
310
+ }
311
+ //# sourceMappingURL=pin.js.map
package/dist/report.js CHANGED
@@ -4,6 +4,8 @@ exports.formatReport = formatReport;
4
4
  exports.formatValidation = formatValidation;
5
5
  /** A SARIF rule id per error, so code-scanning can group findings. */
6
6
  function ruleIdFor(error) {
7
+ if (error.rule)
8
+ return error.rule;
7
9
  if (/^required\[|^forbidden\[/.test(error.path))
8
10
  return 'secureci/contract';
9
11
  if (/\.if$|\bif:/.test(error.path))
@@ -13,7 +15,11 @@ function ruleIdFor(error) {
13
15
  const RULES = {
14
16
  'secureci/validation': 'Workflow or action structure',
15
17
  'secureci/contract': 'Pipeline contract',
16
- 'secureci/expression': 'Expression / if: condition'
18
+ 'secureci/expression': 'Expression / if: condition',
19
+ 'secureci/injection': 'Untrusted input in a shell or script',
20
+ 'secureci/permissions': 'GITHUB_TOKEN permissions',
21
+ 'secureci/dangerous-trigger': 'Privileged trigger with untrusted code',
22
+ 'secureci/vulnerable-action': 'Action with a known security advisory'
17
23
  };
18
24
  function jsonForFile(entry) {
19
25
  return {
@@ -37,10 +37,16 @@ exports.parseActionReference = parseActionReference;
37
37
  exports.getActionCacheDir = getActionCacheDir;
38
38
  exports.githubToken = githubToken;
39
39
  exports._resetGithubTokenForTests = _resetGithubTokenForTests;
40
+ exports.serverUrl = serverUrl;
41
+ exports.apiUrl = apiUrl;
40
42
  exports.lsRemoteSha = lsRemoteSha;
43
+ exports.parseLsRemoteTags = parseLsRemoteTags;
44
+ exports.lsRemoteTags = lsRemoteTags;
45
+ exports.getText = getText;
41
46
  exports.fetchAction = fetchAction;
42
47
  exports.fetchRepoFile = fetchRepoFile;
43
48
  exports.clearActionCache = clearActionCache;
49
+ exports.cachedActionSha = cachedActionSha;
44
50
  exports.resolveActionSha = resolveActionSha;
45
51
  exports.resolveRepoIdentity = resolveRepoIdentity;
46
52
  exports.lsRemoteReachable = lsRemoteReachable;
@@ -110,9 +116,14 @@ function githubToken() {
110
116
  function _resetGithubTokenForTests() {
111
117
  discoveredToken = undefined;
112
118
  }
119
+ /** The GitHub server ($GITHUB_SERVER_URL, default https://github.com). */
113
120
  function serverUrl() {
114
121
  return (process.env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/+$/, '');
115
122
  }
123
+ /**
124
+ * The GitHub REST API base: $GITHUB_API_URL, else api.github.com, or
125
+ * `<server>/api/v3` for GitHub Enterprise Server.
126
+ */
116
127
  function apiUrl() {
117
128
  if (process.env.GITHUB_API_URL) {
118
129
  return process.env.GITHUB_API_URL.replace(/\/+$/, '');
@@ -155,7 +166,51 @@ async function lsRemoteSha(url, ref) {
155
166
  refs.get(`refs/tags/${ref}`) ??
156
167
  refs.get(`refs/heads/${ref}`));
157
168
  }
158
- /** GET a small text response (the API commit lookup). */
169
+ /**
170
+ * Tag name -> commit SHA from `git ls-remote --tags` output. An annotated
171
+ * tag's peeled entry (`refs/tags/x^{}`) wins, so every SHA is a commit.
172
+ */
173
+ function parseLsRemoteTags(output) {
174
+ const tags = new Map();
175
+ const peeled = new Set();
176
+ for (const line of output.split('\n')) {
177
+ const [sha, ref] = line.trim().split(/\s+/);
178
+ if (!sha || !FULL_SHA.test(sha) || !ref?.startsWith('refs/tags/'))
179
+ continue;
180
+ let name = ref.slice('refs/tags/'.length);
181
+ const isPeeled = name.endsWith('^{}');
182
+ if (isPeeled)
183
+ name = name.slice(0, -3);
184
+ if (isPeeled || !peeled.has(name))
185
+ tags.set(name, sha.toLowerCase());
186
+ if (isPeeled)
187
+ peeled.add(name);
188
+ }
189
+ return tags;
190
+ }
191
+ /**
192
+ * Every tag of the repository at `url` and the commit it points at, via
193
+ * anonymous `git ls-remote --tags` (no API rate limit), or undefined if
194
+ * the repository can't be read.
195
+ */
196
+ async function lsRemoteTags(url) {
197
+ try {
198
+ const { stdout } = await execFileAsync('git', ['ls-remote', '--tags', url], {
199
+ encoding: 'utf8',
200
+ timeout: 15000,
201
+ maxBuffer: 16 * 1024 * 1024,
202
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
203
+ });
204
+ return parseLsRemoteTags(stdout);
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ }
210
+ /**
211
+ * GET a small text response (API lookups). Rejects on a non-200 status
212
+ * with an error carrying `status`.
213
+ */
159
214
  function getText(url, headers, timeoutMs) {
160
215
  return new Promise((resolve, reject) => {
161
216
  const client = url.startsWith('http://') ? http : https;
@@ -278,7 +333,27 @@ function writeRefIndex(dir, refs) {
278
333
  * picked up. If the ref can't be resolved (offline, rate-limited), the
279
334
  * last commit cached for it is used. Returns the repository root.
280
335
  */
281
- async function fetchRepository(ref, uses, options) {
336
+ const inflight = new Map();
337
+ /**
338
+ * fetchRepositoryOnce, shared by concurrent callers asking for the same
339
+ * repository and ref (jobs running in parallel), so it is downloaded and
340
+ * written into the cache once.
341
+ */
342
+ function fetchRepository(ref, uses, options) {
343
+ const key = JSON.stringify([
344
+ repoCacheDir(ref),
345
+ ref.ref,
346
+ !!options.offline,
347
+ options.token === undefined ? 'discovered' : options.token
348
+ ]);
349
+ let pending = inflight.get(key);
350
+ if (!pending) {
351
+ pending = fetchRepositoryOnce(ref, uses, options).finally(() => inflight.delete(key));
352
+ inflight.set(key, pending);
353
+ }
354
+ return pending;
355
+ }
356
+ async function fetchRepositoryOnce(ref, uses, options) {
282
357
  const dir = repoCacheDir(ref);
283
358
  const refs = readRefIndex(dir);
284
359
  const cachedSha = FULL_SHA.test(ref.ref)
@@ -334,8 +409,12 @@ async function fetchRepository(ref, uses, options) {
334
409
  const [top] = fs.readdirSync(extractDir);
335
410
  if (!top)
336
411
  throw new Error('No files extracted from tarball');
337
- fs.rmSync(target, { recursive: true, force: true });
338
- fs.renameSync(path.join(extractDir, top), target);
412
+ // A commit's content never changes: keep a copy another job (asking
413
+ // by another ref) put there meanwhile, as it may be in use.
414
+ if (!sha || !fs.existsSync(target)) {
415
+ fs.rmSync(target, { recursive: true, force: true });
416
+ fs.renameSync(path.join(extractDir, top), target);
417
+ }
339
418
  if (sha) {
340
419
  writeRefIndex(dir, { ...readRefIndex(dir), [ref.ref]: sha });
341
420
  }
@@ -410,6 +489,23 @@ function clearActionCache() {
410
489
  logger_1.logger.info('Action cache cleared');
411
490
  }
412
491
  }
492
+ /**
493
+ * The commit a ref resolved to when it was last downloaded into the action
494
+ * cache, without touching the network (for offline use), or undefined when
495
+ * that commit isn't cached. An already-pinned SHA is returned as is. For a
496
+ * moving ref (`@main`) this is the last commit seen, not necessarily the
497
+ * current one.
498
+ */
499
+ function cachedActionSha(uses) {
500
+ const ref = parseActionReference(uses);
501
+ if (!ref)
502
+ return undefined;
503
+ if (FULL_SHA.test(ref.ref))
504
+ return ref.ref.toLowerCase();
505
+ const dir = repoCacheDir(ref);
506
+ const sha = readRefIndex(dir)[ref.ref];
507
+ return sha && fs.existsSync(path.join(dir, sha)) ? sha : undefined;
508
+ }
413
509
  /**
414
510
  * Resolve a `uses:` action reference to its current commit SHA, for the
415
511
  * inventory and lockfile. Returns undefined if the ref can't be resolved
@@ -435,14 +435,24 @@ async function saveCache(fsys, scope, key, paths) {
435
435
  warning: 'Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.'
436
436
  };
437
437
  }
438
- const staging = `${dir}.tmp-${process.pid}`;
439
- fs.rmSync(staging, { recursive: true, force: true });
438
+ // Unique per save: jobs running at once may save the same key.
439
+ const staging = `${dir}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
440
440
  for (const file of files) {
441
441
  copyFile(file.host, path.join(staging, 'files', ...cachedLocation(fsys, file.logical).split('/')));
442
442
  }
443
443
  fs.writeFileSync(path.join(staging, 'meta.json'), JSON.stringify({ key, version, created: Date.now() }));
444
444
  fs.mkdirSync(path.dirname(dir), { recursive: true });
445
- fs.renameSync(staging, dir);
445
+ try {
446
+ fs.renameSync(staging, dir);
447
+ }
448
+ catch (error) {
449
+ fs.rmSync(staging, { recursive: true, force: true });
450
+ // Another job saved this key meanwhile; cache entries are immutable.
451
+ if (fs.existsSync(path.join(dir, 'meta.json'))) {
452
+ return ok(`Cache for key "${key}" already exists; not saving`);
453
+ }
454
+ throw error;
455
+ }
446
456
  return ok(`Cache saved with key: ${key} (${files.length} file(s))`);
447
457
  }
448
458
  /** `actions/cache`, `actions/cache/restore` and `actions/cache/save`. */