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.
@@ -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