@secureci/cli 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/cli.js +164 -39
- package/dist/github-lockfile.js +75 -5
- package/dist/inventory.js +119 -35
- package/dist/parser/pipeline-contract-parser.js +15 -0
- package/dist/parser/pipeline-contract-validator.js +45 -0
- package/dist/runner/action-fetcher.js +68 -0
- package/dist/utils/logger.js +17 -0
- package/dist/utils/progress.js +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,7 +71,8 @@ secureci inventory --check # fail on drift (use in CI)
|
|
|
71
71
|
is checked against it, locally and in CI.
|
|
72
72
|
- **Supply-chain visibility** — [`secureci inventory`](DOCS.md#action-inventory-and-drift-secureci-inventory)
|
|
73
73
|
with a committed lockfile turns a silently moved action tag into a failing
|
|
74
|
-
check
|
|
74
|
+
check, including tags inside the actions you use, and catches a locked
|
|
75
|
+
repository that was deleted and re-created.
|
|
75
76
|
- **Machine-readable output** — `--format json|sarif` drops straight into CI and
|
|
76
77
|
GitHub code scanning.
|
|
77
78
|
- **Audit evidence** — the contract, `secureci.lock` and reports are plain files
|
package/dist/cli.js
CHANGED
|
@@ -38,6 +38,7 @@ const commander_1 = require("commander");
|
|
|
38
38
|
const dry_run_1 = require("./runner/dry-run");
|
|
39
39
|
const executor_1 = require("./runner/executor");
|
|
40
40
|
const action_fetcher_1 = require("./runner/action-fetcher");
|
|
41
|
+
const progress_1 = require("./utils/progress");
|
|
41
42
|
const contract_source_1 = require("./contract-source");
|
|
42
43
|
const validator_1 = require("./parser/validator");
|
|
43
44
|
const workflow_parser_1 = require("./parser/workflow-parser");
|
|
@@ -191,7 +192,7 @@ program
|
|
|
191
192
|
});
|
|
192
193
|
program
|
|
193
194
|
.command('inventory')
|
|
194
|
-
.description('List the actions and reusable workflows a workflow uses (
|
|
195
|
+
.description('List the actions and reusable workflows a workflow uses (including the actions those actions use), with their commit SHAs; track supply-chain drift with a committed secureci.lock')
|
|
195
196
|
.argument('[file...]', 'Workflow files (default: every file in .github/workflows)')
|
|
196
197
|
.option('--offline', 'Do not resolve tags to commit SHAs over the network (already-pinned refs still resolve)')
|
|
197
198
|
.option('--dry-run', `Print the resolved inventory as JSON without writing or checking ${LOCKFILE_NAME}`)
|
|
@@ -248,9 +249,6 @@ async function runInventory(files, options) {
|
|
|
248
249
|
process.exit(1);
|
|
249
250
|
}
|
|
250
251
|
targets.forEach(requireFile);
|
|
251
|
-
const resolver = options.offline
|
|
252
|
-
? undefined
|
|
253
|
-
: (uses) => (0, action_fetcher_1.resolveActionSha)(uses);
|
|
254
252
|
// Merge every file's remote actions by `uses`.
|
|
255
253
|
const byUses = new Map();
|
|
256
254
|
for (const file of targets) {
|
|
@@ -258,7 +256,11 @@ async function runInventory(files, options) {
|
|
|
258
256
|
const parsed = (0, workflow_parser_1.parseFile)(file);
|
|
259
257
|
if (parsed.type !== 'workflow')
|
|
260
258
|
continue;
|
|
261
|
-
const inv = await (0, inventory_1.buildInventory)({
|
|
259
|
+
const inv = await (0, inventory_1.buildInventory)({
|
|
260
|
+
type: 'workflow',
|
|
261
|
+
path: file,
|
|
262
|
+
file: parsed.file
|
|
263
|
+
});
|
|
262
264
|
for (const action of inv.actions) {
|
|
263
265
|
const existing = byUses.get(action.uses);
|
|
264
266
|
if (existing)
|
|
@@ -272,11 +274,119 @@ async function runInventory(files, options) {
|
|
|
272
274
|
process.exit(1);
|
|
273
275
|
}
|
|
274
276
|
}
|
|
275
|
-
const
|
|
277
|
+
const direct = [...byUses.values()];
|
|
278
|
+
const lockPath = path.join(process.cwd(), LOCKFILE_NAME);
|
|
279
|
+
// secureci.lock is first-class; GitHub's actions.lock is used only when
|
|
280
|
+
// it is the lockfile the repository provides.
|
|
281
|
+
const source = (0, github_lockfile_1.findLockfile)(process.cwd());
|
|
282
|
+
// `--update-lock`, or bare `inventory` when no lockfile exists yet,
|
|
283
|
+
// writes the lock; otherwise (`--check`, or bare `inventory` with a
|
|
284
|
+
// lockfile) compares against it.
|
|
285
|
+
const writing = update || (!check && !source);
|
|
286
|
+
const comparing = !writing && !options.dryRun;
|
|
287
|
+
let lock;
|
|
288
|
+
const github = source?.kind === 'github';
|
|
289
|
+
const lockName = github ? 'actions.lock' : LOCKFILE_NAME;
|
|
290
|
+
if (comparing) {
|
|
291
|
+
if (!source) {
|
|
292
|
+
logger_1.logger.error(`No ${LOCKFILE_NAME} found (or GitHub's ${github_lockfile_1.GITHUB_LOCKFILE}). Create one with: secureci inventory --update-lock`);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
lock = (0, github_lockfile_1.readLockfile)(source);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
logger_1.logger.error(`Could not read ${lockName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Download messages would corrupt --dry-run's JSON on stdout and clash
|
|
304
|
+
// with the progress lines; warnings and errors still go to stderr.
|
|
305
|
+
const level = logger_1.logger.getLevel();
|
|
306
|
+
if (level === logger_1.LogLevel.INFO)
|
|
307
|
+
logger_1.logger.setLevel(logger_1.LogLevel.WARN);
|
|
308
|
+
const online = !options.offline;
|
|
309
|
+
if (online && direct.length > 0) {
|
|
310
|
+
process.stderr.write(`Resolving ${direct.length} action(s) over the network, and reading the actions they use. This can take a while (--offline uses only what is cached).\n`);
|
|
311
|
+
}
|
|
312
|
+
// 1. Resolve each tag or branch to the commit it points at.
|
|
313
|
+
const toResolve = online ? direct.filter((a) => !a.resolvedSha) : [];
|
|
314
|
+
const resolving = new progress_1.Progress('Resolving tags', toResolve.length);
|
|
315
|
+
for (const action of toResolve) {
|
|
316
|
+
resolving.tick(action.uses);
|
|
317
|
+
action.resolvedSha = await (0, action_fetcher_1.resolveActionSha)(action.uses);
|
|
318
|
+
}
|
|
319
|
+
resolving.done(`Resolved ${toResolve.length} tag(s)`);
|
|
320
|
+
// 2. Follow remote composite actions and reusable workflows into the
|
|
321
|
+
// actions they use, downloaded at the resolved commit.
|
|
322
|
+
const reading = new progress_1.Progress('Reading actions', online ? direct.length : 0);
|
|
323
|
+
let read = 0;
|
|
324
|
+
const actions = (await (0, inventory_1.addTransitive)(direct, {
|
|
325
|
+
resolveSha: online ? (uses) => (0, action_fetcher_1.resolveActionSha)(uses) : undefined,
|
|
326
|
+
fetch: (uses, kind) => {
|
|
327
|
+
if (online)
|
|
328
|
+
reading.tick(uses.replace(/@([0-9a-f]{12})[0-9a-f]{28}$/, '@$1'));
|
|
329
|
+
read++;
|
|
330
|
+
return kind === 'action'
|
|
331
|
+
? (0, action_fetcher_1.fetchAction)(uses, { offline: options.offline })
|
|
332
|
+
: (0, action_fetcher_1.fetchRepoFile)(uses, { offline: options.offline });
|
|
333
|
+
},
|
|
334
|
+
warn: (message) => logger_1.logger.warn(message),
|
|
335
|
+
onQueue: () => reading.grow()
|
|
336
|
+
})).sort((a, b) => a.uses.localeCompare(b.uses));
|
|
337
|
+
reading.done(`Read ${read} action(s) for the actions they use`);
|
|
338
|
+
const repos = new Map();
|
|
339
|
+
if (online) {
|
|
340
|
+
for (const action of actions) {
|
|
341
|
+
if (!action.resolvedSha)
|
|
342
|
+
continue;
|
|
343
|
+
const identity = !lock || lock.actions[action.uses]?.repoId !== undefined;
|
|
344
|
+
const existence = comparing && action.pinned;
|
|
345
|
+
if (!identity && !existence)
|
|
346
|
+
continue;
|
|
347
|
+
const key = `${action.owner}/${action.repo}`.toLowerCase();
|
|
348
|
+
const entry = repos.get(key) ?? {
|
|
349
|
+
owner: action.owner,
|
|
350
|
+
repo: action.repo,
|
|
351
|
+
identity: false,
|
|
352
|
+
existence: false
|
|
353
|
+
};
|
|
354
|
+
entry.identity || (entry.identity = identity);
|
|
355
|
+
entry.existence || (entry.existence = existence);
|
|
356
|
+
repos.set(key, entry);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const identities = new Map();
|
|
360
|
+
const missingRepos = new Set();
|
|
361
|
+
const checking = new progress_1.Progress('Checking repositories', repos.size);
|
|
362
|
+
for (const [key, r] of repos) {
|
|
363
|
+
checking.tick(`${r.owner}/${r.repo}`);
|
|
364
|
+
const identity = r.identity
|
|
365
|
+
? await (0, action_fetcher_1.resolveRepoIdentity)(r.owner, r.repo)
|
|
366
|
+
: undefined;
|
|
367
|
+
if (identity)
|
|
368
|
+
identities.set(key, identity);
|
|
369
|
+
else if (r.existence && !(await (0, action_fetcher_1.repoExists)(r.owner, r.repo))) {
|
|
370
|
+
missingRepos.add(key);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
checking.done(`Checked ${repos.size} repositor${repos.size === 1 ? 'y' : 'ies'}`);
|
|
374
|
+
for (const action of actions) {
|
|
375
|
+
const identity = identities.get(`${action.owner}/${action.repo}`.toLowerCase());
|
|
376
|
+
if (identity && action.resolvedSha)
|
|
377
|
+
Object.assign(action, identity);
|
|
378
|
+
}
|
|
379
|
+
logger_1.logger.setLevel(level);
|
|
276
380
|
const current = {};
|
|
277
381
|
for (const action of actions) {
|
|
278
|
-
if (action.resolvedSha)
|
|
279
|
-
|
|
382
|
+
if (!action.resolvedSha)
|
|
383
|
+
continue;
|
|
384
|
+
current[action.uses] = {
|
|
385
|
+
commit: action.resolvedSha,
|
|
386
|
+
...(action.ownerId !== undefined ? { ownerId: action.ownerId } : {}),
|
|
387
|
+
...(action.repoId !== undefined ? { repoId: action.repoId } : {}),
|
|
388
|
+
...(action.via ? { via: action.via } : {})
|
|
389
|
+
};
|
|
280
390
|
}
|
|
281
391
|
// --dry-run: print the resolved inventory as JSON, touching nothing.
|
|
282
392
|
if (options.dryRun) {
|
|
@@ -284,49 +394,64 @@ async function runInventory(files, options) {
|
|
|
284
394
|
console.log(JSON.stringify({ actions }, null, 2));
|
|
285
395
|
return;
|
|
286
396
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
fs.writeFileSync(lockPath,
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
writeLock();
|
|
397
|
+
// Used references that couldn't be resolved to a commit now.
|
|
398
|
+
const unresolved = actions.filter((a) => !a.resolvedSha);
|
|
399
|
+
if (writing) {
|
|
400
|
+
if (online) {
|
|
401
|
+
unresolved.forEach((a) => logger_1.logger.warn(`Not locked: could not resolve ${a.uses} (repository deleted, renamed or private, or the network is unavailable?)`));
|
|
402
|
+
}
|
|
403
|
+
fs.writeFileSync(lockPath, (0, github_lockfile_1.serializeSecureciLockfile)(current));
|
|
404
|
+
const transitive = Object.values(current).filter((e) => e.via).length;
|
|
405
|
+
logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}` +
|
|
406
|
+
(transitive > 0 ? ` (${transitive} used by other actions)` : '') +
|
|
407
|
+
'. Commit it to track supply-chain drift.');
|
|
299
408
|
return;
|
|
300
409
|
}
|
|
301
|
-
// Otherwise compare (explicit `--check`, or bare `inventory` with a lockfile).
|
|
302
|
-
if (!source) {
|
|
303
|
-
logger_1.logger.error(`No ${LOCKFILE_NAME} found (or GitHub's ${github_lockfile_1.GITHUB_LOCKFILE}). Create one with: secureci inventory --update-lock`);
|
|
304
|
-
process.exit(1);
|
|
305
|
-
}
|
|
306
|
-
const github = source.kind === 'github';
|
|
307
|
-
const lockName = github ? 'actions.lock' : LOCKFILE_NAME;
|
|
308
410
|
const fixHint = github
|
|
309
411
|
? 'run `gh actions-lock` to update actions.lock'
|
|
310
412
|
: 'run with --update-lock to accept it';
|
|
311
|
-
let lock;
|
|
312
|
-
try {
|
|
313
|
-
const text = fs.readFileSync(source.file, 'utf-8');
|
|
314
|
-
lock = github ? (0, github_lockfile_1.parseGitHubLockfile)(text) : JSON.parse(text);
|
|
315
|
-
}
|
|
316
|
-
catch (error) {
|
|
317
|
-
logger_1.logger.error(`Could not read ${lockName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
318
|
-
process.exit(1);
|
|
319
|
-
}
|
|
320
413
|
logger_1.logger.info(`Checking against ${github ? github_lockfile_1.GITHUB_LOCKFILE : LOCKFILE_NAME}`);
|
|
321
414
|
const diff = (0, inventory_1.diffLock)(current, lock);
|
|
322
415
|
for (const d of diff.drifted) {
|
|
323
416
|
logger_1.logger.error(`Drift: ${d.uses} now resolves to ${d.to.slice(0, 12)} (was ${d.from.slice(0, 12)})`);
|
|
324
417
|
}
|
|
418
|
+
for (const d of diff.identity) {
|
|
419
|
+
logger_1.logger.error(`Repository changed: ${d.uses} ${d.field} is now ${d.to} (was ${d.from}). The name now points at a different ${d.field === 'repo_id' ? 'repository (deleted and re-created?)' : 'owner (transferred, or the account re-registered?)'}.`);
|
|
420
|
+
}
|
|
421
|
+
for (const a of actions) {
|
|
422
|
+
if (missingRepos.has(`${a.owner}/${a.repo}`.toLowerCase())) {
|
|
423
|
+
logger_1.logger.error(`Repository gone: ${a.uses} can no longer be read (deleted, renamed or made private?). The locked commit can't be fetched, and the name could be re-registered by someone else.`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Online, a used reference that doesn't resolve is a failure (its
|
|
427
|
+
// repository may be gone); offline, tags can't resolve, so it's a warning.
|
|
428
|
+
for (const a of unresolved) {
|
|
429
|
+
const locked = lock?.actions[a.uses];
|
|
430
|
+
const at = locked ? ` (locked at ${locked.commit.slice(0, 12)})` : '';
|
|
431
|
+
if (online) {
|
|
432
|
+
logger_1.logger.error(`Could not resolve ${a.uses}${at}: the repository or ref may have been deleted, renamed or made private, or the network is unavailable.`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (!online && unresolved.length > 0) {
|
|
436
|
+
logger_1.logger.warn(`Not checked offline (tags need the network): ${unresolved.map((a) => a.uses).join(', ')}`);
|
|
437
|
+
}
|
|
325
438
|
diff.added.forEach((u) => logger_1.logger.warn(`New action not in ${lockName}: ${u}`));
|
|
326
|
-
// actions.lock
|
|
327
|
-
// doesn't
|
|
439
|
+
// actions.lock is written by `gh actions-lock`, which may record entries
|
|
440
|
+
// SecureCI doesn't follow, so "no longer used" is only for secureci.lock.
|
|
441
|
+
// A reference still in use but unresolved isn't "no longer used".
|
|
328
442
|
if (!github) {
|
|
329
|
-
|
|
443
|
+
const used = new Set(unresolved.map((a) => a.uses));
|
|
444
|
+
diff.removed
|
|
445
|
+
.filter((u) => !used.has(u))
|
|
446
|
+
.forEach((u) => logger_1.logger.info(`In ${lockName} but no longer used: ${u}`));
|
|
447
|
+
}
|
|
448
|
+
if (diff.identity.length > 0) {
|
|
449
|
+
logger_1.logger.error(`A locked repository is no longer the one that was locked. Verify it before trusting it, then ${fixHint}.`);
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
if (missingRepos.size > 0 || (online && unresolved.length > 0)) {
|
|
453
|
+
logger_1.logger.error(`Locked actions could not be verified. Check that each repository and ref still exists and is readable${(0, action_fetcher_1.githubToken)() ? '' : ' (set GITHUB_TOKEN for private repositories)'}.`);
|
|
454
|
+
process.exit(1);
|
|
330
455
|
}
|
|
331
456
|
if (diff.drifted.length > 0) {
|
|
332
457
|
logger_1.logger.error(`Action commits drifted from ${lockName}. Review the change, then ${fixHint}.`);
|
package/dist/github-lockfile.js
CHANGED
|
@@ -36,6 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.GITHUB_LOCKFILE = exports.SECURECI_LOCKFILE = void 0;
|
|
37
37
|
exports.findLockfile = findLockfile;
|
|
38
38
|
exports.parseGitHubLockfile = parseGitHubLockfile;
|
|
39
|
+
exports.parseSecureciLockfile = parseSecureciLockfile;
|
|
40
|
+
exports.serializeSecureciLockfile = serializeSecureciLockfile;
|
|
41
|
+
exports.readLockfile = readLockfile;
|
|
39
42
|
const fs = __importStar(require("fs"));
|
|
40
43
|
const path = __importStar(require("path"));
|
|
41
44
|
const YAML = __importStar(require("yaml"));
|
|
@@ -66,10 +69,23 @@ function findLockfile(repoRoot) {
|
|
|
66
69
|
return { kind: 'github', file: github };
|
|
67
70
|
return undefined;
|
|
68
71
|
}
|
|
72
|
+
/** A positive integer id, or undefined. */
|
|
73
|
+
function id(value) {
|
|
74
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0
|
|
75
|
+
? value
|
|
76
|
+
: undefined;
|
|
77
|
+
}
|
|
78
|
+
function entry(commit, ownerId, repoId, via) {
|
|
79
|
+
return {
|
|
80
|
+
commit,
|
|
81
|
+
...(ownerId !== undefined ? { ownerId } : {}),
|
|
82
|
+
...(repoId !== undefined ? { repoId } : {}),
|
|
83
|
+
...(via && via.length > 0 ? { via } : {})
|
|
84
|
+
};
|
|
85
|
+
}
|
|
69
86
|
/**
|
|
70
|
-
* Read GitHub's `actions.lock
|
|
71
|
-
* `
|
|
72
|
-
* actions use), which SecureCI doesn't scan.
|
|
87
|
+
* Read GitHub's `actions.lock`: each dependency's commit and repository
|
|
88
|
+
* identity (`owner_id`, `repo_id`). It includes transitive dependencies.
|
|
73
89
|
*/
|
|
74
90
|
function parseGitHubLockfile(text) {
|
|
75
91
|
const doc = YAML.parse(text);
|
|
@@ -83,8 +99,62 @@ function parseGitHubLockfile(text) {
|
|
|
83
99
|
if (!match) {
|
|
84
100
|
throw new Error(`actions.lock entry ${uses} has no sha1 commit (got "${String(dep?.commit)}").`);
|
|
85
101
|
}
|
|
86
|
-
actions[uses] = match[1];
|
|
102
|
+
actions[uses] = entry(match[1], id(dep?.owner_id), id(dep?.repo_id));
|
|
87
103
|
}
|
|
88
|
-
return { version:
|
|
104
|
+
return { version: 2, actions };
|
|
105
|
+
}
|
|
106
|
+
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
107
|
+
/**
|
|
108
|
+
* Read `secureci.lock`. Version 1 maps each reference to a commit; version
|
|
109
|
+
* 2 maps it to `{ commit, owner_id?, repo_id?, via? }`.
|
|
110
|
+
*/
|
|
111
|
+
function parseSecureciLockfile(text) {
|
|
112
|
+
const doc = JSON.parse(text);
|
|
113
|
+
const raw = (doc?.actions ?? {});
|
|
114
|
+
const actions = {};
|
|
115
|
+
const bad = (uses) => new Error(`${exports.SECURECI_LOCKFILE} entry ${uses} has no valid commit.`);
|
|
116
|
+
if (doc?.version === 1) {
|
|
117
|
+
for (const [uses, commit] of Object.entries(raw)) {
|
|
118
|
+
if (typeof commit !== 'string' || !FULL_SHA.test(commit))
|
|
119
|
+
throw bad(uses);
|
|
120
|
+
actions[uses] = { commit };
|
|
121
|
+
}
|
|
122
|
+
return { version: 1, actions };
|
|
123
|
+
}
|
|
124
|
+
if (doc?.version === 2) {
|
|
125
|
+
for (const [uses, value] of Object.entries(raw)) {
|
|
126
|
+
const e = (value ?? {});
|
|
127
|
+
if (typeof e.commit !== 'string' || !FULL_SHA.test(e.commit)) {
|
|
128
|
+
throw bad(uses);
|
|
129
|
+
}
|
|
130
|
+
const via = Array.isArray(e.via)
|
|
131
|
+
? e.via.filter((v) => typeof v === 'string')
|
|
132
|
+
: undefined;
|
|
133
|
+
actions[uses] = entry(e.commit, id(e.owner_id), id(e.repo_id), via);
|
|
134
|
+
}
|
|
135
|
+
return { version: 2, actions };
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Unsupported ${exports.SECURECI_LOCKFILE} version "${String(doc?.version)}". Update @secureci/cli.`);
|
|
138
|
+
}
|
|
139
|
+
/** `secureci.lock` (version 2) for these entries, keys sorted. */
|
|
140
|
+
function serializeSecureciLockfile(actions) {
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const uses of Object.keys(actions).sort()) {
|
|
143
|
+
const e = actions[uses];
|
|
144
|
+
out[uses] = {
|
|
145
|
+
commit: e.commit,
|
|
146
|
+
...(e.ownerId !== undefined ? { owner_id: e.ownerId } : {}),
|
|
147
|
+
...(e.repoId !== undefined ? { repo_id: e.repoId } : {}),
|
|
148
|
+
...(e.via && e.via.length > 0 ? { via: e.via } : {})
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return JSON.stringify({ version: 2, actions: out }, null, 2) + '\n';
|
|
152
|
+
}
|
|
153
|
+
/** Read whichever lockfile `findLockfile` found. */
|
|
154
|
+
function readLockfile(source) {
|
|
155
|
+
const text = fs.readFileSync(source.file, 'utf-8');
|
|
156
|
+
return source.kind === 'github'
|
|
157
|
+
? parseGitHubLockfile(text)
|
|
158
|
+
: parseSecureciLockfile(text);
|
|
89
159
|
}
|
|
90
160
|
//# sourceMappingURL=github-lockfile.js.map
|
package/dist/inventory.js
CHANGED
|
@@ -35,8 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.collectReferences = collectReferences;
|
|
37
37
|
exports.buildInventory = buildInventory;
|
|
38
|
+
exports.addTransitive = addTransitive;
|
|
38
39
|
exports.diffLock = diffLock;
|
|
39
|
-
exports.lockEntries = lockEntries;
|
|
40
40
|
const fs = __importStar(require("fs"));
|
|
41
41
|
const path = __importStar(require("path"));
|
|
42
42
|
const workflow_parser_1 = require("./parser/workflow-parser");
|
|
@@ -70,8 +70,13 @@ function classify(uses, kind) {
|
|
|
70
70
|
* actions (`./path`) and local reusable workflows (`jobs.<id>.uses: ./x.yml`)
|
|
71
71
|
* so wrapped references are still listed. Remote references are listed but
|
|
72
72
|
* not followed. Order is the walk order; each carries its call path.
|
|
73
|
+
*
|
|
74
|
+
* Also takes a composite action (its steps). `localActions: false` lists
|
|
75
|
+
* but doesn't follow `./` actions: in a downloaded action or workflow they
|
|
76
|
+
* resolve against whatever the caller checked out, which isn't known here.
|
|
73
77
|
*/
|
|
74
|
-
function collectReferences(parsed) {
|
|
78
|
+
function collectReferences(parsed, options = {}) {
|
|
79
|
+
const followLocalActions = options.localActions ?? true;
|
|
75
80
|
const root = (0, repository_1.repositoryRoot)(parsed.path);
|
|
76
81
|
const refs = [];
|
|
77
82
|
const resolveLocal = (uses, names) => {
|
|
@@ -92,7 +97,7 @@ function collectReferences(parsed) {
|
|
|
92
97
|
return;
|
|
93
98
|
const ref = classify(step.uses, 'action');
|
|
94
99
|
refs.push({ ...ref, path: at });
|
|
95
|
-
if (!isLocal(step.uses))
|
|
100
|
+
if (!isLocal(step.uses) || !followLocalActions)
|
|
96
101
|
return;
|
|
97
102
|
const file = resolveLocal(step.uses, ['action.yml', 'action.yaml']);
|
|
98
103
|
if (!file || stack.includes(file) || stack.length >= MAX_DEPTH)
|
|
@@ -137,7 +142,12 @@ function collectReferences(parsed) {
|
|
|
137
142
|
(job.steps ?? []).forEach((step, i) => addStep(step, `${jobPath}.steps[${i}]`, stack));
|
|
138
143
|
}
|
|
139
144
|
};
|
|
140
|
-
|
|
145
|
+
if (parsed.type === 'workflow') {
|
|
146
|
+
addWorkflow(parsed.file, '', [path.resolve(parsed.path)]);
|
|
147
|
+
}
|
|
148
|
+
else if (parsed.file.runs.using === 'composite') {
|
|
149
|
+
(parsed.file.runs.steps ?? []).forEach((s, i) => addStep(s, `steps[${i}]`, [path.resolve(parsed.path)]));
|
|
150
|
+
}
|
|
141
151
|
return refs;
|
|
142
152
|
}
|
|
143
153
|
/**
|
|
@@ -150,22 +160,10 @@ async function buildInventory(parsed, resolveSha) {
|
|
|
150
160
|
const remote = references.filter((r) => r.kind === 'action' || r.kind === 'reusable-workflow');
|
|
151
161
|
const byUses = new Map();
|
|
152
162
|
for (const ref of remote) {
|
|
153
|
-
|
|
163
|
+
const action = byUses.get(ref.uses) ?? toAction(ref);
|
|
164
|
+
if (!action)
|
|
154
165
|
continue;
|
|
155
|
-
|
|
156
|
-
if (!action) {
|
|
157
|
-
action = {
|
|
158
|
-
uses: ref.uses,
|
|
159
|
-
owner: ref.owner,
|
|
160
|
-
repo: ref.repo,
|
|
161
|
-
subpath: ref.subpath,
|
|
162
|
-
ref: ref.ref,
|
|
163
|
-
pinned: ref.pinned,
|
|
164
|
-
resolvedSha: ref.ref && FULL_SHA.test(ref.ref) ? ref.ref.toLowerCase() : undefined,
|
|
165
|
-
paths: []
|
|
166
|
-
};
|
|
167
|
-
byUses.set(ref.uses, action);
|
|
168
|
-
}
|
|
166
|
+
byUses.set(ref.uses, action);
|
|
169
167
|
action.paths.push(ref.path);
|
|
170
168
|
}
|
|
171
169
|
if (resolveSha) {
|
|
@@ -181,32 +179,118 @@ async function buildInventory(parsed, resolveSha) {
|
|
|
181
179
|
references
|
|
182
180
|
};
|
|
183
181
|
}
|
|
184
|
-
/**
|
|
182
|
+
/** A remote reference as an inventory entry (no paths yet), or null. */
|
|
183
|
+
function toAction(ref) {
|
|
184
|
+
if (!ref.owner || !ref.repo)
|
|
185
|
+
return null;
|
|
186
|
+
if (ref.kind !== 'action' && ref.kind !== 'reusable-workflow')
|
|
187
|
+
return null;
|
|
188
|
+
return {
|
|
189
|
+
uses: ref.uses,
|
|
190
|
+
kind: ref.kind,
|
|
191
|
+
owner: ref.owner,
|
|
192
|
+
repo: ref.repo,
|
|
193
|
+
subpath: ref.subpath,
|
|
194
|
+
ref: ref.ref,
|
|
195
|
+
pinned: ref.pinned,
|
|
196
|
+
resolvedSha: ref.ref && FULL_SHA.test(ref.ref) ? ref.ref.toLowerCase() : undefined,
|
|
197
|
+
paths: []
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The inventory plus every transitive dependency: remote composite actions
|
|
202
|
+
* and remote reusable workflows are downloaded at their resolved commit
|
|
203
|
+
* (so what's read is what's locked) and their own `uses:` added, up to
|
|
204
|
+
* MAX_DEPTH levels. A dependency records `via`, the references that pull it
|
|
205
|
+
* in. Node and Docker actions have no `uses:` to follow.
|
|
206
|
+
*/
|
|
207
|
+
async function addTransitive(direct, deps) {
|
|
208
|
+
const byUses = new Map(direct.map((a) => [a.uses, a]));
|
|
209
|
+
const queue = direct.map((action) => ({ action, depth: 1 }));
|
|
210
|
+
while (queue.length > 0) {
|
|
211
|
+
const { action, depth } = queue.shift();
|
|
212
|
+
const at = action.resolvedSha ?? action.ref;
|
|
213
|
+
if (!at || depth > MAX_DEPTH)
|
|
214
|
+
continue;
|
|
215
|
+
const name = [action.owner, action.repo, action.subpath]
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.join('/');
|
|
218
|
+
const file = await deps.fetch(`${name}@${at}`, action.kind);
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = file ? (0, workflow_parser_1.parseFile)(file) : undefined;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
parsed = undefined;
|
|
225
|
+
}
|
|
226
|
+
if (!parsed) {
|
|
227
|
+
deps.warn(`Could not read ${action.uses}, so the actions it uses are not listed.`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
for (const ref of collectReferences(parsed, { localActions: false })) {
|
|
231
|
+
const refPath = `${action.uses} > ${ref.path}`;
|
|
232
|
+
const known = byUses.get(ref.uses);
|
|
233
|
+
if (known) {
|
|
234
|
+
known.paths.push(refPath);
|
|
235
|
+
if (known.via && !known.via.includes(action.uses)) {
|
|
236
|
+
known.via = [...known.via, action.uses].sort();
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const dep = toAction(ref);
|
|
241
|
+
if (!dep)
|
|
242
|
+
continue;
|
|
243
|
+
dep.paths.push(refPath);
|
|
244
|
+
dep.via = [action.uses];
|
|
245
|
+
if (!dep.resolvedSha && deps.resolveSha) {
|
|
246
|
+
dep.resolvedSha = await deps.resolveSha(dep.uses);
|
|
247
|
+
}
|
|
248
|
+
byUses.set(dep.uses, dep);
|
|
249
|
+
queue.push({ action: dep, depth: depth + 1 });
|
|
250
|
+
deps.onQueue?.(dep);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return [...byUses.values()];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Compare the current entries against a lockfile: moved commits, new and
|
|
257
|
+
* removed refs, and repositories whose identity changed (compared only
|
|
258
|
+
* when both sides know it).
|
|
259
|
+
*/
|
|
185
260
|
function diffLock(current, lock) {
|
|
186
261
|
const drifted = [];
|
|
262
|
+
const identity = [];
|
|
187
263
|
const added = [];
|
|
188
|
-
for (const [uses,
|
|
264
|
+
for (const [uses, entry] of Object.entries(current)) {
|
|
189
265
|
const known = lock.actions[uses];
|
|
190
|
-
if (known === undefined)
|
|
266
|
+
if (known === undefined) {
|
|
191
267
|
added.push(uses);
|
|
192
|
-
|
|
193
|
-
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (known.commit !== entry.commit) {
|
|
271
|
+
drifted.push({ uses, from: known.commit, to: entry.commit });
|
|
272
|
+
}
|
|
273
|
+
for (const [field, key] of [
|
|
274
|
+
['owner_id', 'ownerId'],
|
|
275
|
+
['repo_id', 'repoId']
|
|
276
|
+
]) {
|
|
277
|
+
const from = known[key];
|
|
278
|
+
const to = entry[key];
|
|
279
|
+
if (from !== undefined && to !== undefined && from !== to) {
|
|
280
|
+
identity.push({ uses, field, from, to });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
194
283
|
}
|
|
195
284
|
const removed = Object.keys(lock.actions).filter((uses) => !(uses in current));
|
|
196
285
|
return {
|
|
197
|
-
changed: drifted.length > 0 ||
|
|
286
|
+
changed: drifted.length > 0 ||
|
|
287
|
+
added.length > 0 ||
|
|
288
|
+
removed.length > 0 ||
|
|
289
|
+
identity.length > 0,
|
|
198
290
|
drifted,
|
|
199
291
|
added: added.sort(),
|
|
200
|
-
removed: removed.sort()
|
|
292
|
+
removed: removed.sort(),
|
|
293
|
+
identity
|
|
201
294
|
};
|
|
202
295
|
}
|
|
203
|
-
/** The `uses -> resolvedSha` map an inventory contributes to a lockfile. */
|
|
204
|
-
function lockEntries(inv) {
|
|
205
|
-
const entries = {};
|
|
206
|
-
for (const action of inv.actions) {
|
|
207
|
-
if (action.resolvedSha)
|
|
208
|
-
entries[action.uses] = action.resolvedSha;
|
|
209
|
-
}
|
|
210
|
-
return entries;
|
|
211
|
-
}
|
|
212
296
|
//# sourceMappingURL=inventory.js.map
|
|
@@ -96,6 +96,21 @@ function parsePipelineContract(filePath) {
|
|
|
96
96
|
result.pins.allow = parseStringList(pins.allow, 'pins.allow');
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
if (spec.lockfile !== undefined) {
|
|
100
|
+
if (typeof spec.lockfile !== 'object' ||
|
|
101
|
+
spec.lockfile === null ||
|
|
102
|
+
Array.isArray(spec.lockfile)) {
|
|
103
|
+
throw new Error('Contract "lockfile" must be a mapping');
|
|
104
|
+
}
|
|
105
|
+
const lockfile = spec.lockfile;
|
|
106
|
+
result.lockfile = {};
|
|
107
|
+
if (lockfile.required !== undefined) {
|
|
108
|
+
if (typeof lockfile.required !== 'boolean') {
|
|
109
|
+
throw new Error('Contract "lockfile.required" must be true or false');
|
|
110
|
+
}
|
|
111
|
+
result.lockfile.required = lockfile.required;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
99
114
|
if (spec['allowed-owners'] !== undefined) {
|
|
100
115
|
result.allowedOwners = parseStringList(spec['allowed-owners'], 'allowed-owners');
|
|
101
116
|
}
|
|
@@ -40,6 +40,7 @@ const job_plan_1 = require("../runner/job-plan");
|
|
|
40
40
|
const workflow_parser_1 = require("./workflow-parser");
|
|
41
41
|
const repository_1 = require("../utils/repository");
|
|
42
42
|
const action_fetcher_1 = require("../runner/action-fetcher");
|
|
43
|
+
const github_lockfile_1 = require("../github-lockfile");
|
|
43
44
|
/** Maximum nesting of composite actions / reusable workflows. */
|
|
44
45
|
const MAX_DEPTH = 10;
|
|
45
46
|
/**
|
|
@@ -195,11 +196,55 @@ async function validatePipelineContract(parsed, spec, options = {}) {
|
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
}
|
|
199
|
+
if (spec.lockfile?.required) {
|
|
200
|
+
errors.push(...checkLockfile(parsed, sequence.filter(isRemote)));
|
|
201
|
+
}
|
|
198
202
|
return {
|
|
199
203
|
valid: errors.every((e) => e.severity !== 'error'),
|
|
200
204
|
errors
|
|
201
205
|
};
|
|
202
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* `lockfile.required`: the repository commits a lockfile (secureci.lock,
|
|
209
|
+
* else GitHub's actions.lock) and it lists every remote reference used.
|
|
210
|
+
* Offline: whether the locked commits still match is `inventory --check`.
|
|
211
|
+
*/
|
|
212
|
+
function checkLockfile(parsed, remote) {
|
|
213
|
+
const source = (0, github_lockfile_1.findLockfile)((0, repository_1.repositoryRoot)(parsed.path));
|
|
214
|
+
if (!source) {
|
|
215
|
+
return [
|
|
216
|
+
{
|
|
217
|
+
path: 'lockfile',
|
|
218
|
+
message: 'The contract requires a lockfile, but the repository has no secureci.lock (or .github/workflows/actions.lock). Create one with: secureci inventory --update-lock',
|
|
219
|
+
severity: 'error'
|
|
220
|
+
}
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
const name = path.basename(source.file);
|
|
224
|
+
let locked;
|
|
225
|
+
try {
|
|
226
|
+
locked = new Set(Object.keys((0, github_lockfile_1.readLockfile)(source).actions).map((uses) => {
|
|
227
|
+
const { name: n, version } = splitActionRef(uses);
|
|
228
|
+
return version === undefined ? n : `${n}@${version}`;
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
return [
|
|
233
|
+
{
|
|
234
|
+
path: 'lockfile',
|
|
235
|
+
message: `Could not read ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
|
236
|
+
severity: 'error'
|
|
237
|
+
}
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
return remote
|
|
241
|
+
.filter((u) => !locked.has(`${u.name}@${u.version}`))
|
|
242
|
+
.map((u) => ({
|
|
243
|
+
path: u.path,
|
|
244
|
+
message: `Action "${u.name}${u.version ? '@' + u.version : ''}" is not in ${name}; lock it with ${source.kind === 'github' ? '`gh actions-lock`' : '`secureci inventory --update-lock`'}`,
|
|
245
|
+
severity: 'error'
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
203
248
|
/** Whether job `from` (transitively) needs job `to`. */
|
|
204
249
|
function needsTransitively(nodes, from, to) {
|
|
205
250
|
const seen = new Set();
|
|
@@ -42,6 +42,9 @@ exports.fetchAction = fetchAction;
|
|
|
42
42
|
exports.fetchRepoFile = fetchRepoFile;
|
|
43
43
|
exports.clearActionCache = clearActionCache;
|
|
44
44
|
exports.resolveActionSha = resolveActionSha;
|
|
45
|
+
exports.resolveRepoIdentity = resolveRepoIdentity;
|
|
46
|
+
exports.lsRemoteReachable = lsRemoteReachable;
|
|
47
|
+
exports.repoExists = repoExists;
|
|
45
48
|
const fs = __importStar(require("fs"));
|
|
46
49
|
const path = __importStar(require("path"));
|
|
47
50
|
const http = __importStar(require("http"));
|
|
@@ -428,4 +431,69 @@ async function resolveActionSha(uses, options = {}) {
|
|
|
428
431
|
}
|
|
429
432
|
return undefined;
|
|
430
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* GitHub's numeric ids for a repository and its owner, from the REST API,
|
|
436
|
+
* or undefined if they can't be looked up (offline, rate-limited, gone).
|
|
437
|
+
* The ids survive a rename but not a delete-and-recreate, so a changed id
|
|
438
|
+
* means a different repository now answers to the same name.
|
|
439
|
+
*/
|
|
440
|
+
async function resolveRepoIdentity(owner, repo, options = {}) {
|
|
441
|
+
const timeoutMs = options.timeoutMs ?? DOWNLOAD_TIMEOUT_MS;
|
|
442
|
+
const token = options.token === undefined ? githubToken() : options.token;
|
|
443
|
+
const lookup = async (auth) => {
|
|
444
|
+
try {
|
|
445
|
+
const body = JSON.parse(await getText(`${apiUrl()}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { ...requestHeaders(auth), Accept: 'application/vnd.github+json' }, timeoutMs));
|
|
446
|
+
const repoId = body.id;
|
|
447
|
+
const ownerId = body.owner?.id;
|
|
448
|
+
return typeof repoId === 'number' && typeof ownerId === 'number'
|
|
449
|
+
? { identity: { ownerId, repoId } }
|
|
450
|
+
: {};
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
return { unauthorized: error.status === 401 };
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
const first = await lookup(token);
|
|
457
|
+
if (first.identity)
|
|
458
|
+
return first.identity;
|
|
459
|
+
// A rejected token shouldn't block the lookup: retry anonymously.
|
|
460
|
+
if (token && first.unauthorized)
|
|
461
|
+
return (await lookup(null)).identity;
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
/** Whether `git ls-remote` can read the repository at `url`. */
|
|
465
|
+
async function lsRemoteReachable(url) {
|
|
466
|
+
try {
|
|
467
|
+
await execFileAsync('git', ['ls-remote', url, 'HEAD'], {
|
|
468
|
+
encoding: 'utf8',
|
|
469
|
+
timeout: 15000,
|
|
470
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
471
|
+
});
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Whether `owner/repo` can still be read: via the API when a token is
|
|
480
|
+
* available (404 means no), otherwise (or if the API can't answer) via
|
|
481
|
+
* anonymous `git ls-remote`. False also when the network is down.
|
|
482
|
+
*/
|
|
483
|
+
async function repoExists(owner, repo, options = {}) {
|
|
484
|
+
const timeoutMs = options.timeoutMs ?? DOWNLOAD_TIMEOUT_MS;
|
|
485
|
+
const token = options.token === undefined ? githubToken() : options.token;
|
|
486
|
+
if (token) {
|
|
487
|
+
try {
|
|
488
|
+
await getText(`${apiUrl()}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { ...requestHeaders(token), Accept: 'application/vnd.github+json' }, timeoutMs);
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
if (error.status === 404)
|
|
493
|
+
return false;
|
|
494
|
+
// 401, rate limit or network: fall back to anonymous git.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return lsRemoteReachable(`${serverUrl()}/${owner}/${repo}.git`);
|
|
498
|
+
}
|
|
431
499
|
//# sourceMappingURL=action-fetcher.js.map
|
package/dist/utils/logger.js
CHANGED
|
@@ -17,6 +17,16 @@ class Logger {
|
|
|
17
17
|
setLevel(level) {
|
|
18
18
|
this.level = level;
|
|
19
19
|
}
|
|
20
|
+
getLevel() {
|
|
21
|
+
return this.level;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Run `hook` before each message is written (e.g. to clear an in-place
|
|
25
|
+
* progress line); undefined removes it.
|
|
26
|
+
*/
|
|
27
|
+
setBeforeWrite(hook) {
|
|
28
|
+
this.beforeWrite = hook;
|
|
29
|
+
}
|
|
20
30
|
/**
|
|
21
31
|
* Register secret values so they get masked (replaced with `***`) in any
|
|
22
32
|
* subsequent log output, mirroring GitHub Actions' own secret masking.
|
|
@@ -48,35 +58,42 @@ class Logger {
|
|
|
48
58
|
}
|
|
49
59
|
debug(message, ...args) {
|
|
50
60
|
if (this.level <= LogLevel.DEBUG) {
|
|
61
|
+
this.beforeWrite?.();
|
|
51
62
|
console.log(ansi_1.colors.gray(`[DEBUG] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
|
|
52
63
|
}
|
|
53
64
|
}
|
|
54
65
|
info(message, ...args) {
|
|
55
66
|
if (this.level <= LogLevel.INFO) {
|
|
67
|
+
this.beforeWrite?.();
|
|
56
68
|
console.log(ansi_1.colors.blue(`[INFO] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
|
|
57
69
|
}
|
|
58
70
|
}
|
|
59
71
|
success(message, ...args) {
|
|
60
72
|
if (this.level <= LogLevel.INFO) {
|
|
73
|
+
this.beforeWrite?.();
|
|
61
74
|
console.log(ansi_1.colors.green(`✓ ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
77
|
warn(message, ...args) {
|
|
65
78
|
if (this.level <= LogLevel.WARN) {
|
|
79
|
+
this.beforeWrite?.();
|
|
66
80
|
console.warn(ansi_1.colors.yellow(`[WARN] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
|
|
67
81
|
}
|
|
68
82
|
}
|
|
69
83
|
error(message, ...args) {
|
|
70
84
|
if (this.level <= LogLevel.ERROR) {
|
|
85
|
+
this.beforeWrite?.();
|
|
71
86
|
console.error(ansi_1.colors.red(`[ERROR] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
|
|
72
87
|
}
|
|
73
88
|
}
|
|
74
89
|
step(message) {
|
|
90
|
+
this.beforeWrite?.();
|
|
75
91
|
console.log(ansi_1.colors.cyan(`\n→ ${this.mask(message)}`));
|
|
76
92
|
}
|
|
77
93
|
result(message, success) {
|
|
78
94
|
const icon = success ? '✓' : '✗';
|
|
79
95
|
const color = success ? ansi_1.colors.green : ansi_1.colors.red;
|
|
96
|
+
this.beforeWrite?.();
|
|
80
97
|
console.log(color(`${icon} ${this.mask(message)}`));
|
|
81
98
|
}
|
|
82
99
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Progress = void 0;
|
|
4
|
+
const logger_1 = require("./logger");
|
|
5
|
+
const CLEAR_LINE = '\r\x1b[K';
|
|
6
|
+
/**
|
|
7
|
+
* Progress for a slow phase, e.g. resolving actions over the network. On a
|
|
8
|
+
* terminal it updates one line in place (`Label [3/10] item`); elsewhere
|
|
9
|
+
* (CI logs) it prints a line per item. The total can grow as work is found.
|
|
10
|
+
*/
|
|
11
|
+
class Progress {
|
|
12
|
+
constructor(label, total, out = process.stderr) {
|
|
13
|
+
this.label = label;
|
|
14
|
+
this.total = total;
|
|
15
|
+
this.out = out;
|
|
16
|
+
this.count = 0;
|
|
17
|
+
this.drawn = false;
|
|
18
|
+
this.tty = !!out.isTTY;
|
|
19
|
+
// A log line would otherwise be appended to the half-drawn line.
|
|
20
|
+
if (this.tty)
|
|
21
|
+
logger_1.logger.setBeforeWrite(() => this.clear());
|
|
22
|
+
}
|
|
23
|
+
/** More items were found to process. */
|
|
24
|
+
grow(n = 1) {
|
|
25
|
+
this.total += n;
|
|
26
|
+
}
|
|
27
|
+
tick(item) {
|
|
28
|
+
this.count++;
|
|
29
|
+
if (this.count > this.total)
|
|
30
|
+
this.total = this.count;
|
|
31
|
+
const line = `${this.label} [${this.count}/${this.total}] ${item}`;
|
|
32
|
+
if (this.tty) {
|
|
33
|
+
this.out.write(CLEAR_LINE + line);
|
|
34
|
+
this.drawn = true;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
this.out.write(line + '\n');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** End the phase, leaving `summary` (if any work was done). */
|
|
41
|
+
done(summary) {
|
|
42
|
+
if (this.tty)
|
|
43
|
+
logger_1.logger.setBeforeWrite(undefined);
|
|
44
|
+
if (this.count === 0)
|
|
45
|
+
return;
|
|
46
|
+
this.clear();
|
|
47
|
+
if (summary)
|
|
48
|
+
this.out.write(summary + '\n');
|
|
49
|
+
}
|
|
50
|
+
clear() {
|
|
51
|
+
if (this.drawn) {
|
|
52
|
+
this.out.write(CLEAR_LINE);
|
|
53
|
+
this.drawn = false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.Progress = Progress;
|
|
58
|
+
//# sourceMappingURL=progress.js.map
|