code-foundry 1.47.5 → 1.48.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/.github/workflows/ci.yml +12 -0
- package/.github/workflows/cloudflare-delivery.yml +8 -1
- package/.github/workflows/cloudflare-deploy.yml +8 -1
- package/.github/workflows/eval.yml +3 -0
- package/.github/workflows/test.yml +15 -0
- package/.github/workflows/validation-billing.yml +16 -0
- package/CHANGELOG.md +15 -15
- package/docs/CACHING.md +84 -0
- package/docs/CONFIGURATION.md +13 -0
- package/docs/README.md +1 -1
- package/package.json +2 -1
- package/src/cli.mjs +27 -4
- package/src/commands/doctor.mjs +5 -1
- package/src/commands/sync.mjs +1 -0
- package/src/lib/profile.mjs +120 -0
- package/src/lib/task-filters.mjs +380 -0
- package/.github/actions/attest-artifact/action.yml +0 -56
- package/.github/actions/cache/action.yml +0 -47
- package/.github/actions/codeql/action.yml +0 -33
- package/.github/actions/setup/action.yml +0 -890
package/src/lib/task-filters.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { listValue, readConfig } from './config.mjs'
|
|
7
|
+
import { resolveMonorepoTool } from './profile.mjs'
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Tasks that support `filter_<task>` path filters in code-foundry.yml. A
|
|
@@ -151,3 +155,379 @@ export function taskAffected(task, filters, paths) {
|
|
|
151
155
|
const matchers = globs.map(globToRegExp)
|
|
152
156
|
return paths.some((path) => matchers.some((matcher) => matcher.test(path)))
|
|
153
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @typedef {{ name: string, dir: string, dependencies: string[] }} WorkspacePackage
|
|
161
|
+
* `dir` is repo-relative with forward slashes; the workspace root is ''.
|
|
162
|
+
*/
|
|
163
|
+
/** @typedef {{ label: string, files: string[], read: (path: string) => string }} WorkspaceSource */
|
|
164
|
+
/**
|
|
165
|
+
* @typedef {{
|
|
166
|
+
* tool: 'turbo'|'vite-plus'|'none',
|
|
167
|
+
* base: string,
|
|
168
|
+
* head: string,
|
|
169
|
+
* all: boolean,
|
|
170
|
+
* conservative: boolean,
|
|
171
|
+
* reason: string,
|
|
172
|
+
* mergeBase?: string,
|
|
173
|
+
* changedFiles: number,
|
|
174
|
+
* packages: string[],
|
|
175
|
+
* removedPackages: string[],
|
|
176
|
+
* filterArgs: string[],
|
|
177
|
+
* }} AffectedResult
|
|
178
|
+
*/
|
|
179
|
+
|
|
180
|
+
const SAFE_REF = /^[A-Za-z0-9._/~^@{}-]+$/
|
|
181
|
+
const SAFE_PACKAGE_NAME = /^(?:@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/i
|
|
182
|
+
/** Workspace glob syntax this module cannot evaluate faithfully. */
|
|
183
|
+
const UNSUPPORTED_GLOB = /[{}[\]()+@\\]|^!!|\*\*[^/]|[^/]\*\*/
|
|
184
|
+
|
|
185
|
+
/** @param {string} ref @param {string} label */
|
|
186
|
+
function assertRef(ref, label) {
|
|
187
|
+
if (!ref || ref.startsWith('-') || !SAFE_REF.test(ref))
|
|
188
|
+
throw new Error(`invalid ${label} ref: ${ref}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @param {string} root @param {string[]} args */
|
|
192
|
+
const git = (root, args) =>
|
|
193
|
+
execFileSync('git', args, {
|
|
194
|
+
cwd: root,
|
|
195
|
+
encoding: 'utf8',
|
|
196
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
197
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Workspace files as of a git ref (committed objects, not the working tree).
|
|
202
|
+
* @param {string} root
|
|
203
|
+
* @param {string} ref
|
|
204
|
+
* @returns {WorkspaceSource}
|
|
205
|
+
*/
|
|
206
|
+
export function gitWorkspaceSource(root, ref) {
|
|
207
|
+
assertRef(ref, 'workspace')
|
|
208
|
+
const files = git(root, ['ls-tree', '-r', '-z', '--name-only', ref]).split('\0').filter(Boolean)
|
|
209
|
+
return { label: ref, files, read: (path) => git(root, ['show', `${ref}:${path}`]) }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Workspace globs from package.json `workspaces` and pnpm-workspace.yaml.
|
|
214
|
+
* Throws on syntax it cannot evaluate so callers fail closed.
|
|
215
|
+
* @param {WorkspaceSource} source
|
|
216
|
+
* @returns {string[]}
|
|
217
|
+
*/
|
|
218
|
+
export function workspaceGlobs(source) {
|
|
219
|
+
/** @type {string[]} */
|
|
220
|
+
const globs = []
|
|
221
|
+
if (source.files.includes('package.json')) {
|
|
222
|
+
let manifest
|
|
223
|
+
try {
|
|
224
|
+
manifest = JSON.parse(source.read('package.json'))
|
|
225
|
+
} catch {
|
|
226
|
+
throw new Error(`malformed package.json at ${source.label}`)
|
|
227
|
+
}
|
|
228
|
+
const workspaces = manifest.workspaces
|
|
229
|
+
const list = Array.isArray(workspaces) ? workspaces : workspaces?.packages
|
|
230
|
+
if (workspaces !== undefined && !Array.isArray(list))
|
|
231
|
+
throw new Error('unsupported workspaces declaration in package.json')
|
|
232
|
+
if (Array.isArray(list)) {
|
|
233
|
+
for (const item of list) {
|
|
234
|
+
if (typeof item !== 'string') throw new Error('non-string workspace glob in package.json')
|
|
235
|
+
globs.push(item)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (source.files.includes('pnpm-workspace.yaml')) {
|
|
240
|
+
let inPackages = false
|
|
241
|
+
for (const line of source.read('pnpm-workspace.yaml').split(/\r?\n/)) {
|
|
242
|
+
if (/^packages:\s*$/.test(line)) inPackages = true
|
|
243
|
+
else if (/^packages:\s*\S/.test(line)) {
|
|
244
|
+
// Flow style: packages: ['apps/*', 'packages/*']
|
|
245
|
+
const flow = line.match(/^packages:\s*\[(.*)\]\s*(?:#.*)?$/)
|
|
246
|
+
if (!flow) throw new Error('unsupported packages declaration in pnpm-workspace.yaml')
|
|
247
|
+
for (const part of flow[1].split(',')) {
|
|
248
|
+
const entry = part.trim()
|
|
249
|
+
if (!entry) continue
|
|
250
|
+
const item = entry.match(/^(['"]?)([^'"]+)\1$/)
|
|
251
|
+
if (!item) throw new Error(`unsupported pnpm-workspace.yaml entry: ${entry}`)
|
|
252
|
+
globs.push(item[2])
|
|
253
|
+
}
|
|
254
|
+
} else if (/^\S/.test(line)) inPackages = false
|
|
255
|
+
else if (inPackages) {
|
|
256
|
+
if (/^\s*(?:#.*)?$/.test(line)) continue
|
|
257
|
+
const item = line.match(/^\s*-\s*(['"]?)([^'"#]+?)\1\s*(?:#.*)?$/)
|
|
258
|
+
if (!item) throw new Error(`unsupported pnpm-workspace.yaml entry: ${line.trim()}`)
|
|
259
|
+
globs.push(item[2])
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const glob of globs) {
|
|
264
|
+
const body = glob.startsWith('!') ? glob.slice(1) : glob
|
|
265
|
+
if (!body || UNSUPPORTED_GLOB.test(body)) throw new Error(`unsupported workspace glob: ${glob}`)
|
|
266
|
+
}
|
|
267
|
+
return globs
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Workspace packages including the root. Throws when a member cannot be read or named.
|
|
272
|
+
* @param {WorkspaceSource} source
|
|
273
|
+
* @returns {WorkspacePackage[]}
|
|
274
|
+
*/
|
|
275
|
+
export function listWorkspacePackages(source) {
|
|
276
|
+
const globs = workspaceGlobs(source)
|
|
277
|
+
const includes = globs.filter((glob) => !glob.startsWith('!')).map(globToRegExp)
|
|
278
|
+
const excludes = globs
|
|
279
|
+
.filter((glob) => glob.startsWith('!'))
|
|
280
|
+
.map((glob) => globToRegExp(glob.slice(1)))
|
|
281
|
+
const directories = source.files
|
|
282
|
+
.filter((file) => file.endsWith('/package.json') && !file.split('/').includes('node_modules'))
|
|
283
|
+
.map((file) => file.slice(0, -'/package.json'.length))
|
|
284
|
+
.filter((dir) => includes.some((re) => re.test(dir)) && !excludes.some((re) => re.test(dir)))
|
|
285
|
+
|
|
286
|
+
/** @type {Array<{ name: string, dir: string, manifest: Record<string, any> }>} */
|
|
287
|
+
const manifests = []
|
|
288
|
+
/** @param {string} dir @param {boolean} isRoot */
|
|
289
|
+
const load = (dir, isRoot) => {
|
|
290
|
+
const path = dir ? `${dir}/package.json` : 'package.json'
|
|
291
|
+
let manifest
|
|
292
|
+
try {
|
|
293
|
+
manifest = JSON.parse(source.read(path))
|
|
294
|
+
} catch {
|
|
295
|
+
throw new Error(`malformed manifest ${path} at ${source.label}`)
|
|
296
|
+
}
|
|
297
|
+
if (typeof manifest.name === 'string' && manifest.name) {
|
|
298
|
+
manifests.push({ name: manifest.name, dir, manifest })
|
|
299
|
+
} else if (isRoot) {
|
|
300
|
+
manifests.push({ name: '//', dir, manifest })
|
|
301
|
+
} else {
|
|
302
|
+
throw new Error(`workspace package ${path} has no name`)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (source.files.includes('package.json')) load('', true)
|
|
306
|
+
for (const dir of directories) load(dir, false)
|
|
307
|
+
|
|
308
|
+
const seen = new Set()
|
|
309
|
+
for (const item of manifests) {
|
|
310
|
+
if (seen.has(item.name)) throw new Error(`duplicate workspace package name: ${item.name}`)
|
|
311
|
+
seen.add(item.name)
|
|
312
|
+
}
|
|
313
|
+
return manifests.map(({ name, dir, manifest }) => ({
|
|
314
|
+
name,
|
|
315
|
+
dir,
|
|
316
|
+
dependencies: [
|
|
317
|
+
...new Set(
|
|
318
|
+
['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].flatMap(
|
|
319
|
+
(table) =>
|
|
320
|
+
Object.keys(manifest[table] ?? {}).filter((dep) => seen.has(dep) && dep !== name)
|
|
321
|
+
)
|
|
322
|
+
),
|
|
323
|
+
],
|
|
324
|
+
}))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Changed files to affected packages across base and head graphs, so deleted,
|
|
329
|
+
* renamed, and re-wired packages keep their dependents. An unowned file selects
|
|
330
|
+
* every package unless `ignore` globs (`affected_ignore`) say otherwise.
|
|
331
|
+
* @param {WorkspacePackage[][]} graphs Head graph first.
|
|
332
|
+
* @param {string[]} changedFiles Repo-relative, forward slashes.
|
|
333
|
+
* @param {string[]} [ignore] Repo-relative globs of unowned files to skip.
|
|
334
|
+
* @returns {{ all: boolean, packages: string[], reason: string }}
|
|
335
|
+
*/
|
|
336
|
+
export function affectedFromChangedFiles(graphs, changedFiles, ignore = []) {
|
|
337
|
+
const ignored = ignore.map(globToRegExp)
|
|
338
|
+
const everything = [...new Set(graphs.flat().map((item) => item.name))].toSorted()
|
|
339
|
+
const direct = new Set()
|
|
340
|
+
for (const file of changedFiles) {
|
|
341
|
+
const owners = new Set()
|
|
342
|
+
for (const graph of graphs) {
|
|
343
|
+
const owner = graph
|
|
344
|
+
.filter((item) => item.dir !== '' && file.startsWith(`${item.dir}/`))
|
|
345
|
+
.toSorted((a, b) => b.dir.length - a.dir.length)[0]
|
|
346
|
+
if (owner) owners.add(owner.name)
|
|
347
|
+
}
|
|
348
|
+
if (owners.size > 0) {
|
|
349
|
+
for (const name of owners) direct.add(name)
|
|
350
|
+
} else if (!ignored.some((re) => re.test(file))) {
|
|
351
|
+
return { all: true, packages: everything, reason: `shared or unowned file changed: ${file}` }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const dependents = new Map()
|
|
356
|
+
for (const item of graphs.flat())
|
|
357
|
+
for (const dep of item.dependencies) {
|
|
358
|
+
if (!dependents.has(dep)) dependents.set(dep, new Set())
|
|
359
|
+
dependents.get(dep).add(item.name)
|
|
360
|
+
}
|
|
361
|
+
const affected = new Set(direct)
|
|
362
|
+
const queue = [...direct]
|
|
363
|
+
while (queue.length) {
|
|
364
|
+
for (const next of dependents.get(queue.pop()) ?? []) {
|
|
365
|
+
if (!affected.has(next)) {
|
|
366
|
+
affected.add(next)
|
|
367
|
+
queue.push(next)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { all: false, packages: [...affected].toSorted(), reason: 'git diff and workspace graphs' }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* `vp run` arguments selecting the given packages (the root is `-w`).
|
|
376
|
+
* @param {WorkspacePackage[]} packages
|
|
377
|
+
* @param {string[]} names
|
|
378
|
+
*/
|
|
379
|
+
export function vitePlusFilterArgs(packages, names) {
|
|
380
|
+
/** @type {string[]} */
|
|
381
|
+
const args = []
|
|
382
|
+
for (const name of names) {
|
|
383
|
+
const item = packages.find((candidate) => candidate.name === name)
|
|
384
|
+
if (item?.dir === '') args.push('-w')
|
|
385
|
+
else if (SAFE_PACKAGE_NAME.test(name)) args.push('--filter', name)
|
|
386
|
+
else throw new Error(`unsafe package name for --filter: ${name}`)
|
|
387
|
+
}
|
|
388
|
+
return args
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @param {string} root @param {string} base @param {string} head
|
|
393
|
+
* @returns {string[]}
|
|
394
|
+
*/
|
|
395
|
+
function gitChangedFiles(root, base, head) {
|
|
396
|
+
assertRef(base, 'base')
|
|
397
|
+
assertRef(head, 'head')
|
|
398
|
+
const output = git(root, ['diff', '--name-only', '--no-renames', '-z', `${base}...${head}`, '--'])
|
|
399
|
+
return output.split('\0').filter(Boolean)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* @param {string} root @param {string} base @param {string} head
|
|
404
|
+
* @param {(command: string, args: string[], cwd: string) => string} run
|
|
405
|
+
* @returns {string[]} affected package names
|
|
406
|
+
*/
|
|
407
|
+
function turboAffected(root, base, head, run) {
|
|
408
|
+
assertRef(base, 'base')
|
|
409
|
+
assertRef(head, 'head')
|
|
410
|
+
const local = join(root, 'node_modules/.bin/turbo')
|
|
411
|
+
const output = run(
|
|
412
|
+
existsSync(local) ? local : 'turbo',
|
|
413
|
+
['query', 'affected', '--packages', `--base=${base}`, `--head=${head}`, '--no-update-notifier'],
|
|
414
|
+
root
|
|
415
|
+
)
|
|
416
|
+
const result = JSON.parse(output)
|
|
417
|
+
const items = result?.data?.affectedPackages?.items ?? result?.data?.affectedTasks?.items
|
|
418
|
+
if (!Array.isArray(items)) throw new Error('Turbo affected query returned no package/task items')
|
|
419
|
+
return [
|
|
420
|
+
...new Set(
|
|
421
|
+
items
|
|
422
|
+
.map(
|
|
423
|
+
(/** @type {any} */ item) =>
|
|
424
|
+
item?.name ?? item?.package?.name ?? String(item?.fullName ?? '').split('#')[0]
|
|
425
|
+
)
|
|
426
|
+
.filter(Boolean)
|
|
427
|
+
),
|
|
428
|
+
].toSorted()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** @type {(command: string, args: string[], cwd: string) => string} */
|
|
432
|
+
const defaultRun = (command, args, cwd) =>
|
|
433
|
+
execFileSync(command, args, {
|
|
434
|
+
cwd,
|
|
435
|
+
encoding: 'utf8',
|
|
436
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
437
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
/** @param {unknown} error */
|
|
441
|
+
const describe = (error) => (error instanceof Error ? error.message.split('\n')[0] : String(error))
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Packages affected between two refs. Turbo uses `turbo query affected`; Vite+
|
|
445
|
+
* has no such query, so git and the workspace graphs at both refs decide.
|
|
446
|
+
* Anything unprovable is conservative (`all: true`).
|
|
447
|
+
* @param {string} root
|
|
448
|
+
* @param {{ base: string, head: string, tool?: string, run?: (command: string, args: string[], cwd: string) => string }} options
|
|
449
|
+
* @returns {AffectedResult}
|
|
450
|
+
*/
|
|
451
|
+
export function affectedPackages(root, options) {
|
|
452
|
+
const config = readConfig(join(root, '.github/code-foundry.yml'))
|
|
453
|
+
const requested = options.tool && options.tool !== 'auto' ? options.tool : undefined
|
|
454
|
+
const resolution = resolveMonorepoTool(
|
|
455
|
+
root,
|
|
456
|
+
requested ? { ...config, monorepo_tool: requested } : config
|
|
457
|
+
)
|
|
458
|
+
const { base, head } = options
|
|
459
|
+
|
|
460
|
+
/** @param {Partial<AffectedResult> & { reason: string }} partial @returns {AffectedResult} */
|
|
461
|
+
const result = (partial) => ({
|
|
462
|
+
tool: resolution.tool,
|
|
463
|
+
base,
|
|
464
|
+
head,
|
|
465
|
+
all: false,
|
|
466
|
+
conservative: false,
|
|
467
|
+
changedFiles: 0,
|
|
468
|
+
packages: [],
|
|
469
|
+
removedPackages: [],
|
|
470
|
+
filterArgs: [],
|
|
471
|
+
...partial,
|
|
472
|
+
})
|
|
473
|
+
/** @param {string} reason @param {string[]} [names] */
|
|
474
|
+
const conservative = (reason, names = []) =>
|
|
475
|
+
result({ all: true, conservative: true, reason, packages: names })
|
|
476
|
+
|
|
477
|
+
if (resolution.tool === 'none')
|
|
478
|
+
return conservative(
|
|
479
|
+
`no task runner configured (${resolution.reason}); treating all packages as affected`
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
if (resolution.tool === 'turbo') {
|
|
483
|
+
// Turbo owns its own graph; this module's workspace parsing is never a
|
|
484
|
+
// prerequisite for the Turbo path.
|
|
485
|
+
try {
|
|
486
|
+
return result({
|
|
487
|
+
reason: 'turbo query affected',
|
|
488
|
+
packages: turboAffected(root, base, head, options.run ?? defaultRun),
|
|
489
|
+
})
|
|
490
|
+
} catch (error) {
|
|
491
|
+
return conservative(
|
|
492
|
+
`affected selection failed (${describe(error)}); treating all packages as affected`
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** @type {string[]} */
|
|
498
|
+
let headNames = []
|
|
499
|
+
try {
|
|
500
|
+
assertRef(base, 'base')
|
|
501
|
+
assertRef(head, 'head')
|
|
502
|
+
const headPackages = listWorkspacePackages(gitWorkspaceSource(root, head))
|
|
503
|
+
headNames = headPackages.map((item) => item.name).toSorted()
|
|
504
|
+
// One merge-base serves both the historical graph and the diff, so
|
|
505
|
+
// divergent branches are compared against their real common ancestor.
|
|
506
|
+
const mergeBase = git(root, ['merge-base', base, head]).trim()
|
|
507
|
+
const basePackages = listWorkspacePackages(gitWorkspaceSource(root, mergeBase))
|
|
508
|
+
const files = gitChangedFiles(root, mergeBase, head)
|
|
509
|
+
const selection = affectedFromChangedFiles(
|
|
510
|
+
[headPackages, basePackages],
|
|
511
|
+
files,
|
|
512
|
+
listValue(config.affected_ignore ?? '')
|
|
513
|
+
)
|
|
514
|
+
const existing = selection.packages.filter((name) => headNames.includes(name))
|
|
515
|
+
const removed = selection.packages.filter((name) => !headNames.includes(name))
|
|
516
|
+
return result({
|
|
517
|
+
mergeBase,
|
|
518
|
+
reason: selection.reason,
|
|
519
|
+
changedFiles: files.length,
|
|
520
|
+
all: selection.all,
|
|
521
|
+
packages: selection.all ? headNames : existing,
|
|
522
|
+
removedPackages: selection.all ? [] : removed,
|
|
523
|
+
// An empty selection must stay empty: callers skip the run instead of
|
|
524
|
+
// passing no filters, which would run every package.
|
|
525
|
+
filterArgs: selection.all ? [] : vitePlusFilterArgs(headPackages, existing),
|
|
526
|
+
})
|
|
527
|
+
} catch (error) {
|
|
528
|
+
return conservative(
|
|
529
|
+
`affected selection failed (${describe(error)}); treating all packages as affected`,
|
|
530
|
+
headNames
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
name: Attest verified build artifacts
|
|
2
|
-
description: Generate checksums and signed provenance for explicitly selected build outputs.
|
|
3
|
-
inputs:
|
|
4
|
-
artifacts:
|
|
5
|
-
description: 'JSON array of nonempty regular artifact files relative to the workspace.'
|
|
6
|
-
required: true
|
|
7
|
-
outputs:
|
|
8
|
-
manifest-path:
|
|
9
|
-
description: Path to the generated SHA-256 JSON manifest.
|
|
10
|
-
value: ${{ steps.manifest.outputs.manifest-path }}
|
|
11
|
-
bundle-path:
|
|
12
|
-
description: Path to the signed provenance bundle.
|
|
13
|
-
value: ${{ steps.attest.outputs.bundle-path }}
|
|
14
|
-
attestation-url:
|
|
15
|
-
description: URL of the published attestation.
|
|
16
|
-
value: ${{ steps.attest.outputs.attestation-url }}
|
|
17
|
-
runs:
|
|
18
|
-
using: composite
|
|
19
|
-
steps:
|
|
20
|
-
- name: Select and hash build outputs
|
|
21
|
-
id: manifest
|
|
22
|
-
shell: bash
|
|
23
|
-
env:
|
|
24
|
-
ARTIFACTS: ${{ inputs.artifacts }}
|
|
25
|
-
run: |
|
|
26
|
-
set -euo pipefail
|
|
27
|
-
[[ "$GITHUB_EVENT_NAME" != pull_request && "$GITHUB_EVENT_NAME" != pull_request_target ]]
|
|
28
|
-
node --input-type=module <<'NODE'
|
|
29
|
-
import { appendFileSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
30
|
-
import { join, resolve } from 'node:path'
|
|
31
|
-
import { pathToFileURL } from 'node:url'
|
|
32
|
-
const helper = pathToFileURL(resolve(process.env.GITHUB_ACTION_PATH, '../../../src/commands/release-integrity.mjs'))
|
|
33
|
-
const { assetManifest } = await import(helper.href)
|
|
34
|
-
const files = JSON.parse(process.env.ARTIFACTS)
|
|
35
|
-
if (!Array.isArray(files) || files.some(file => typeof file !== 'string')) throw new Error('artifacts must be a JSON string array')
|
|
36
|
-
const manifest = assetManifest(process.env.GITHUB_WORKSPACE, files)
|
|
37
|
-
const directory = mkdtempSync(join(process.env.RUNNER_TEMP, 'foundry-attestation-'))
|
|
38
|
-
const manifestPath = join(directory, 'manifest.json')
|
|
39
|
-
const sumsPath = join(directory, 'SHA256SUMS')
|
|
40
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
|
|
41
|
-
writeFileSync(sumsPath, manifest.checksums)
|
|
42
|
-
appendFileSync(process.env.GITHUB_OUTPUT, `manifest-path=${manifestPath}\nchecksums-path=${sumsPath}\n`)
|
|
43
|
-
NODE
|
|
44
|
-
- name: Sign build provenance
|
|
45
|
-
id: attest
|
|
46
|
-
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
|
47
|
-
with:
|
|
48
|
-
subject-checksums: ${{ steps.manifest.outputs.checksums-path }}
|
|
49
|
-
- name: Verify artifacts were not changed during signing
|
|
50
|
-
shell: bash
|
|
51
|
-
env:
|
|
52
|
-
CHECKSUMS_PATH: ${{ steps.manifest.outputs.checksums-path }}
|
|
53
|
-
run: |
|
|
54
|
-
set -euo pipefail
|
|
55
|
-
cd "$GITHUB_WORKSPACE"
|
|
56
|
-
sha256sum --check "$CHECKSUMS_PATH"
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
name: Repository Cache
|
|
2
|
-
description: Restore a cache and optionally enable the post-job save performed by actions/cache.
|
|
3
|
-
|
|
4
|
-
inputs:
|
|
5
|
-
path:
|
|
6
|
-
description: Cache paths.
|
|
7
|
-
required: true
|
|
8
|
-
key:
|
|
9
|
-
description: Primary cache key.
|
|
10
|
-
required: true
|
|
11
|
-
restore-keys:
|
|
12
|
-
description: Optional newline-delimited restore keys.
|
|
13
|
-
required: false
|
|
14
|
-
default: ''
|
|
15
|
-
save:
|
|
16
|
-
description: Enable cache saving after the job completes.
|
|
17
|
-
required: false
|
|
18
|
-
default: 'true'
|
|
19
|
-
|
|
20
|
-
outputs:
|
|
21
|
-
cache-hit:
|
|
22
|
-
description: Whether an exact cache was restored.
|
|
23
|
-
value: ${{ steps.cache.outputs.cache-hit || steps.restore.outputs.cache-hit }}
|
|
24
|
-
|
|
25
|
-
runs:
|
|
26
|
-
using: composite
|
|
27
|
-
steps:
|
|
28
|
-
# Caches are restored on every event but saved on push only: scheduled
|
|
29
|
-
# and dispatched runs share the default branch with untrusted
|
|
30
|
-
# runtime-ref checkouts, so letting them save would allow cache
|
|
31
|
-
# poisoning. Push events require write access and stay trusted.
|
|
32
|
-
- name: Restore cache
|
|
33
|
-
id: restore
|
|
34
|
-
if: inputs.save != 'true' || github.event_name != 'push'
|
|
35
|
-
uses: actions/cache/restore@v5
|
|
36
|
-
with:
|
|
37
|
-
path: ${{ inputs.path }}
|
|
38
|
-
key: ${{ inputs.key }}
|
|
39
|
-
restore-keys: ${{ inputs.restore-keys }}
|
|
40
|
-
- name: Restore and save cache
|
|
41
|
-
id: cache
|
|
42
|
-
if: inputs.save == 'true' && github.event_name == 'push'
|
|
43
|
-
uses: actions/cache@v5
|
|
44
|
-
with:
|
|
45
|
-
path: ${{ inputs.path }}
|
|
46
|
-
key: ${{ inputs.key }}
|
|
47
|
-
restore-keys: ${{ inputs.restore-keys }}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
name: CodeQL Analyze
|
|
2
|
-
description: Initialize and analyze one CodeQL language using the repository baseline.
|
|
3
|
-
|
|
4
|
-
inputs:
|
|
5
|
-
language:
|
|
6
|
-
description: CodeQL language identifier.
|
|
7
|
-
required: true
|
|
8
|
-
build-mode:
|
|
9
|
-
description: CodeQL build mode.
|
|
10
|
-
required: false
|
|
11
|
-
default: none
|
|
12
|
-
category:
|
|
13
|
-
description: CodeQL analysis category.
|
|
14
|
-
required: true
|
|
15
|
-
|
|
16
|
-
runs:
|
|
17
|
-
using: composite
|
|
18
|
-
steps:
|
|
19
|
-
- name: Initialize
|
|
20
|
-
uses: github/codeql-action/init@v4
|
|
21
|
-
with:
|
|
22
|
-
languages: ${{ inputs.language }}
|
|
23
|
-
build-mode: ${{ inputs.build-mode }}
|
|
24
|
-
- name: Analyze
|
|
25
|
-
uses: github/codeql-action/analyze@v4
|
|
26
|
-
with:
|
|
27
|
-
category: ${{ inputs.category }}
|
|
28
|
-
# Pin pull-request uploads to the contributor's head SHA/ref so a
|
|
29
|
-
# moving merge ref cannot race the required code-scanning context.
|
|
30
|
-
ref: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
|
|
31
|
-
sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
|
32
|
-
upload-database: false
|
|
33
|
-
wait-for-processing: false
|