code-foundry 1.15.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/consumer-qualification.yml +95 -0
- package/.github/workflows/consumer-qualification_self-ci.yml +19 -0
- package/.github/workflows/release_self-ci.yml +10 -0
- package/CHANGELOG.md +7 -0
- package/docs/PERFORMANCE.md +8 -3
- package/docs/consumer-qualification.md +48 -0
- package/package.json +1 -1
- package/src/commands/sync.mjs +41 -3
- package/src/lib/consumer-qualification.mjs +273 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
name: Consumer Qualification
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_call:
|
|
5
|
+
inputs:
|
|
6
|
+
billing-pause-bypass:
|
|
7
|
+
description: Allow an explicitly dispatched release to qualify while shared CI remains paused.
|
|
8
|
+
required: false
|
|
9
|
+
type: boolean
|
|
10
|
+
default: false
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
actions: read
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
pack:
|
|
18
|
+
name: Pack candidate once
|
|
19
|
+
if: vars.CI_BILLING_PAUSED != 'true' || inputs['billing-pause-bypass'] == true
|
|
20
|
+
runs-on: ubuntu-24.04
|
|
21
|
+
timeout-minutes: 10
|
|
22
|
+
outputs:
|
|
23
|
+
filename: ${{ steps.pack.outputs.filename }}
|
|
24
|
+
steps:
|
|
25
|
+
- name: Checkout candidate
|
|
26
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
27
|
+
with:
|
|
28
|
+
persist-credentials: false
|
|
29
|
+
- name: Setup packaging Node
|
|
30
|
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
|
31
|
+
with:
|
|
32
|
+
node-version: '24'
|
|
33
|
+
- name: Pack the distributable once
|
|
34
|
+
id: pack
|
|
35
|
+
run: |
|
|
36
|
+
set -euo pipefail
|
|
37
|
+
mkdir -p "$RUNNER_TEMP/candidate"
|
|
38
|
+
npm pack --ignore-scripts --json --pack-destination "$RUNNER_TEMP/candidate" > "$RUNNER_TEMP/candidate/pack.json"
|
|
39
|
+
PACKAGE=$(node -e 'const p=require(process.argv[1]); if(p.length!==1 || !/^[A-Za-z0-9._-]+\.tgz$/.test(p[0].filename)) process.exit(1); console.log(p[0].filename)' "$RUNNER_TEMP/candidate/pack.json")
|
|
40
|
+
echo "filename=$PACKAGE" >> "$GITHUB_OUTPUT"
|
|
41
|
+
- name: Retain the shared candidate
|
|
42
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
43
|
+
with:
|
|
44
|
+
name: qualification-candidate-${{ github.run_id }}
|
|
45
|
+
path: ${{ runner.temp }}/candidate/*
|
|
46
|
+
if-no-files-found: error
|
|
47
|
+
overwrite: true
|
|
48
|
+
retention-days: 14
|
|
49
|
+
|
|
50
|
+
qualify:
|
|
51
|
+
name: Node ${{ matrix.node }}
|
|
52
|
+
needs: pack
|
|
53
|
+
if: vars.CI_BILLING_PAUSED != 'true' || inputs['billing-pause-bypass'] == true
|
|
54
|
+
runs-on: ubuntu-24.04
|
|
55
|
+
timeout-minutes: 15
|
|
56
|
+
strategy:
|
|
57
|
+
fail-fast: false
|
|
58
|
+
matrix:
|
|
59
|
+
node: ['20', '22', '24']
|
|
60
|
+
steps:
|
|
61
|
+
- name: Checkout qualification runtime
|
|
62
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
63
|
+
with:
|
|
64
|
+
persist-credentials: false
|
|
65
|
+
- name: Setup Node
|
|
66
|
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
|
67
|
+
with:
|
|
68
|
+
node-version: ${{ matrix.node }}
|
|
69
|
+
- name: Retrieve the shared archive from this attempt
|
|
70
|
+
env:
|
|
71
|
+
GH_TOKEN: ${{ github.token }}
|
|
72
|
+
run: |
|
|
73
|
+
gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
|
74
|
+
--name "qualification-candidate-$GITHUB_RUN_ID" \
|
|
75
|
+
--dir "$RUNNER_TEMP/candidate"
|
|
76
|
+
- name: Install pinned workflow validator
|
|
77
|
+
run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
|
78
|
+
- name: Qualify the exact shared distributable
|
|
79
|
+
env:
|
|
80
|
+
PACKAGE: ${{ needs.pack.outputs.filename }}
|
|
81
|
+
run: |
|
|
82
|
+
set -euo pipefail
|
|
83
|
+
node src/lib/consumer-qualification.mjs \
|
|
84
|
+
"$RUNNER_TEMP/candidate/$PACKAGE" \
|
|
85
|
+
"$(git rev-parse HEAD)" \
|
|
86
|
+
"$RUNNER_TEMP/report.json" \
|
|
87
|
+
"$(go env GOPATH)/bin/actionlint"
|
|
88
|
+
- name: Retain qualification evidence
|
|
89
|
+
if: always()
|
|
90
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
91
|
+
with:
|
|
92
|
+
name: consumer-qualification-${{ github.run_attempt }}-node-${{ matrix.node }}
|
|
93
|
+
path: ${{ runner.temp }}/report.json
|
|
94
|
+
if-no-files-found: warn
|
|
95
|
+
retention-days: 14
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Qualification
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [main]
|
|
6
|
+
types: [ready_for_review]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
actions: read
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: qualification-${{ github.event_name }}-${{ github.event.pull_request.number }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
consumers:
|
|
18
|
+
if: vars.CI_BILLING_PAUSED != 'true' && github.event.pull_request.draft == false
|
|
19
|
+
uses: ./.github/workflows/consumer-qualification.yml
|
|
@@ -19,8 +19,18 @@ permissions:
|
|
|
19
19
|
id-token: write
|
|
20
20
|
|
|
21
21
|
jobs:
|
|
22
|
+
qualification:
|
|
23
|
+
name: Qualify consumers
|
|
24
|
+
if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)
|
|
25
|
+
uses: ./.github/workflows/consumer-qualification.yml
|
|
26
|
+
with:
|
|
27
|
+
billing-pause-bypass: ${{ github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true }}
|
|
28
|
+
permissions:
|
|
29
|
+
actions: read
|
|
30
|
+
contents: read
|
|
22
31
|
release:
|
|
23
32
|
name: Release
|
|
33
|
+
needs: qualification
|
|
24
34
|
if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)
|
|
25
35
|
uses: ./.github/workflows/release.yml
|
|
26
36
|
with:
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.16.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.15.0...v1.16.0) (2026-09-09)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **qualification:** gate releases on installed consumer compatibility ([#544](https://github.com/0xPlayerOne/code-foundry/issues/544)) ([33c759b](https://github.com/0xPlayerOne/code-foundry/commit/33c759be8c78c633f55a58dc6144986758676336))
|
|
9
|
+
|
|
3
10
|
## [1.15.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.14.0...v1.15.0) (2026-09-09)
|
|
4
11
|
|
|
5
12
|
|
package/docs/PERFORMANCE.md
CHANGED
|
@@ -15,14 +15,19 @@ belong to the run that produced them, not the source tree.
|
|
|
15
15
|
| Format, lint, type-check, and build | 15 s | Bound the local CI feedback loop |
|
|
16
16
|
| Runtime dependencies | 0 | Keep the installed CLI dependency-free |
|
|
17
17
|
| Development dependencies | 4 | Prevent unreviewed toolchain growth |
|
|
18
|
-
| Packed artifact |
|
|
19
|
-
| Unpacked artifact |
|
|
20
|
-
| Packed files |
|
|
18
|
+
| Packed artifact | 225 kB | Bound registry transfer and install cost |
|
|
19
|
+
| Unpacked artifact | 860 kB | Bound installed footprint |
|
|
20
|
+
| Packed files | 100 | Detect accidental release contents |
|
|
21
21
|
|
|
22
22
|
The performance workflow disables build-cache reads and writes for this task.
|
|
23
23
|
That makes timing comparisons independent of a warm protected-branch cache and
|
|
24
24
|
prevents benchmark code from populating shared cache entries.
|
|
25
25
|
|
|
26
|
+
The consumer qualification workflow adds the installed-package harness and
|
|
27
|
+
workflow contract to the distributable. The merged candidate measured 221,495
|
|
28
|
+
packed bytes, 853,573 unpacked bytes, and 100 files on Node 24.18.0; the updated
|
|
29
|
+
budgets leave a small margin while continuing to bound package growth.
|
|
30
|
+
|
|
26
31
|
## v1.6.1 baseline
|
|
27
32
|
|
|
28
33
|
Measurements were taken on 2026-09-08 with Node 24.18.0. Process startup used
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Consumer Qualification
|
|
2
|
+
|
|
3
|
+
Release-candidate compatibility checks against the distributable package.
|
|
4
|
+
|
|
5
|
+
**Status:** Opt-in reusable workflow; required by Code Foundry's own release caller.
|
|
6
|
+
**Scope:** Package installation, CLI initialization/synchronization, generated workflow contracts.
|
|
7
|
+
|
|
8
|
+
`consumer-qualification.yml` packs the checked-out candidate once, shares that archive across all supported
|
|
9
|
+
Node majors using an artifact scoped to the same workflow run (including failed-job reruns), installs it offline with lifecycle scripts disabled,
|
|
10
|
+
and executes its public CLI from the installed package. The harness does not
|
|
11
|
+
import the source checkout's initializer as a substitute for package testing.
|
|
12
|
+
|
|
13
|
+
The matrix covers npm, pnpm, Yarn, Bun, direct and staging-release topologies,
|
|
14
|
+
authored documentation and workflows, an existing mise selection, and nested
|
|
15
|
+
Rust/Python manifests. It checks detected package managers, byte preservation,
|
|
16
|
+
and two successive no-change synchronizations. Lockfiles are detection fixtures:
|
|
17
|
+
this suite does not claim to install or execute every ecosystem toolchain.
|
|
18
|
+
Repository unit/policy suites and real consumer canaries remain required.
|
|
19
|
+
|
|
20
|
+
Actionlint checks copies of generated callers against the candidate's local
|
|
21
|
+
reusable workflows. Only exact candidate repository/ref calls are localized;
|
|
22
|
+
production callers and custom workflows are never edited by this step. Shellcheck
|
|
23
|
+
and Pyflakes are disabled here because this gate tests Actions contracts; existing
|
|
24
|
+
shell/security validation remains independent. Actionlint is pinned to v1.7.12.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm pack --ignore-scripts --pack-destination /tmp/candidate
|
|
28
|
+
node src/lib/consumer-qualification.mjs \
|
|
29
|
+
/tmp/candidate/code-foundry-VERSION.tgz \
|
|
30
|
+
"$(git rev-parse HEAD)" /tmp/qualification.json /absolute/path/to/actionlint
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The JSON report binds the result to the source SHA, archive SHA-256, Node version,
|
|
34
|
+
and completed fixtures. Failed runs retain `complete: false`. Running without
|
|
35
|
+
Actionlint is a local structural probe only (`actionlint: false`), not release
|
|
36
|
+
qualification. Downstream eligibility must require both `complete` and
|
|
37
|
+
`actionlint`, the expected source/digest, and successful reports for every matrix
|
|
38
|
+
member. Reports are evidence, not signatures or a substitute for release identity
|
|
39
|
+
verification.
|
|
40
|
+
|
|
41
|
+
The self release caller cannot start Release Please or npm publication until all
|
|
42
|
+
qualification jobs succeed. An explicit `release-while-paused` dispatch is passed
|
|
43
|
+
through to the reusable workflow; ordinary calls remain blocked by the billing pause.
|
|
44
|
+
Consumer-generated release callers do not inherit this Code Foundry-specific
|
|
45
|
+
qualification job. This does not yet guarantee that its legacy publishing job uses
|
|
46
|
+
this same archive; the verified-publication workflow handles that separate
|
|
47
|
+
requirement. No branch protections, repository settings, credentials, or consumer
|
|
48
|
+
runtimes are changed by this feature.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-foundry",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
|
|
5
5
|
"homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
|
|
6
6
|
"bugs": {
|
package/src/commands/sync.mjs
CHANGED
|
@@ -242,7 +242,15 @@ export function syncRepository(options) {
|
|
|
242
242
|
}
|
|
243
243
|
if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
|
|
244
244
|
content = Buffer.from(
|
|
245
|
-
renderWorkflow(
|
|
245
|
+
renderWorkflow(
|
|
246
|
+
content.toString('utf8'),
|
|
247
|
+
config,
|
|
248
|
+
runtimeRepository,
|
|
249
|
+
runtimeRef,
|
|
250
|
+
rustCodeql,
|
|
251
|
+
file,
|
|
252
|
+
target === source
|
|
253
|
+
)
|
|
246
254
|
)
|
|
247
255
|
}
|
|
248
256
|
if (file === '.github/dependabot.yml') {
|
|
@@ -537,8 +545,10 @@ function sourcePath(source, file) {
|
|
|
537
545
|
* @param {string} repository
|
|
538
546
|
* @param {string} ref
|
|
539
547
|
* @param {{ shards: string, threads: string, maxParallel: string }} rustCodeql
|
|
548
|
+
* @param {string} file
|
|
549
|
+
* @param {boolean} selfRepository
|
|
540
550
|
*/
|
|
541
|
-
function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
551
|
+
function renderWorkflow(content, config, repository, ref, rustCodeql, file, selfRepository) {
|
|
542
552
|
const localPrefix = 'uses: ./.github/workflows/'
|
|
543
553
|
const remotePrefix = `uses: ${repository}/.github/workflows/`
|
|
544
554
|
let rendered = content.replaceAll(localPrefix, remotePrefix)
|
|
@@ -578,7 +588,15 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
|
578
588
|
'release-pr': config.pr_runner ?? config.runner,
|
|
579
589
|
release: config.release_runner ?? config.runner,
|
|
580
590
|
}
|
|
581
|
-
const workflow =
|
|
591
|
+
const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
|
|
592
|
+
// Consumer release callers are generic package release workflows. The
|
|
593
|
+
// installed-consumer qualification harness is specific to Code Foundry's
|
|
594
|
+
// own package and must remain in the self workflow rather than being
|
|
595
|
+
// rendered into every consumer's release caller.
|
|
596
|
+
if (workflow === 'release' && !selfRepository) {
|
|
597
|
+
rendered = removeWorkflowBlock(rendered, 'qualification')
|
|
598
|
+
rendered = removeWorkflowNeed(rendered, 'release', 'qualification')
|
|
599
|
+
}
|
|
582
600
|
// The staging-release topology validates and scans pull requests against
|
|
583
601
|
// both main and the integration branch; direct repositories only ever
|
|
584
602
|
// target main, so their callers trigger on main alone.
|
|
@@ -669,6 +687,26 @@ function removeWorkflowBlock(content, blockId) {
|
|
|
669
687
|
return lines.join('\n')
|
|
670
688
|
}
|
|
671
689
|
|
|
690
|
+
/**
|
|
691
|
+
* Remove a single root-job dependency while preserving the rest of the job.
|
|
692
|
+
* @param {string} content
|
|
693
|
+
* @param {string} jobId
|
|
694
|
+
* @param {string} dependency
|
|
695
|
+
* @returns {string}
|
|
696
|
+
*/
|
|
697
|
+
function removeWorkflowNeed(content, jobId, dependency) {
|
|
698
|
+
const lines = content.split('\n')
|
|
699
|
+
const start = lines.findIndex((line) => line === ` ${jobId}:`)
|
|
700
|
+
if (start === -1) return content
|
|
701
|
+
let end = start + 1
|
|
702
|
+
while (end < lines.length && !/^ [A-Za-z0-9_-]+:\s*$/.test(lines[end])) end += 1
|
|
703
|
+
const need = lines.findIndex(
|
|
704
|
+
(line, index) => index > start && index < end && line === ` needs: ${dependency}`
|
|
705
|
+
)
|
|
706
|
+
if (need >= 0) lines.splice(need, 1)
|
|
707
|
+
return lines.join('\n')
|
|
708
|
+
}
|
|
709
|
+
|
|
672
710
|
/**
|
|
673
711
|
* Dependabot updates land on the repository's integration branch. Direct
|
|
674
712
|
* repositories have no staging branch, so every update targets main. Cargo,
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import {
|
|
6
|
+
cpSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
/** @param {string} file */
|
|
20
|
+
export function digest(file) {
|
|
21
|
+
if (!lstatSync(file).isFile()) throw new Error(`Expected a regular file: ${file}`)
|
|
22
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Snapshot consumer-owned bytes, never Git metadata or dependency directories.
|
|
26
|
+
* @param {string} root @param {string} [prefix]
|
|
27
|
+
* @returns {Record<string, string>}
|
|
28
|
+
*/
|
|
29
|
+
export function snapshot(root, prefix = '') {
|
|
30
|
+
/** @type {Record<string, string>} */
|
|
31
|
+
const result = {}
|
|
32
|
+
for (const entry of readdirSync(join(root, prefix), { withFileTypes: true })) {
|
|
33
|
+
if (['.git', 'node_modules'].includes(entry.name)) continue
|
|
34
|
+
const name = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
35
|
+
if (entry.isSymbolicLink()) throw new Error(`Unexpected fixture symlink: ${name}`)
|
|
36
|
+
if (entry.isDirectory()) Object.assign(result, snapshot(root, name))
|
|
37
|
+
else if (entry.isFile()) result[name] = digest(join(root, name))
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} command @param {string[]} args @param {string} cwd */
|
|
43
|
+
function run(command, args, cwd) {
|
|
44
|
+
const result = spawnSync(command, args, {
|
|
45
|
+
cwd,
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
timeout: 120_000,
|
|
48
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
49
|
+
env: { ...process.env, CI: 'true', npm_config_update_notifier: 'false' },
|
|
50
|
+
})
|
|
51
|
+
if (result.error || result.status !== 0) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`${command} ${args.join(' ')} failed (${result.status ?? result.error?.message})\n${result.stderr}\n${result.stdout}`
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return result.stdout
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const FIXTURES = [
|
|
60
|
+
{ name: 'npm-direct', manager: 'npm', lock: 'package-lock.json', topology: 'direct' },
|
|
61
|
+
{ name: 'pnpm-direct', manager: 'pnpm', lock: 'pnpm-lock.yaml', topology: 'direct' },
|
|
62
|
+
{ name: 'yarn-staging', manager: 'yarn', lock: 'yarn.lock', topology: 'staging-release' },
|
|
63
|
+
{ name: 'bun-mise', manager: 'bun', lock: 'bun.lock', topology: 'direct' },
|
|
64
|
+
{ name: 'nested-rust', manager: '', lock: '', topology: 'direct' },
|
|
65
|
+
{ name: 'nested-python', manager: '', lock: '', topology: 'direct' },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
/** @param {string} root @param {(typeof FIXTURES)[number]} fixture */
|
|
69
|
+
export function seedFixture(root, fixture) {
|
|
70
|
+
mkdirSync(join(root, '.github/workflows'), { recursive: true })
|
|
71
|
+
const authored = '# Consumer-owned instructions\n\nPreserve this paragraph exactly.\n'
|
|
72
|
+
writeFileSync(join(root, 'AGENTS.md'), authored)
|
|
73
|
+
writeFileSync(
|
|
74
|
+
join(root, '.github/workflows/custom.yml'),
|
|
75
|
+
'name: Custom\non: workflow_dispatch\njobs:\n check:\n runs-on: ubuntu-latest\n steps:\n - run: echo preserved\n'
|
|
76
|
+
)
|
|
77
|
+
writeFileSync(join(root, 'LICENSE'), 'Consumer-owned license\n')
|
|
78
|
+
if (fixture.manager) {
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(root, 'package.json'),
|
|
81
|
+
JSON.stringify(
|
|
82
|
+
{
|
|
83
|
+
name: `qualification-${fixture.name}`,
|
|
84
|
+
version: '0.0.0',
|
|
85
|
+
private: true,
|
|
86
|
+
scripts: { 'test:unit': 'node -e "process.exit(0)"' },
|
|
87
|
+
},
|
|
88
|
+
null,
|
|
89
|
+
2
|
|
90
|
+
) + '\n'
|
|
91
|
+
)
|
|
92
|
+
const locks = {
|
|
93
|
+
npm: '{"name":"fixture","lockfileVersion":3,"packages":{}}\n',
|
|
94
|
+
pnpm: "lockfileVersion: '9.0'\nimporters: {}\n",
|
|
95
|
+
yarn: '# yarn lockfile v1\n',
|
|
96
|
+
bun: '{"lockfileVersion":1,"workspaces":{},"packages":{}}\n',
|
|
97
|
+
}
|
|
98
|
+
writeFileSync(
|
|
99
|
+
join(root, fixture.lock),
|
|
100
|
+
locks[/** @type {keyof typeof locks} */ (fixture.manager)]
|
|
101
|
+
)
|
|
102
|
+
} else if (fixture.name === 'nested-rust') {
|
|
103
|
+
mkdirSync(join(root, 'native/core/src'), { recursive: true })
|
|
104
|
+
writeFileSync(
|
|
105
|
+
join(root, 'native/core/Cargo.toml'),
|
|
106
|
+
'[package]\nname = "fixture"\nversion = "0.1.0"\nedition = "2021"\n'
|
|
107
|
+
)
|
|
108
|
+
writeFileSync(join(root, 'native/core/src/lib.rs'), 'pub fn value() -> u8 { 1 }\n')
|
|
109
|
+
} else {
|
|
110
|
+
mkdirSync(join(root, 'services/api'), { recursive: true })
|
|
111
|
+
writeFileSync(
|
|
112
|
+
join(root, 'services/api/pyproject.toml'),
|
|
113
|
+
'[project]\nname = "fixture"\nversion = "0.1.0"\n'
|
|
114
|
+
)
|
|
115
|
+
writeFileSync(join(root, 'services/api/app.py'), 'VALUE = 1\n')
|
|
116
|
+
}
|
|
117
|
+
if (fixture.name === 'bun-mise') writeFileSync(join(root, '.mise.toml'), '[tools]\nnode = "22"\n')
|
|
118
|
+
// Explicit topology only; init must still detect languages and lockfiles.
|
|
119
|
+
writeFileSync(
|
|
120
|
+
join(root, '.github/code-foundry.yml'),
|
|
121
|
+
`git_workflow: ${fixture.topology}\nmerge_strategy: ${fixture.topology === 'direct' ? 'squash' : 'rebase'}\nrelease_merge_strategy: ${fixture.topology === 'direct' ? 'squash' : 'rebase'}\n`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @param {string} value */
|
|
126
|
+
function escapeRegExp(value) {
|
|
127
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Localize only this candidate's remote workflow calls for Actionlint's local
|
|
131
|
+
* caller/callee contract checks. Production files are never rewritten.
|
|
132
|
+
* @param {string} text @param {string} repository @param {string} ref
|
|
133
|
+
*/
|
|
134
|
+
export function localizeCalls(text, repository, ref) {
|
|
135
|
+
const pattern = new RegExp(
|
|
136
|
+
`(uses:\\s*)${escapeRegExp(repository)}/\\.github/workflows/([A-Za-z0-9_.-]+)@${escapeRegExp(ref)}(?=\\s|$)`,
|
|
137
|
+
'g'
|
|
138
|
+
)
|
|
139
|
+
return text.replace(pattern, '$1./.github/workflows/$2')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {string} consumer @param {string} candidate @param {string} destination @param {string} actionlint @param {string} version */
|
|
143
|
+
function lintContracts(consumer, candidate, destination, actionlint, version) {
|
|
144
|
+
mkdirSync(join(destination, '.github/workflows'), { recursive: true })
|
|
145
|
+
run('git', ['init', '-q'], destination)
|
|
146
|
+
cpSync(join(candidate, '.github'), join(destination, '.github'), { recursive: true })
|
|
147
|
+
// Event callers in the candidate are not reusable-workflow dependencies.
|
|
148
|
+
for (const name of readdirSync(join(destination, '.github/workflows'))) {
|
|
149
|
+
if (name.includes('_self-ci')) rmSync(join(destination, '.github/workflows', name))
|
|
150
|
+
}
|
|
151
|
+
const paths = []
|
|
152
|
+
for (const name of readdirSync(join(consumer, '.github/workflows'))) {
|
|
153
|
+
if (!/\.ya?ml$/.test(name)) continue
|
|
154
|
+
const target = join(destination, '.github/workflows', `consumer-${name}`)
|
|
155
|
+
const text = readFileSync(join(consumer, '.github/workflows', name), 'utf8')
|
|
156
|
+
writeFileSync(target, localizeCalls(text, '0xPlayerOne/code-foundry', `v${version}`))
|
|
157
|
+
paths.push(target)
|
|
158
|
+
}
|
|
159
|
+
// Check all candidate callees as well as rendered callers, without executing them.
|
|
160
|
+
const workflows = readdirSync(join(destination, '.github/workflows')).filter((name) =>
|
|
161
|
+
/\.ya?ml$/.test(name)
|
|
162
|
+
)
|
|
163
|
+
run(
|
|
164
|
+
actionlint,
|
|
165
|
+
[
|
|
166
|
+
'-shellcheck=',
|
|
167
|
+
'-pyflakes=',
|
|
168
|
+
...workflows.map((name) => join(destination, '.github/workflows', name)),
|
|
169
|
+
],
|
|
170
|
+
destination
|
|
171
|
+
)
|
|
172
|
+
return paths.length
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @param {{ packageFile: string, sourceSha: string, reportPath: string, actionlint?: string }} options */
|
|
176
|
+
export function qualifyCandidate(options) {
|
|
177
|
+
if (!/^[a-f0-9]{40}$/.test(options.sourceSha))
|
|
178
|
+
throw new Error('An exact 40-character source SHA is required')
|
|
179
|
+
const packageFile = resolve(options.packageFile)
|
|
180
|
+
const before = digest(packageFile)
|
|
181
|
+
const temporary = mkdtempSync(join(tmpdir(), 'code-foundry-qualification-'))
|
|
182
|
+
const report = {
|
|
183
|
+
schema_version: 1,
|
|
184
|
+
source_sha: options.sourceSha,
|
|
185
|
+
artifact_sha256: before,
|
|
186
|
+
node: process.version,
|
|
187
|
+
complete: false,
|
|
188
|
+
actionlint: Boolean(options.actionlint),
|
|
189
|
+
/** @type {Array<{ name: string, status: string }>} */ fixtures: [],
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const host = join(temporary, 'host')
|
|
193
|
+
mkdirSync(host)
|
|
194
|
+
writeFileSync(join(host, 'package.json'), '{"private":true}\n')
|
|
195
|
+
run(
|
|
196
|
+
'npm',
|
|
197
|
+
[
|
|
198
|
+
'install',
|
|
199
|
+
'--offline',
|
|
200
|
+
'--ignore-scripts',
|
|
201
|
+
'--no-audit',
|
|
202
|
+
'--no-fund',
|
|
203
|
+
'--package-lock=false',
|
|
204
|
+
packageFile,
|
|
205
|
+
],
|
|
206
|
+
host
|
|
207
|
+
)
|
|
208
|
+
const candidate = join(host, 'node_modules/code-foundry')
|
|
209
|
+
const manifest = JSON.parse(readFileSync(join(candidate, 'package.json'), 'utf8'))
|
|
210
|
+
assert.equal(manifest.name, 'code-foundry')
|
|
211
|
+
assert.equal(manifest.bin?.['code-foundry'], 'src/cli.mjs')
|
|
212
|
+
const cli = join(candidate, 'src/cli.mjs')
|
|
213
|
+
assert.match(run(process.execPath, [cli, '--help'], host), /code-foundry/)
|
|
214
|
+
for (const fixture of FIXTURES) {
|
|
215
|
+
const root = join(temporary, fixture.name)
|
|
216
|
+
seedFixture(root, fixture)
|
|
217
|
+
run('git', ['init', '-q'], root)
|
|
218
|
+
const original = snapshot(root)
|
|
219
|
+
run(process.execPath, [cli, 'init', '--target', root], root)
|
|
220
|
+
assert.ok(
|
|
221
|
+
readFileSync(join(root, 'AGENTS.md'), 'utf8').startsWith(
|
|
222
|
+
'# Consumer-owned instructions\n\nPreserve this paragraph exactly.\n'
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
const initialized = snapshot(root)
|
|
226
|
+
for (const [name, hash] of Object.entries(original)) {
|
|
227
|
+
if (name === 'AGENTS.md' || name === '.github/code-foundry.yml') continue
|
|
228
|
+
assert.equal(initialized[name], hash, `${fixture.name}: authored ${name} was changed`)
|
|
229
|
+
}
|
|
230
|
+
if (fixture.manager)
|
|
231
|
+
assert.match(
|
|
232
|
+
readFileSync(join(root, '.github/code-foundry.yml'), 'utf8'),
|
|
233
|
+
new RegExp(`^package_manager: ${fixture.manager}$`, 'm')
|
|
234
|
+
)
|
|
235
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
236
|
+
run(process.execPath, [cli, 'sync', '--target', root], root)
|
|
237
|
+
assert.deepEqual(snapshot(root), initialized, `${fixture.name}: sync is not idempotent`)
|
|
238
|
+
}
|
|
239
|
+
if (options.actionlint)
|
|
240
|
+
lintContracts(
|
|
241
|
+
root,
|
|
242
|
+
candidate,
|
|
243
|
+
join(temporary, `lint-${fixture.name}`),
|
|
244
|
+
options.actionlint,
|
|
245
|
+
manifest.version
|
|
246
|
+
)
|
|
247
|
+
report.fixtures.push({ name: fixture.name, status: 'passed' })
|
|
248
|
+
}
|
|
249
|
+
assert.equal(digest(packageFile), before, 'Candidate archive changed during qualification')
|
|
250
|
+
report.complete = true
|
|
251
|
+
return report
|
|
252
|
+
} finally {
|
|
253
|
+
mkdirSync(dirname(resolve(options.reportPath)), { recursive: true })
|
|
254
|
+
writeFileSync(options.reportPath, JSON.stringify(report, null, 2) + '\n')
|
|
255
|
+
rmSync(temporary, { recursive: true, force: true })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
260
|
+
try {
|
|
261
|
+
const [packageFile, sourceSha, reportPath, actionlint, ...extra] = process.argv.slice(2)
|
|
262
|
+
if (!packageFile || !sourceSha || !reportPath || extra.length)
|
|
263
|
+
throw new Error(
|
|
264
|
+
'Usage: node consumer-qualification.mjs PACKAGE.tgz SOURCE_SHA REPORT.json [ACTIONLINT]'
|
|
265
|
+
)
|
|
266
|
+
console.log(
|
|
267
|
+
JSON.stringify(qualifyCandidate({ packageFile, sourceSha, reportPath, actionlint }))
|
|
268
|
+
)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
271
|
+
process.exitCode = 1
|
|
272
|
+
}
|
|
273
|
+
}
|