code-foundry 1.14.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/actions/attest-artifact/action.yml +56 -0
- package/.github/workflows/consumer-qualification.yml +95 -0
- package/.github/workflows/consumer-qualification_self-ci.yml +19 -0
- package/.github/workflows/release-integrity.yml +63 -0
- package/.github/workflows/release-integrity_self-ci.yml +22 -0
- package/.github/workflows/release_self-ci.yml +10 -0
- package/CHANGELOG.md +14 -0
- package/README.md +2 -1
- package/docs/PERFORMANCE.md +8 -3
- package/docs/README.md +1 -0
- package/docs/consumer-qualification.md +48 -0
- package/docs/release-integrity.md +140 -0
- package/package.json +1 -1
- package/src/cli.mjs +61 -4
- package/src/commands/release-integrity.mjs +254 -0
- package/src/commands/sync.mjs +43 -3
- package/src/lib/consumer-qualification.mjs +273 -0
|
@@ -0,0 +1,56 @@
|
|
|
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"
|
|
@@ -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
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
name: Code Foundry Release Integrity
|
|
2
|
+
on:
|
|
3
|
+
workflow_call:
|
|
4
|
+
inputs:
|
|
5
|
+
runtime-ref:
|
|
6
|
+
description: Exact Code Foundry tag or SHA containing the verifier.
|
|
7
|
+
required: true
|
|
8
|
+
type: string
|
|
9
|
+
tag:
|
|
10
|
+
description: Exact published release tag in the caller repository.
|
|
11
|
+
required: true
|
|
12
|
+
type: string
|
|
13
|
+
expected-sha:
|
|
14
|
+
description: Optional full expected source commit SHA.
|
|
15
|
+
required: false
|
|
16
|
+
type: string
|
|
17
|
+
default: ''
|
|
18
|
+
outputs:
|
|
19
|
+
source-sha:
|
|
20
|
+
description: Verified immutable tag commit.
|
|
21
|
+
value: ${{ jobs.verify.outputs.source-sha }}
|
|
22
|
+
permissions:
|
|
23
|
+
contents: read
|
|
24
|
+
attestations: read
|
|
25
|
+
jobs:
|
|
26
|
+
verify:
|
|
27
|
+
if: vars.CI_BILLING_PAUSED != 'true'
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
timeout-minutes: 10
|
|
30
|
+
outputs:
|
|
31
|
+
source-sha: ${{ steps.verify.outputs.source-sha }}
|
|
32
|
+
steps:
|
|
33
|
+
- name: Validate runtime pin
|
|
34
|
+
env:
|
|
35
|
+
RUNTIME_REF: ${{ inputs.runtime-ref }}
|
|
36
|
+
run: '[[ "$RUNTIME_REF" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+|[0-9a-fA-F]{40})$ ]]'
|
|
37
|
+
- name: Checkout verifier, not release-owned executable code
|
|
38
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
39
|
+
with:
|
|
40
|
+
repository: 0xPlayerOne/code-foundry
|
|
41
|
+
ref: ${{ inputs.runtime-ref }}
|
|
42
|
+
persist-credentials: false
|
|
43
|
+
sparse-checkout: src/commands
|
|
44
|
+
- name: Verify immutable release and signed attestation
|
|
45
|
+
id: verify
|
|
46
|
+
env:
|
|
47
|
+
GH_TOKEN: ${{ github.token }}
|
|
48
|
+
RELEASE_TAG: ${{ inputs.tag }}
|
|
49
|
+
EXPECTED_SHA: ${{ inputs.expected-sha }}
|
|
50
|
+
run: |
|
|
51
|
+
set -euo pipefail
|
|
52
|
+
args=(release --repo "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG")
|
|
53
|
+
if [ -n "$EXPECTED_SHA" ]; then args+=(--expected-sha "$EXPECTED_SHA"); fi
|
|
54
|
+
node src/commands/release-integrity.mjs "${args[@]}" > "$RUNNER_TEMP/release-integrity.json"
|
|
55
|
+
node -e 'const fs = require("node:fs"); const r = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + "/release-integrity.json")); fs.appendFileSync(process.env.GITHUB_OUTPUT, "source-sha=" + r.sourceSha + "\n")'
|
|
56
|
+
- name: Preserve verification evidence
|
|
57
|
+
if: always() && steps.verify.outcome != 'skipped'
|
|
58
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
59
|
+
with:
|
|
60
|
+
name: release-integrity-${{ github.run_id }}-${{ github.run_attempt }}
|
|
61
|
+
path: ${{ runner.temp }}/release-integrity.json
|
|
62
|
+
if-no-files-found: error
|
|
63
|
+
retention-days: 30
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: Verify Code Foundry Release Integrity
|
|
2
|
+
on:
|
|
3
|
+
release:
|
|
4
|
+
types: [published]
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
inputs:
|
|
7
|
+
tag:
|
|
8
|
+
description: Published immutable release tag to verify.
|
|
9
|
+
required: true
|
|
10
|
+
type: string
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
attestations: read
|
|
14
|
+
jobs:
|
|
15
|
+
verify:
|
|
16
|
+
# Enable only after an administrator enables immutable releases. This is a
|
|
17
|
+
# post-publication verifier, not a substitute for the GitHub repository setting.
|
|
18
|
+
if: vars.CI_BILLING_PAUSED != 'true' && (vars.REQUIRE_IMMUTABLE_RELEASES == 'true' || github.event_name == 'workflow_dispatch')
|
|
19
|
+
uses: ./.github/workflows/release-integrity.yml
|
|
20
|
+
with:
|
|
21
|
+
runtime-ref: ${{ github.sha }}
|
|
22
|
+
tag: ${{ github.event.release.tag_name || inputs.tag }}
|
|
@@ -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,19 @@
|
|
|
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
|
+
|
|
10
|
+
## [1.15.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.14.0...v1.15.0) (2026-09-09)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **releases:** verify immutable releases and attest selected build artifacts ([#537](https://github.com/0xPlayerOne/code-foundry/issues/537)) ([099aa57](https://github.com/0xPlayerOne/code-foundry/commit/099aa577227ab067a9a554e4cfc286aa2f5eb38e))
|
|
16
|
+
|
|
3
17
|
## [1.14.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.13.0...v1.14.0) (2026-09-09)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -127,7 +127,8 @@ first. Either way, Release Please creates a GitHub release after the version
|
|
|
127
127
|
PR is merged. npm publication is opt-in through `npm_publish: true` and
|
|
128
128
|
supports npm trusted publishing or an `NPM_TOKEN` fallback.
|
|
129
129
|
|
|
130
|
-
Read [Release management](docs/RELEASES.md)
|
|
130
|
+
Read [Release management](docs/RELEASES.md),
|
|
131
|
+
[Release integrity and build provenance](docs/release-integrity.md), and
|
|
131
132
|
[Publishing packages](docs/PUBLISHING.md) before enabling automated
|
|
132
133
|
publishing. They are intentionally written with placeholders so they can be
|
|
133
134
|
copied into other repositories.
|
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
|
package/docs/README.md
CHANGED
|
@@ -10,6 +10,7 @@ its own names, environments, and deployment details.
|
|
|
10
10
|
- [Configuration reference](CONFIGURATION.md)
|
|
11
11
|
- [Workflow and CI conventions](WORKFLOWS.md)
|
|
12
12
|
- [Release management](RELEASES.md)
|
|
13
|
+
- [Release integrity and build provenance](release-integrity.md)
|
|
13
14
|
- [Publishing packages](PUBLISHING.md)
|
|
14
15
|
- [Caching and remote caching](CACHING.md)
|
|
15
16
|
- [Performance budgets and baselines](PERFORMANCE.md)
|
|
@@ -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.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Release integrity and build provenance
|
|
2
|
+
|
|
3
|
+
Code Foundry now provides a read-only immutable-setting preflight, cryptographic
|
|
4
|
+
release/asset verification, and an optional build-provenance action. These are
|
|
5
|
+
separate guarantees: a checksum identifies bytes, an attestation identifies their
|
|
6
|
+
origin, and GitHub's immutable-release setting prevents replacement after publish.
|
|
7
|
+
|
|
8
|
+
## Enable immutable releases explicitly
|
|
9
|
+
|
|
10
|
+
An administrator must enable **Settings → General → Releases → Enable release
|
|
11
|
+
immutability**. This applies only to future releases. Existing mutable releases
|
|
12
|
+
are not retroactively frozen, and this implementation does not change the setting.
|
|
13
|
+
After enabling it, set the repository variable `REQUIRE_IMMUTABLE_RELEASES=true`
|
|
14
|
+
to activate Code Foundry's post-publication verification caller. Manual dispatch
|
|
15
|
+
can verify an explicitly selected tag without the variable. Billing pause is
|
|
16
|
+
honored by both workflows. A skipped workflow is not verification evidence.
|
|
17
|
+
|
|
18
|
+
Publish all assets to a draft release **before** publishing it. Do not attach or
|
|
19
|
+
replace assets after an immutable release is published. The existing Release
|
|
20
|
+
Please/npm workflow is not rewritten by this change; repositories adding release
|
|
21
|
+
assets must review their attachment ordering before enabling immutability.
|
|
22
|
+
|
|
23
|
+
A release workflow can run this preflight before publication:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx code-foundry release-integrity settings --repo owner/repository
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The setting endpoint needs an authenticated identity with repository
|
|
30
|
+
Administration **read** permission. Do not grant Administration write for this
|
|
31
|
+
check. The ordinary Actions token may be unable to read it. Inaccessible,
|
|
32
|
+
unsupported, malformed, or disabled settings fail as unverified; a 404 is not
|
|
33
|
+
silently interpreted as proof that a setting is absent. No setting writes exist
|
|
34
|
+
in the verifier.
|
|
35
|
+
|
|
36
|
+
## Verify published releases and local assets
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx code-foundry release-integrity release \
|
|
40
|
+
--repo owner/repository --tag v1.2.3 \
|
|
41
|
+
--expected-sha FULL_40_CHARACTER_COMMIT_SHA \
|
|
42
|
+
--asset dist/package.tgz
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`--expected-sha` and repeatable `--asset` are optional. Verification requires the
|
|
46
|
+
exact published tag to be immutable, invokes `gh release verify` for signed
|
|
47
|
+
release-attestation validation, resolves lightweight/annotated tags to a commit,
|
|
48
|
+
and optionally checks that commit against the expected source. Each local asset
|
|
49
|
+
is checked with `gh release verify-asset`, and hashed before/after verification to
|
|
50
|
+
reject replacement during the check. GitHub-generated source archives are not
|
|
51
|
+
supported release assets for this command; verify uploaded binaries/packages.
|
|
52
|
+
The GitHub CLI must support these subcommands; an older CLI fails rather than
|
|
53
|
+
falling back to a metadata-only claim. The verifier targets github.com explicitly.
|
|
54
|
+
|
|
55
|
+
JSON is emitted on stdout with a nonzero exit status on failure. No credential or
|
|
56
|
+
raw failed API response is included in the report. Selected assets must be
|
|
57
|
+
nonempty regular files inside the repository, without symlink components.
|
|
58
|
+
|
|
59
|
+
`sync` installs an opt-in release-event caller in
|
|
60
|
+
`.github/workflows/release-integrity.yml`; set `REQUIRE_IMMUTABLE_RELEASES=true`
|
|
61
|
+
to enable it. To call the reusable workflow directly, pin both references to the
|
|
62
|
+
same reviewed tag or commit:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
permissions:
|
|
66
|
+
contents: read
|
|
67
|
+
attestations: read
|
|
68
|
+
jobs:
|
|
69
|
+
verify-release:
|
|
70
|
+
uses: 0xPlayerOne/code-foundry/.github/workflows/release-integrity.yml@REVIEWED_REF
|
|
71
|
+
with:
|
|
72
|
+
runtime-ref: REVIEWED_REF
|
|
73
|
+
tag: v1.2.3
|
|
74
|
+
expected-sha: FULL_40_CHARACTER_COMMIT_SHA
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`runtime-ref`, `tag`, and optional `expected-sha` are the workflow inputs. The job
|
|
78
|
+
checks out only the selected verifier, not executable code from the release under
|
|
79
|
+
test, needs `contents: read` and `attestations: read`, preserves evidence, and
|
|
80
|
+
exposes the verified `source-sha`. Downstream publication or consumer steps should
|
|
81
|
+
explicitly `needs` this job when verification is their gate. Code Foundry's own
|
|
82
|
+
release-event caller verifies **after publication**; it cannot prevent a release
|
|
83
|
+
that has already been published. Actual immutability comes from the repository
|
|
84
|
+
setting.
|
|
85
|
+
|
|
86
|
+
## Optional build provenance
|
|
87
|
+
|
|
88
|
+
In the same trusted job that builds the artifacts, grant `contents: read`,
|
|
89
|
+
`id-token: write`, and `attestations: write`, then call:
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
- name: Attest built package
|
|
93
|
+
# Replace REVIEWED_SHA with the reviewed Code Foundry commit.
|
|
94
|
+
uses: 0xPlayerOne/code-foundry/.github/actions/attest-artifact@REVIEWED_SHA
|
|
95
|
+
id: provenance
|
|
96
|
+
with:
|
|
97
|
+
artifacts: '["dist/package.tgz"]'
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The action hashes only the explicitly selected files, generates a checksum
|
|
101
|
+
manifest, signs those digests with the commit-pinned official GitHub provenance
|
|
102
|
+
action, and checks the files have not changed during signing. Its outputs provide
|
|
103
|
+
the manifest, signed bundle, and attestation URL. Persist the manifest and bundle
|
|
104
|
+
alongside build evidence; attach release assets before publishing an immutable
|
|
105
|
+
release. The action refuses PR event contexts. Repository-owned build scripts
|
|
106
|
+
must still be trusted: signing attacker-controlled downloaded bytes does not
|
|
107
|
+
prove that this job built them. Do not place attestation into an unrelated job
|
|
108
|
+
that merely downloads and re-labels artifacts from another build.
|
|
109
|
+
|
|
110
|
+
Artifact attestations require GitHub support for the repository/account. Never
|
|
111
|
+
silently skip a required attestation because the account lacks that capability.
|
|
112
|
+
Consumers should use `gh attestation verify` with the expected source repository
|
|
113
|
+
and trusted signer workflow as appropriate; digest equality alone is insufficient.
|
|
114
|
+
This action covers local file artifacts, not registry image publishing. npm
|
|
115
|
+
trusted publishing remains available through the existing release workflow; this
|
|
116
|
+
change does not replace or require an npm token.
|
|
117
|
+
|
|
118
|
+
Generate a local unsigned manifest without GitHub/network access with:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
npx code-foundry release-integrity manifest --asset dist/package.tgz
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
That command makes no provenance or authenticity claim.
|
|
125
|
+
|
|
126
|
+
## Validation and references
|
|
127
|
+
|
|
128
|
+
Run `node --test test/release-integrity.test.mjs`. Tests cover exact command
|
|
129
|
+
contracts, signature-verifier invocation, tag identity, asset replacement/path
|
|
130
|
+
safety, settings uncertainty, sync-installed caller wiring, and pinned/read-only
|
|
131
|
+
workflow structure using CLI fixtures. Real signing and release verification
|
|
132
|
+
require a supported authenticated GitHub environment and must be exercised there
|
|
133
|
+
before rollout.
|
|
134
|
+
|
|
135
|
+
- https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases
|
|
136
|
+
- https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/verify-release-integrity
|
|
137
|
+
- https://cli.github.com/manual/gh_release_verify
|
|
138
|
+
- https://cli.github.com/manual/gh_release_verify-asset
|
|
139
|
+
- https://docs.github.com/en/rest/repos/repos#get-immutable-releases-settings
|
|
140
|
+
- https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds
|
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/cli.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
7
|
|
|
8
8
|
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
|
9
9
|
|
|
10
|
-
/** @typedef {{ target: string, root: string, dryRun: boolean, force: boolean, github: boolean, createPr: boolean, exclude: string[], base: string, head: string, tag: string, workflow: string, mode: string, version: string, releaseSubcommand?: string, fleetSubcommand?: string, ciSubcommand?: 'pause'|'resume'|'status' }} Options */
|
|
10
|
+
/** @typedef {{ target: string, root: string, dryRun: boolean, force: boolean, github: boolean, createPr: boolean, exclude: string[], base: string, head: string, tag: string, workflow: string, mode: string, version: string, repository: string, expectedSha: string, assets: string[], rootProvided: boolean, releaseSubcommand?: string, fleetSubcommand?: string, integritySubcommand?: 'settings'|'release'|'manifest', ciSubcommand?: 'pause'|'resume'|'status' }} Options */
|
|
11
11
|
/** @typedef {{ command: string, options: Options }} ParsedArgs */
|
|
12
12
|
|
|
13
13
|
const usage = `code-foundry — initialize and maintain agent-ready repositories
|
|
@@ -21,6 +21,9 @@ Usage:
|
|
|
21
21
|
npx code-foundry ci pause|resume|status [--target PATH]
|
|
22
22
|
npx code-foundry release reconcile [--github] [--base BRANCH] [--head BRANCH]
|
|
23
23
|
npx code-foundry release hook --tag TAG --workflow WORKFLOW
|
|
24
|
+
npx code-foundry release-integrity settings --repo OWNER/REPO
|
|
25
|
+
npx code-foundry release-integrity release --repo OWNER/REPO --tag TAG
|
|
26
|
+
npx code-foundry release-integrity manifest --asset PATH [--root PATH]
|
|
24
27
|
npx code-foundry fleet status [--root PATH]
|
|
25
28
|
npx code-foundry fleet upgrade [--root PATH] [--dry-run] [--create-pr]
|
|
26
29
|
|
|
@@ -38,6 +41,9 @@ Options:
|
|
|
38
41
|
--tag TAG Published release tag for a post-release hook
|
|
39
42
|
--workflow FILE Workflow to dispatch for a post-release hook
|
|
40
43
|
--mode MODE auto, workflow-dispatch, release-event, or disabled
|
|
44
|
+
--repo OWNER/REPO GitHub repository for release-integrity commands
|
|
45
|
+
--expected-sha SHA Expected source commit for release-integrity verification
|
|
46
|
+
--asset PATH Selected release-integrity artifact (repeatable)
|
|
41
47
|
--root PATH Fleet root containing repositories (default: current directory)
|
|
42
48
|
--create-pr Create isolated upgrade branches and pull requests
|
|
43
49
|
--version TAG Runtime tag to report in fleet upgrade branches
|
|
@@ -71,6 +77,10 @@ function parseArgs(argv) {
|
|
|
71
77
|
workflow: '',
|
|
72
78
|
mode: 'auto',
|
|
73
79
|
version: `v${readPackageVersion(packageRoot)}`,
|
|
80
|
+
repository: '',
|
|
81
|
+
expectedSha: '',
|
|
82
|
+
assets: [],
|
|
83
|
+
rootProvided: false,
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
if (command === 'release') {
|
|
@@ -80,6 +90,13 @@ function parseArgs(argv) {
|
|
|
80
90
|
`unknown release command: ${subcommand ?? '(missing)'}; use release reconcile, release hook, release validate-prs, or release recovery-plan`
|
|
81
91
|
)
|
|
82
92
|
options.releaseSubcommand = subcommand
|
|
93
|
+
} else if (command === 'release-integrity') {
|
|
94
|
+
const subcommand = argv.shift() ?? ''
|
|
95
|
+
if (!['settings', 'release', 'manifest'].includes(subcommand))
|
|
96
|
+
fail(
|
|
97
|
+
`unknown release-integrity command: ${subcommand || '(missing)'}; use release-integrity settings, release, or manifest`
|
|
98
|
+
)
|
|
99
|
+
options.integritySubcommand = /** @type {'settings'|'release'|'manifest'} */ (subcommand)
|
|
83
100
|
} else if (command === 'fleet') {
|
|
84
101
|
const subcommand = argv.shift() ?? ''
|
|
85
102
|
if (subcommand !== 'status' && subcommand !== 'upgrade')
|
|
@@ -110,12 +127,26 @@ function parseArgs(argv) {
|
|
|
110
127
|
else if (arg === '--base') options.base = argv.shift() ?? fail('--base requires a branch')
|
|
111
128
|
else if (arg === '--head') options.head = argv.shift() ?? fail('--head requires a branch')
|
|
112
129
|
else if (arg === '--tag') options.tag = argv.shift() ?? fail('--tag requires a tag')
|
|
113
|
-
else if (arg === '--
|
|
130
|
+
else if (arg === '--repo') {
|
|
131
|
+
if (command !== 'release-integrity')
|
|
132
|
+
fail('--repo is only supported by release-integrity commands')
|
|
133
|
+
options.repository = argv.shift() ?? fail('--repo requires OWNER/REPO')
|
|
134
|
+
} else if (arg === '--expected-sha') {
|
|
135
|
+
if (command !== 'release-integrity')
|
|
136
|
+
fail('--expected-sha is only supported by release-integrity commands')
|
|
137
|
+
options.expectedSha = argv.shift() ?? fail('--expected-sha requires a commit SHA')
|
|
138
|
+
} else if (arg === '--asset') {
|
|
139
|
+
if (command !== 'release-integrity')
|
|
140
|
+
fail('--asset is only supported by release-integrity commands')
|
|
141
|
+
options.assets.push(argv.shift() ?? fail('--asset requires a path'))
|
|
142
|
+
} else if (arg === '--workflow')
|
|
114
143
|
options.workflow = argv.shift() ?? fail('--workflow requires a workflow')
|
|
115
144
|
else if (arg === '--mode')
|
|
116
145
|
options.mode = argv.shift() ?? fail('--mode requires a delivery mode')
|
|
117
|
-
else if (arg === '--root')
|
|
118
|
-
|
|
146
|
+
else if (arg === '--root') {
|
|
147
|
+
options.root = argv.shift() ?? fail('--root requires a path')
|
|
148
|
+
options.rootProvided = true
|
|
149
|
+
} else if (arg === '--create-pr') options.createPr = true
|
|
119
150
|
else if (arg === '--version') options.version = argv.shift() ?? fail('--version requires a tag')
|
|
120
151
|
else if (arg === '--exclude')
|
|
121
152
|
options.exclude.push(argv.shift() ?? fail('--exclude requires a name'))
|
|
@@ -177,6 +208,32 @@ async function main() {
|
|
|
177
208
|
} catch (error) {
|
|
178
209
|
fail(error instanceof Error ? error.message : String(error))
|
|
179
210
|
}
|
|
211
|
+
} else if (command === 'release-integrity') {
|
|
212
|
+
try {
|
|
213
|
+
const { integrityCommand } = await import('./commands/release-integrity.mjs')
|
|
214
|
+
const integrityArgs = /** @type {string[]} */ ([options.integritySubcommand])
|
|
215
|
+
if (options.repository) integrityArgs.push('--repo', options.repository)
|
|
216
|
+
if (options.tag) integrityArgs.push('--tag', options.tag)
|
|
217
|
+
if (options.expectedSha) integrityArgs.push('--expected-sha', options.expectedSha)
|
|
218
|
+
if (options.rootProvided || options.integritySubcommand !== 'settings')
|
|
219
|
+
integrityArgs.push('--root', options.root)
|
|
220
|
+
for (const asset of options.assets) integrityArgs.push('--asset', asset)
|
|
221
|
+
console.log(JSON.stringify(integrityCommand(integrityArgs), null, 2))
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.log(
|
|
224
|
+
JSON.stringify(
|
|
225
|
+
{
|
|
226
|
+
schemaVersion: 1,
|
|
227
|
+
kind: 'code-foundry-release-integrity',
|
|
228
|
+
status: 'failed',
|
|
229
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
230
|
+
},
|
|
231
|
+
null,
|
|
232
|
+
2
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
process.exitCode = 1
|
|
236
|
+
}
|
|
180
237
|
} else if (command === 'fleet') {
|
|
181
238
|
try {
|
|
182
239
|
const { discoverRepositories, upgradeFleet } = await import('./commands/fleet.mjs')
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { lstatSync, readFileSync, realpathSync } from 'node:fs'
|
|
6
|
+
import { basename, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
9
|
+
|
|
10
|
+
/** @typedef {(argv: string[]) => {status: number|null, stdout: string}} Runner */
|
|
11
|
+
|
|
12
|
+
/** @type {Runner} */
|
|
13
|
+
export function runGh(argv) {
|
|
14
|
+
const result = spawnSync('gh', argv, {
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
timeout: 120000,
|
|
17
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
18
|
+
env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_HOST: 'github.com' },
|
|
19
|
+
})
|
|
20
|
+
if (result.error) throw new Error(`GitHub CLI unavailable: ${result.error.name}`)
|
|
21
|
+
return { status: result.status, stdout: result.stdout ?? '' }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {string} repository */
|
|
25
|
+
function validateRepository(repository) {
|
|
26
|
+
if (
|
|
27
|
+
!/^[\w.-]+\/[\w.-]+$/.test(repository) ||
|
|
28
|
+
repository.split('/').some((part) => ['.', '..'].includes(part))
|
|
29
|
+
)
|
|
30
|
+
throw new Error('repository must be an owner/name on github.com')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {string} tag */
|
|
34
|
+
function validateTag(tag) {
|
|
35
|
+
const hasInvalidCharacter = tag
|
|
36
|
+
? [...tag].some((character) => {
|
|
37
|
+
const codePoint = character.codePointAt(0)
|
|
38
|
+
return (
|
|
39
|
+
(codePoint !== undefined && (codePoint <= 0x20 || codePoint === 0x7f)) ||
|
|
40
|
+
'~^:?*[\\'.includes(character)
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
: false
|
|
44
|
+
if (
|
|
45
|
+
!tag ||
|
|
46
|
+
tag.startsWith('-') ||
|
|
47
|
+
hasInvalidCharacter ||
|
|
48
|
+
tag.includes('..') ||
|
|
49
|
+
tag.includes('@{')
|
|
50
|
+
)
|
|
51
|
+
throw new Error('Invalid release tag')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {Runner} run @param {string[]} argv */
|
|
55
|
+
function json(run, argv) {
|
|
56
|
+
const result = run(argv)
|
|
57
|
+
if (result.status !== 0)
|
|
58
|
+
throw new Error(
|
|
59
|
+
`GitHub verification command failed (${result.status ?? 'signal'}); state is unverified`
|
|
60
|
+
)
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(result.stdout)
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error('GitHub verification returned invalid JSON; state is unverified')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {Runner} run @param {string} endpoint */
|
|
69
|
+
function api(run, endpoint) {
|
|
70
|
+
return json(run, ['api', '--hostname', 'github.com', endpoint])
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read only. A permissions failure is unknown, never evidence of disabled settings.
|
|
74
|
+
* @param {string} repository @param {Runner} [run]
|
|
75
|
+
*/
|
|
76
|
+
export function verifyImmutableSetting(repository, run = runGh) {
|
|
77
|
+
validateRepository(repository)
|
|
78
|
+
const result = api(run, `repos/${repository}/immutable-releases`)
|
|
79
|
+
if (result?.enabled !== true) throw new Error('Release immutability is not confirmed enabled')
|
|
80
|
+
return {
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
kind: 'code-foundry-release-integrity',
|
|
83
|
+
status: 'passed',
|
|
84
|
+
repository,
|
|
85
|
+
check: 'immutable-setting',
|
|
86
|
+
enforcedByOwner: result.enforced_by_owner === true,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @param {string} root @param {string} file */
|
|
91
|
+
export function assetDigest(root, file) {
|
|
92
|
+
if (!file || isAbsolute(file)) throw new Error('Asset paths must be repository-relative')
|
|
93
|
+
const base = realpathSync(root)
|
|
94
|
+
const path = resolve(base, file)
|
|
95
|
+
const rel = relative(base, path)
|
|
96
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`))
|
|
97
|
+
throw new Error('Asset path escapes repository')
|
|
98
|
+
let current = base
|
|
99
|
+
for (const part of rel.split(sep)) {
|
|
100
|
+
current = resolve(current, part)
|
|
101
|
+
if (lstatSync(current).isSymbolicLink()) throw new Error('Release assets must not be symlinks')
|
|
102
|
+
}
|
|
103
|
+
if (!lstatSync(path).isFile()) throw new Error('Release asset must be a regular file')
|
|
104
|
+
const content = readFileSync(path)
|
|
105
|
+
if (!content.length) throw new Error('Release asset is empty')
|
|
106
|
+
return {
|
|
107
|
+
file: rel.split(sep).join('/'),
|
|
108
|
+
name: basename(path),
|
|
109
|
+
bytes: content.length,
|
|
110
|
+
digest: `sha256:${createHash('sha256').update(content).digest('hex')}`,
|
|
111
|
+
path,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {string} root @param {string[]} files */
|
|
116
|
+
export function assetManifest(root, files) {
|
|
117
|
+
if (!files.length) throw new Error('At least one artifact is required')
|
|
118
|
+
const assets = files.map((file) => assetDigest(root, file))
|
|
119
|
+
if (new Set(assets.map((asset) => asset.file)).size !== assets.length)
|
|
120
|
+
throw new Error('Duplicate artifact')
|
|
121
|
+
if (assets.some((asset) => /[\r\n\\]/.test(asset.file)))
|
|
122
|
+
throw new Error('Unsupported checksum filename')
|
|
123
|
+
return {
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
kind: 'code-foundry-asset-manifest',
|
|
126
|
+
assets: assets.map(({ path: _path, ...asset }) => asset),
|
|
127
|
+
checksums: assets.map((asset) => `${asset.digest.slice(7)} ${asset.file}\n`).join(''),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {Runner} run @param {string} repository @param {string} tag */
|
|
132
|
+
function tagCommit(run, repository, tag) {
|
|
133
|
+
let object = api(run, `repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`).object
|
|
134
|
+
for (let depth = 0; object?.type === 'tag' && depth < 8; depth += 1) {
|
|
135
|
+
if (!/^[0-9a-f]{40}$/i.test(object.sha ?? '')) throw new Error('Malformed annotated tag')
|
|
136
|
+
object = api(run, `repos/${repository}/git/tags/${object.sha}`).object
|
|
137
|
+
}
|
|
138
|
+
if (object?.type !== 'commit' || !/^[0-9a-f]{40}$/i.test(object.sha ?? ''))
|
|
139
|
+
throw new Error('Release tag does not resolve to a commit')
|
|
140
|
+
return object.sha
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** GitHub CLI performs signature/attestation verification, not merely a metadata check.
|
|
144
|
+
* @param {{repository: string, tag: string, root?: string, assets?: string[], expectedSha?: string}} options
|
|
145
|
+
* @param {Runner} [run]
|
|
146
|
+
*/
|
|
147
|
+
export function verifyRelease(options, run = runGh) {
|
|
148
|
+
const { repository, tag } = options
|
|
149
|
+
validateRepository(repository)
|
|
150
|
+
validateTag(tag)
|
|
151
|
+
if (options.expectedSha && !/^[0-9a-f]{40}$/i.test(options.expectedSha))
|
|
152
|
+
throw new Error('expected-sha must be a full commit SHA')
|
|
153
|
+
const release = api(run, `repos/${repository}/releases/tags/${encodeURIComponent(tag)}`)
|
|
154
|
+
if (release?.immutable !== true || release.draft !== false || release.tag_name !== tag)
|
|
155
|
+
throw new Error('Expected a published immutable release with the exact requested tag')
|
|
156
|
+
json(run, ['release', 'verify', tag, '--repo', repository, '--format', 'json'])
|
|
157
|
+
const sourceSha = tagCommit(run, repository, tag)
|
|
158
|
+
if (options.expectedSha && options.expectedSha.toLowerCase() !== sourceSha.toLowerCase())
|
|
159
|
+
throw new Error('Release tag does not match the expected source SHA')
|
|
160
|
+
const assets = []
|
|
161
|
+
for (const file of options.assets ?? []) {
|
|
162
|
+
const asset = assetDigest(options.root ?? process.cwd(), file)
|
|
163
|
+
json(run, [
|
|
164
|
+
'release',
|
|
165
|
+
'verify-asset',
|
|
166
|
+
tag,
|
|
167
|
+
asset.path,
|
|
168
|
+
'--repo',
|
|
169
|
+
repository,
|
|
170
|
+
'--format',
|
|
171
|
+
'json',
|
|
172
|
+
])
|
|
173
|
+
// Rehash after signature verification so a replaced local file is not reported verified.
|
|
174
|
+
if (assetDigest(options.root ?? process.cwd(), file).digest !== asset.digest)
|
|
175
|
+
throw new Error('Local release asset changed during verification')
|
|
176
|
+
assets.push({ file: asset.file, bytes: asset.bytes, digest: asset.digest, status: 'verified' })
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
schemaVersion: 1,
|
|
180
|
+
kind: 'code-foundry-release-integrity',
|
|
181
|
+
status: 'passed',
|
|
182
|
+
check: 'release-attestation',
|
|
183
|
+
repository,
|
|
184
|
+
tag,
|
|
185
|
+
sourceSha,
|
|
186
|
+
immutable: true,
|
|
187
|
+
assets,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @param {string[]} argv @param {Runner} [run] */
|
|
192
|
+
export function integrityCommand(argv, run = runGh) {
|
|
193
|
+
const [command, ...args] = argv
|
|
194
|
+
/** @type {Record<string, string>} */
|
|
195
|
+
const values = {}
|
|
196
|
+
const assets = []
|
|
197
|
+
while (args.length) {
|
|
198
|
+
const key = args.shift()
|
|
199
|
+
if (!key || !['--repo', '--tag', '--root', '--asset', '--expected-sha'].includes(key))
|
|
200
|
+
throw new Error(`Unknown argument: ${key}`)
|
|
201
|
+
const value = args.shift()
|
|
202
|
+
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${key}`)
|
|
203
|
+
if (key === '--asset') assets.push(value)
|
|
204
|
+
else {
|
|
205
|
+
if (Object.hasOwn(values, key)) throw new Error(`Duplicate ${key}`)
|
|
206
|
+
values[key] = value
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const allowed =
|
|
210
|
+
command === 'settings'
|
|
211
|
+
? ['--repo']
|
|
212
|
+
: command === 'manifest'
|
|
213
|
+
? ['--root']
|
|
214
|
+
: ['--repo', '--tag', '--root', '--expected-sha']
|
|
215
|
+
if (
|
|
216
|
+
Object.keys(values).some((key) => !allowed.includes(key)) ||
|
|
217
|
+
(command === 'settings' && assets.length)
|
|
218
|
+
)
|
|
219
|
+
throw new Error('An argument is not supported by this integrity command')
|
|
220
|
+
if (command === 'settings') return verifyImmutableSetting(values['--repo'] ?? '', run)
|
|
221
|
+
if (command === 'manifest') return assetManifest(values['--root'] ?? process.cwd(), assets)
|
|
222
|
+
if (command === 'release')
|
|
223
|
+
return verifyRelease(
|
|
224
|
+
{
|
|
225
|
+
repository: values['--repo'] ?? '',
|
|
226
|
+
tag: values['--tag'] ?? '',
|
|
227
|
+
root: values['--root'],
|
|
228
|
+
expectedSha: values['--expected-sha'],
|
|
229
|
+
assets,
|
|
230
|
+
},
|
|
231
|
+
run
|
|
232
|
+
)
|
|
233
|
+
throw new Error('Use release-integrity settings, release, or manifest')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
237
|
+
try {
|
|
238
|
+
console.log(JSON.stringify(integrityCommand(process.argv.slice(2)), null, 2))
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.log(
|
|
241
|
+
JSON.stringify(
|
|
242
|
+
{
|
|
243
|
+
schemaVersion: 1,
|
|
244
|
+
kind: 'code-foundry-release-integrity',
|
|
245
|
+
status: 'failed',
|
|
246
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
247
|
+
},
|
|
248
|
+
null,
|
|
249
|
+
2
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
process.exitCode = 1
|
|
253
|
+
}
|
|
254
|
+
}
|
package/src/commands/sync.mjs
CHANGED
|
@@ -48,6 +48,7 @@ const standardFiles = [
|
|
|
48
48
|
'.github/workflows/draft-pr.yml',
|
|
49
49
|
'.github/workflows/release-pr.yml',
|
|
50
50
|
'.github/workflows/release.yml',
|
|
51
|
+
'.github/workflows/release-integrity.yml',
|
|
51
52
|
'.github/workflows/opencode-security.yml',
|
|
52
53
|
]
|
|
53
54
|
|
|
@@ -241,7 +242,15 @@ export function syncRepository(options) {
|
|
|
241
242
|
}
|
|
242
243
|
if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
|
|
243
244
|
content = Buffer.from(
|
|
244
|
-
renderWorkflow(
|
|
245
|
+
renderWorkflow(
|
|
246
|
+
content.toString('utf8'),
|
|
247
|
+
config,
|
|
248
|
+
runtimeRepository,
|
|
249
|
+
runtimeRef,
|
|
250
|
+
rustCodeql,
|
|
251
|
+
file,
|
|
252
|
+
target === source
|
|
253
|
+
)
|
|
245
254
|
)
|
|
246
255
|
}
|
|
247
256
|
if (file === '.github/dependabot.yml') {
|
|
@@ -492,6 +501,7 @@ function shouldInclude(file, languages, features, config) {
|
|
|
492
501
|
// without a configuration change. The detect job keeps the scan off unless
|
|
493
502
|
// the configuration or the variable enables it and the API key exists.
|
|
494
503
|
if (file === '.github/workflows/opencode-security.yml') return true
|
|
504
|
+
if (file === '.github/workflows/release-integrity.yml') return true
|
|
495
505
|
const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
|
|
496
506
|
if (workflow === 'draft-control' || workflow === 'draft-enforcement') {
|
|
497
507
|
return (
|
|
@@ -535,8 +545,10 @@ function sourcePath(source, file) {
|
|
|
535
545
|
* @param {string} repository
|
|
536
546
|
* @param {string} ref
|
|
537
547
|
* @param {{ shards: string, threads: string, maxParallel: string }} rustCodeql
|
|
548
|
+
* @param {string} file
|
|
549
|
+
* @param {boolean} selfRepository
|
|
538
550
|
*/
|
|
539
|
-
function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
551
|
+
function renderWorkflow(content, config, repository, ref, rustCodeql, file, selfRepository) {
|
|
540
552
|
const localPrefix = 'uses: ./.github/workflows/'
|
|
541
553
|
const remotePrefix = `uses: ${repository}/.github/workflows/`
|
|
542
554
|
let rendered = content.replaceAll(localPrefix, remotePrefix)
|
|
@@ -576,7 +588,15 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
|
576
588
|
'release-pr': config.pr_runner ?? config.runner,
|
|
577
589
|
release: config.release_runner ?? config.runner,
|
|
578
590
|
}
|
|
579
|
-
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
|
+
}
|
|
580
600
|
// The staging-release topology validates and scans pull requests against
|
|
581
601
|
// both main and the integration branch; direct repositories only ever
|
|
582
602
|
// target main, so their callers trigger on main alone.
|
|
@@ -667,6 +687,26 @@ function removeWorkflowBlock(content, blockId) {
|
|
|
667
687
|
return lines.join('\n')
|
|
668
688
|
}
|
|
669
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
|
+
|
|
670
710
|
/**
|
|
671
711
|
* Dependabot updates land on the repository's integration branch. Direct
|
|
672
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
|
+
}
|