code-foundry 1.17.0 → 1.18.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.
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
name: Qualified Foundry Publication
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_call:
|
|
5
|
+
inputs:
|
|
6
|
+
tag:
|
|
7
|
+
description: Published immutable release tag at the caller commit.
|
|
8
|
+
required: true
|
|
9
|
+
type: string
|
|
10
|
+
asset:
|
|
11
|
+
description: Exact pre-attached Code Foundry npm tarball name.
|
|
12
|
+
required: true
|
|
13
|
+
type: string
|
|
14
|
+
environment:
|
|
15
|
+
description: Protected GitHub environment for npm publication.
|
|
16
|
+
required: false
|
|
17
|
+
default: npm
|
|
18
|
+
type: string
|
|
19
|
+
secrets:
|
|
20
|
+
NPM_TOKEN:
|
|
21
|
+
required: false
|
|
22
|
+
|
|
23
|
+
permissions:
|
|
24
|
+
attestations: read
|
|
25
|
+
contents: read
|
|
26
|
+
|
|
27
|
+
jobs:
|
|
28
|
+
qualify:
|
|
29
|
+
name: Qualify Node ${{ matrix.node }}
|
|
30
|
+
if: vars.CI_BILLING_PAUSED != 'true' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
|
31
|
+
runs-on: ubuntu-24.04
|
|
32
|
+
timeout-minutes: 15
|
|
33
|
+
strategy:
|
|
34
|
+
fail-fast: false
|
|
35
|
+
matrix:
|
|
36
|
+
node: ['20', '22', '24']
|
|
37
|
+
steps:
|
|
38
|
+
- name: Checkout trusted qualification runtime
|
|
39
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
40
|
+
with:
|
|
41
|
+
persist-credentials: false
|
|
42
|
+
ref: ${{ github.sha }}
|
|
43
|
+
- name: Setup Node
|
|
44
|
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
|
45
|
+
with:
|
|
46
|
+
node-version: ${{ matrix.node }}
|
|
47
|
+
- name: Install pinned Actionlint
|
|
48
|
+
run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
|
49
|
+
- name: Download and cryptographically verify the release archive
|
|
50
|
+
env:
|
|
51
|
+
GH_TOKEN: ${{ github.token }}
|
|
52
|
+
RELEASE_TAG: ${{ inputs.tag }}
|
|
53
|
+
RELEASE_ASSET: ${{ inputs.asset }}
|
|
54
|
+
run: |
|
|
55
|
+
set -euo pipefail
|
|
56
|
+
node src/commands/qualified-publication.mjs download \
|
|
57
|
+
"$GITHUB_REPOSITORY" "$RELEASE_TAG" "$GITHUB_SHA" "$RELEASE_ASSET" \
|
|
58
|
+
"$RUNNER_TEMP/candidate" > "$RUNNER_TEMP/download.json"
|
|
59
|
+
- name: Qualify those exact bytes
|
|
60
|
+
env:
|
|
61
|
+
RELEASE_ASSET: ${{ inputs.asset }}
|
|
62
|
+
run: |
|
|
63
|
+
set -euo pipefail
|
|
64
|
+
node src/lib/consumer-qualification.mjs \
|
|
65
|
+
"$RUNNER_TEMP/candidate/$RELEASE_ASSET" "$GITHUB_SHA" \
|
|
66
|
+
"$RUNNER_TEMP/report.json" "$(go env GOPATH)/bin/actionlint"
|
|
67
|
+
- name: Retain this attempt's qualification report
|
|
68
|
+
if: always()
|
|
69
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
70
|
+
with:
|
|
71
|
+
name: qualified-${{ github.run_attempt }}-node-${{ matrix.node }}
|
|
72
|
+
path: ${{ runner.temp }}/report.json
|
|
73
|
+
if-no-files-found: error
|
|
74
|
+
retention-days: 14
|
|
75
|
+
|
|
76
|
+
publish:
|
|
77
|
+
name: Publish qualified archive
|
|
78
|
+
needs: qualify
|
|
79
|
+
if: vars.CI_BILLING_PAUSED != 'true' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
|
80
|
+
runs-on: ubuntu-24.04
|
|
81
|
+
timeout-minutes: 15
|
|
82
|
+
environment: ${{ inputs.environment }}
|
|
83
|
+
concurrency:
|
|
84
|
+
group: qualified-foundry-npm-${{ github.repository }}-${{ inputs.tag }}
|
|
85
|
+
cancel-in-progress: false
|
|
86
|
+
permissions:
|
|
87
|
+
actions: read
|
|
88
|
+
attestations: read
|
|
89
|
+
contents: read
|
|
90
|
+
id-token: write
|
|
91
|
+
env:
|
|
92
|
+
HAS_NPM_TOKEN: ${{ secrets.NPM_TOKEN != '' }}
|
|
93
|
+
RELEASE_TAG: ${{ inputs.tag }}
|
|
94
|
+
RELEASE_ASSET: ${{ inputs.asset }}
|
|
95
|
+
steps:
|
|
96
|
+
- name: Checkout trusted publication runtime
|
|
97
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
98
|
+
with:
|
|
99
|
+
persist-credentials: false
|
|
100
|
+
ref: ${{ github.sha }}
|
|
101
|
+
- name: Setup Node for trusted publishing
|
|
102
|
+
if: env.HAS_NPM_TOKEN != 'true'
|
|
103
|
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
|
104
|
+
with:
|
|
105
|
+
node-version: '24'
|
|
106
|
+
- name: Setup Node for token publishing
|
|
107
|
+
if: env.HAS_NPM_TOKEN == 'true'
|
|
108
|
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
|
109
|
+
with:
|
|
110
|
+
node-version: '24'
|
|
111
|
+
registry-url: https://registry.npmjs.org
|
|
112
|
+
- name: Retrieve only this run attempt's evidence
|
|
113
|
+
env:
|
|
114
|
+
GH_TOKEN: ${{ github.token }}
|
|
115
|
+
run: |
|
|
116
|
+
set -euo pipefail
|
|
117
|
+
for node in 20 22 24; do
|
|
118
|
+
gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
|
119
|
+
--name "qualified-$GITHUB_RUN_ATTEMPT-node-$node" \
|
|
120
|
+
--dir "$RUNNER_TEMP/reports/$node"
|
|
121
|
+
done
|
|
122
|
+
node src/commands/qualified-publication.mjs download \
|
|
123
|
+
"$GITHUB_REPOSITORY" "$RELEASE_TAG" "$GITHUB_SHA" "$RELEASE_ASSET" \
|
|
124
|
+
"$RUNNER_TEMP/candidate" > "$RUNNER_TEMP/download.json"
|
|
125
|
+
- name: Reverify and publish the tested tarball without lifecycle hooks
|
|
126
|
+
env:
|
|
127
|
+
GH_TOKEN: ${{ github.token }}
|
|
128
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
129
|
+
run: |
|
|
130
|
+
set -euo pipefail
|
|
131
|
+
node src/commands/qualified-publication.mjs publish \
|
|
132
|
+
"$GITHUB_REPOSITORY" "$RELEASE_TAG" "$GITHUB_SHA" "$RELEASE_ASSET" \
|
|
133
|
+
"$RUNNER_TEMP/candidate" \
|
|
134
|
+
"$RUNNER_TEMP/reports/20/report.json" \
|
|
135
|
+
"$RUNNER_TEMP/reports/22/report.json" \
|
|
136
|
+
"$RUNNER_TEMP/reports/24/report.json" > "$RUNNER_TEMP/publication.json"
|
|
137
|
+
- name: Retain publication identity
|
|
138
|
+
if: always()
|
|
139
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
140
|
+
with:
|
|
141
|
+
name: qualified-publication-${{ github.run_attempt }}
|
|
142
|
+
path: ${{ runner.temp }}/publication.json
|
|
143
|
+
if-no-files-found: warn
|
|
144
|
+
retention-days: 14
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.18.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.17.0...v1.18.0) (2026-09-09)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **releases:** stage qualified assets and publish verified identical archives ([#547](https://github.com/0xPlayerOne/code-foundry/issues/547)) ([dffcb83](https://github.com/0xPlayerOne/code-foundry/commit/dffcb834c8ee8c5c2bcba606350276e6a7de58d0))
|
|
9
|
+
|
|
3
10
|
## [1.17.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.16.0...v1.17.0) (2026-09-09)
|
|
4
11
|
|
|
5
12
|
|
package/docs/PERFORMANCE.md
CHANGED
|
@@ -15,8 +15,8 @@ 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 |
|
|
18
|
+
| Packed artifact | 250 kB | Bound registry transfer and install cost |
|
|
19
|
+
| Unpacked artifact | 930 kB | Bound installed footprint |
|
|
20
20
|
| Packed files | 110 | Detect accidental release contents |
|
|
21
21
|
|
|
22
22
|
The performance workflow disables build-cache reads and writes for this task.
|
|
@@ -30,6 +30,10 @@ budgets leave a small margin while continuing to bound package growth. The
|
|
|
30
30
|
product-quality profiles and consumer qualification workflow measured 232,650
|
|
31
31
|
packed bytes, 890,418 unpacked bytes, and 103 files on Node 24.18.0, so the
|
|
32
32
|
limits remain intentionally bounded while covering both opt-in feature sets.
|
|
33
|
+
The qualified-publication workflow adds the release staging and publication
|
|
34
|
+
contract to the distributable. This candidate measured 238,816 packed bytes,
|
|
35
|
+
915,957 unpacked bytes, and 106 files on Node 24.18.0; the 250 kB and 930 kB
|
|
36
|
+
limits retain a measured margin while covering the required release path.
|
|
33
37
|
|
|
34
38
|
## v1.6.1 baseline
|
|
35
39
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Qualified Publication
|
|
2
|
+
|
|
3
|
+
Publish only the same immutable Code Foundry archive that passed consumer qualification.
|
|
4
|
+
|
|
5
|
+
**Dependencies:** Consumer qualification (#544) and release integrity (#537).
|
|
6
|
+
**Activation:** Opt-in replacement publisher; no existing publisher is silently changed.
|
|
7
|
+
|
|
8
|
+
The reusable `qualified-foundry-publish.yml` downloads an explicitly named npm
|
|
9
|
+
archive from an already-published immutable release, verifies the release and
|
|
10
|
+
asset attestations against the caller's exact commit, qualifies that same archive
|
|
11
|
+
on Node 20/22/24, and only then admits a protected npm publication job. The final
|
|
12
|
+
job downloads only reports from its own workflow run **and run attempt**, rechecks
|
|
13
|
+
all required fixtures and archive/source identities, repeats cryptographic asset
|
|
14
|
+
verification, validates the package name/version, and publishes the tarball with
|
|
15
|
+
lifecycle scripts disabled. It never publishes a directory or rebuilds the package.
|
|
16
|
+
|
|
17
|
+
The publishing job uses a GitHub environment, serializes publication for a tag,
|
|
18
|
+
and has no dependency installation/build step. Qualification has no npm credential.
|
|
19
|
+
Normal npm trusted publishing is preferred; an explicit optional token supports
|
|
20
|
+
existing consumers. Configure the trusted publisher for the **actual caller and
|
|
21
|
+
reusable-workflow relationship** before enabling this route. Existing version
|
|
22
|
+
publication is not overwritten: retrying an already-published version fails rather
|
|
23
|
+
than treating a registry conflict or network error as proof of identity.
|
|
24
|
+
|
|
25
|
+
## Release producer contract
|
|
26
|
+
|
|
27
|
+
Build and test a package once, attach its tarball to a **draft** release, then
|
|
28
|
+
publish that release with immutability enabled. The archive must contain the
|
|
29
|
+
Code Foundry CLI/templates; this is intentionally not a generic package harness.
|
|
30
|
+
The candidate's `package.json` name must be `code-foundry` and its version must
|
|
31
|
+
match the explicit tag. All qualification modules must exist in the caller commit.
|
|
32
|
+
A recent GitHub CLI with `release verify` and `release verify-asset` is required;
|
|
33
|
+
missing support or authentication errors fail closed.
|
|
34
|
+
|
|
35
|
+
This workflow blocks npm publication, not a GitHub Release that was already
|
|
36
|
+
published. It does **not** add assets after an immutable release is published.
|
|
37
|
+
The `stage` command implements the draft-asset producer: it verifies qualification,
|
|
38
|
+
checks immutability without changing settings, resolves the existing tag to the
|
|
39
|
+
qualified commit, uploads the archive and digest-bound qualification receipt,
|
|
40
|
+
rechecks uploaded digests, then publishes and verifies the release. It requires
|
|
41
|
+
an existing draft and existing tag; it never creates/moves tags or overwrites
|
|
42
|
+
conflicting assets. Settings permission failures block writes. Matching existing
|
|
43
|
+
assets can resume staging; conflicting assets require manual reconciliation.
|
|
44
|
+
The pre-release gate in #544 supplies the required matrix reports.
|
|
45
|
+
The legacy Release Please workflow creates releases without
|
|
46
|
+
this archive staging step. Wire the new `stage` command into a draft-producing
|
|
47
|
+
caller instead of pointing the old direct-release caller at the new publisher:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
node src/commands/qualified-publication.mjs stage \
|
|
51
|
+
"$GITHUB_REPOSITORY" "$TAG" "$SOURCE_SHA" "$ASSET" "$CANDIDATE_DIRECTORY" \
|
|
52
|
+
"$REPORT_NODE_20" "$REPORT_NODE_22" "$REPORT_NODE_24"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The stage command needs a credential with immutable-setting read, release
|
|
56
|
+
write, and attestation-read access; a normal Actions token may lack the
|
|
57
|
+
administration-read permission.
|
|
58
|
+
No elevated credential is installed or requested automatically. The producer must
|
|
59
|
+
also prevent concurrent tag mutation (for example with protected release-tag
|
|
60
|
+
rules and a single tag-scoped producer); GitHub does not offer an atomic
|
|
61
|
+
"publish this draft only if the tag still resolves to SHA" operation. The final
|
|
62
|
+
verification blocks npm if that invariant is violated, but cannot undo an
|
|
63
|
+
already-published immutable release.
|
|
64
|
+
|
|
65
|
+
Disable the old npm path before
|
|
66
|
+
activating the replacement to prevent racing publishers. None of those production
|
|
67
|
+
settings or existing release workflows are changed in this PR.
|
|
68
|
+
|
|
69
|
+
Example caller job after its release producer (illustrative job IDs):
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
permissions:
|
|
73
|
+
actions: read
|
|
74
|
+
attestations: read
|
|
75
|
+
contents: read
|
|
76
|
+
id-token: write
|
|
77
|
+
jobs:
|
|
78
|
+
publish:
|
|
79
|
+
needs: release-producer
|
|
80
|
+
uses: ./.github/workflows/qualified-foundry-publish.yml
|
|
81
|
+
with:
|
|
82
|
+
tag: ${{ needs.release-producer.outputs.tag }}
|
|
83
|
+
asset: ${{ needs.release-producer.outputs.npm-asset }}
|
|
84
|
+
environment: npm
|
|
85
|
+
secrets:
|
|
86
|
+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Only main-branch `push` and `workflow_dispatch` callers are admitted. Pull-request
|
|
90
|
+
events (including fork PRs), release-event shortcuts, and billing-paused runs
|
|
91
|
+
cannot publish through this workflow. The event guard does not itself verify
|
|
92
|
+
branch protection or prohibit a fork's independent main-branch workflow; configure
|
|
93
|
+
branch/environment protections and registry publisher identity separately.
|
|
94
|
+
The tag must resolve to `github.sha`, not a caller-selected old commit. To retry
|
|
95
|
+
an older release after main moves, use a separately reviewed recovery procedure;
|
|
96
|
+
do not weaken the identity gate ad hoc.
|
|
97
|
+
|
|
98
|
+
## Local policy tests and trust
|
|
99
|
+
|
|
100
|
+
`node --test test/qualified-publication.test.mjs` uses CLI/verifier fixtures to
|
|
101
|
+
exercise missing matrix members, skipped checks, absent Actionlint, changed
|
|
102
|
+
archives, invalid asset names, wrong package identity, and prevention of npm
|
|
103
|
+
execution before verification. These tests do not establish live GitHub signing,
|
|
104
|
+
OIDC permissions, registry publication, or runner tool availability.
|
|
105
|
+
|
|
106
|
+
Qualification reports are not standalone signatures: they are trusted only after
|
|
107
|
+
selection from this workflow's successful jobs in the same run attempt. Supplying
|
|
108
|
+
arbitrary local JSON to the library is not a security boundary. The reusable
|
|
109
|
+
workflow and its caller must be reviewed/trusted and protected; repository-owned
|
|
110
|
+
code executes with the permissions of its job. No credentials or production
|
|
111
|
+
resources were configured by adding this feature.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-foundry",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.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": {
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import {
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs'
|
|
15
|
+
import { join, resolve } from 'node:path'
|
|
16
|
+
import { pathToFileURL } from 'node:url'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
|
|
19
|
+
export const REQUIRED_NODES = ['20', '22', '24']
|
|
20
|
+
export const REQUIRED_FIXTURES = [
|
|
21
|
+
'npm-direct',
|
|
22
|
+
'pnpm-direct',
|
|
23
|
+
'yarn-staging',
|
|
24
|
+
'bun-mise',
|
|
25
|
+
'nested-rust',
|
|
26
|
+
'nested-python',
|
|
27
|
+
]
|
|
28
|
+
/** @typedef {{ repository: string, tag: string, sourceSha: string, asset: string, directory: string }} Candidate */
|
|
29
|
+
/** @typedef {(command:string, args:string[], cwd?:string) => string} Run */
|
|
30
|
+
/** @param {unknown} condition @param {string} message @returns {asserts condition} */
|
|
31
|
+
function ensure(condition, message) {
|
|
32
|
+
if (!condition) throw new Error(message)
|
|
33
|
+
}
|
|
34
|
+
/** @type {Run} */
|
|
35
|
+
export function runCommand(command, args, cwd) {
|
|
36
|
+
const result = spawnSync(command, args, {
|
|
37
|
+
cwd,
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
timeout: 180_000,
|
|
40
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
41
|
+
env: { ...process.env, GH_HOST: 'github.com', GH_PROMPT_DISABLED: '1' },
|
|
42
|
+
})
|
|
43
|
+
ensure(
|
|
44
|
+
!result.error && result.status === 0,
|
|
45
|
+
`${command} failed (${result.status ?? result.error?.message}); publication remains blocked`
|
|
46
|
+
)
|
|
47
|
+
return result.stdout
|
|
48
|
+
}
|
|
49
|
+
/** @param {Candidate} candidate */
|
|
50
|
+
export function validateCandidate(candidate) {
|
|
51
|
+
ensure(
|
|
52
|
+
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate.repository) &&
|
|
53
|
+
!candidate.repository.split('/').some((part) => ['.', '..'].includes(part)),
|
|
54
|
+
'Expected an owner/repository'
|
|
55
|
+
)
|
|
56
|
+
ensure(
|
|
57
|
+
/^v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(candidate.tag),
|
|
58
|
+
'Expected an explicit version tag'
|
|
59
|
+
)
|
|
60
|
+
ensure(/^[a-f0-9]{40}$/.test(candidate.sourceSha), 'Expected an exact source SHA')
|
|
61
|
+
ensure(
|
|
62
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(candidate.asset),
|
|
63
|
+
'Expected one exact tarball asset name'
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
/** @param {string} file */
|
|
67
|
+
export function sha256(file) {
|
|
68
|
+
ensure(
|
|
69
|
+
lstatSync(file).isFile() && !lstatSync(file).isSymbolicLink(),
|
|
70
|
+
'Expected a regular non-symlink file'
|
|
71
|
+
)
|
|
72
|
+
const bytes = readFileSync(file)
|
|
73
|
+
ensure(bytes.length > 0, 'Empty artifact or report')
|
|
74
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
75
|
+
}
|
|
76
|
+
/** Pure policy check. Only reports from this workflow run are accepted by the caller.
|
|
77
|
+
* @param {unknown[]} reports @param {string} sourceSha @param {string} artifactSha256
|
|
78
|
+
*/
|
|
79
|
+
export function validateQualificationReports(reports, sourceSha, artifactSha256) {
|
|
80
|
+
ensure(
|
|
81
|
+
reports.length === REQUIRED_NODES.length,
|
|
82
|
+
'Every required Node qualification report is mandatory'
|
|
83
|
+
)
|
|
84
|
+
const seen = new Set()
|
|
85
|
+
for (const value of reports) {
|
|
86
|
+
const report = /** @type {Record<string, any>} */ (value)
|
|
87
|
+
ensure(
|
|
88
|
+
report?.schema_version === 1 && report.complete === true && report.actionlint === true,
|
|
89
|
+
'Incomplete or unlinted qualification report'
|
|
90
|
+
)
|
|
91
|
+
ensure(
|
|
92
|
+
report.source_sha === sourceSha && report.artifact_sha256 === artifactSha256,
|
|
93
|
+
'Qualification source or archive identity mismatch'
|
|
94
|
+
)
|
|
95
|
+
const node = /^v(\d+)\.\d+\.\d+$/.exec(report.node ?? '')?.[1]
|
|
96
|
+
ensure(
|
|
97
|
+
node && REQUIRED_NODES.includes(node) && !seen.has(node),
|
|
98
|
+
'Unexpected or duplicate Node qualification'
|
|
99
|
+
)
|
|
100
|
+
seen.add(node)
|
|
101
|
+
ensure(
|
|
102
|
+
Array.isArray(report.fixtures) && report.fixtures.length === REQUIRED_FIXTURES.length,
|
|
103
|
+
'Incomplete consumer fixture matrix'
|
|
104
|
+
)
|
|
105
|
+
for (const fixture of REQUIRED_FIXTURES)
|
|
106
|
+
ensure(
|
|
107
|
+
report.fixtures.filter(
|
|
108
|
+
(/** @type {Record<string,any>} */ entry) =>
|
|
109
|
+
entry.name === fixture && entry.status === 'passed'
|
|
110
|
+
).length === 1,
|
|
111
|
+
`Missing successful fixture: ${fixture}`
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
nodes: REQUIRED_NODES.filter((node) => seen.has(node)),
|
|
116
|
+
fixtures: [...REQUIRED_FIXTURES],
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** @param {Candidate} candidate */
|
|
120
|
+
async function verifier(candidate) {
|
|
121
|
+
// Supplied by the separately reviewed release-integrity feature (#537).
|
|
122
|
+
const { verifyRelease } = await import('./release-integrity.mjs')
|
|
123
|
+
return verifyRelease({
|
|
124
|
+
repository: candidate.repository,
|
|
125
|
+
tag: candidate.tag,
|
|
126
|
+
expectedSha: candidate.sourceSha,
|
|
127
|
+
root: candidate.directory,
|
|
128
|
+
assets: [candidate.asset],
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
/** @typedef {(candidate:Candidate) => Promise<any>} Verify */
|
|
132
|
+
/** @param {Candidate} candidate @param {{run?:Run, verify?:Verify}} [adapters] */
|
|
133
|
+
export async function downloadVerifiedArchive(candidate, adapters = {}) {
|
|
134
|
+
validateCandidate(candidate)
|
|
135
|
+
const run = adapters.run ?? runCommand
|
|
136
|
+
const verify = adapters.verify ?? verifier
|
|
137
|
+
mkdirSync(candidate.directory, { recursive: true })
|
|
138
|
+
ensure(
|
|
139
|
+
lstatSync(candidate.directory).isDirectory() &&
|
|
140
|
+
!lstatSync(candidate.directory).isSymbolicLink(),
|
|
141
|
+
'Download directory must be a real directory'
|
|
142
|
+
)
|
|
143
|
+
ensure(readdirSync(candidate.directory).length === 0, 'Use a fresh empty download directory')
|
|
144
|
+
run('gh', [
|
|
145
|
+
'release',
|
|
146
|
+
'download',
|
|
147
|
+
candidate.tag,
|
|
148
|
+
'--repo',
|
|
149
|
+
candidate.repository,
|
|
150
|
+
'--pattern',
|
|
151
|
+
candidate.asset,
|
|
152
|
+
'--dir',
|
|
153
|
+
candidate.directory,
|
|
154
|
+
])
|
|
155
|
+
ensure(
|
|
156
|
+
readdirSync(candidate.directory).length === 1 &&
|
|
157
|
+
readdirSync(candidate.directory)[0] === candidate.asset,
|
|
158
|
+
'Unexpected downloaded release files'
|
|
159
|
+
)
|
|
160
|
+
const file = join(realpathSync(candidate.directory), candidate.asset)
|
|
161
|
+
const digest = sha256(file)
|
|
162
|
+
const identity = await verify(candidate)
|
|
163
|
+
ensure(
|
|
164
|
+
identity.status === 'passed' &&
|
|
165
|
+
identity.immutable === true &&
|
|
166
|
+
identity.sourceSha === candidate.sourceSha,
|
|
167
|
+
'Release verification did not establish candidate identity'
|
|
168
|
+
)
|
|
169
|
+
ensure(
|
|
170
|
+
identity.assets?.length === 1 && identity.assets[0].digest === `sha256:${digest}`,
|
|
171
|
+
'Verified release asset differs from downloaded bytes'
|
|
172
|
+
)
|
|
173
|
+
ensure(sha256(file) === digest, 'Archive changed during verification')
|
|
174
|
+
return { file, digest, identity }
|
|
175
|
+
}
|
|
176
|
+
/** @param {Candidate} candidate @param {string[]} reportPaths @param {Run} [run] */
|
|
177
|
+
export function qualifyArchive(candidate, reportPaths, run = runCommand) {
|
|
178
|
+
validateCandidate(candidate)
|
|
179
|
+
const file = join(realpathSync(candidate.directory), candidate.asset)
|
|
180
|
+
const digest = sha256(file)
|
|
181
|
+
const reports = reportPaths.map((path) => {
|
|
182
|
+
sha256(path)
|
|
183
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
184
|
+
})
|
|
185
|
+
const qualification = validateQualificationReports(reports, candidate.sourceSha, digest)
|
|
186
|
+
// Read one manifest without extracting archive paths into the workspace.
|
|
187
|
+
const manifest = JSON.parse(run('tar', ['-xOf', file, 'package/package.json']))
|
|
188
|
+
ensure(
|
|
189
|
+
manifest.name === 'code-foundry' && manifest.version === candidate.tag.slice(1),
|
|
190
|
+
'Archive package identity does not match this Code Foundry release'
|
|
191
|
+
)
|
|
192
|
+
return {
|
|
193
|
+
schemaVersion: 1,
|
|
194
|
+
kind: 'code-foundry-publication-eligibility',
|
|
195
|
+
repository: candidate.repository,
|
|
196
|
+
tag: candidate.tag,
|
|
197
|
+
sourceSha: candidate.sourceSha,
|
|
198
|
+
asset: candidate.asset,
|
|
199
|
+
digest: `sha256:${digest}`,
|
|
200
|
+
qualification,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** @param {Candidate} candidate @param {string[]} reports @param {{run?:Run, verify?:Verify}} [adapters] */
|
|
204
|
+
export async function publishQualifiedArchive(candidate, reports, adapters = {}) {
|
|
205
|
+
const run = adapters.run ?? runCommand
|
|
206
|
+
const before = qualifyArchive(candidate, reports, run)
|
|
207
|
+
const identity = await (adapters.verify ?? verifier)(candidate)
|
|
208
|
+
ensure(
|
|
209
|
+
identity.status === 'passed' &&
|
|
210
|
+
identity.immutable === true &&
|
|
211
|
+
identity.sourceSha === before.sourceSha &&
|
|
212
|
+
identity.assets?.length === 1 &&
|
|
213
|
+
identity.assets[0].digest === before.digest,
|
|
214
|
+
'Current release verification does not match qualified bytes'
|
|
215
|
+
)
|
|
216
|
+
const file = join(realpathSync(candidate.directory), candidate.asset)
|
|
217
|
+
ensure(`sha256:${sha256(file)}` === before.digest, 'Qualified archive changed before publication')
|
|
218
|
+
// Never publish a directory, rebuild, or invoke package lifecycle hooks here.
|
|
219
|
+
run(
|
|
220
|
+
'npm',
|
|
221
|
+
['publish', file, '--ignore-scripts', '--provenance', '--access', 'public'],
|
|
222
|
+
candidate.directory
|
|
223
|
+
)
|
|
224
|
+
ensure(`sha256:${sha256(file)}` === before.digest, 'Archive changed during publication')
|
|
225
|
+
return { ...before, status: 'published' }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Stage only a previously-created draft: never move tags, overwrite assets, or
|
|
229
|
+
* treat an API/permissions failure as an absent release. Authentication needs
|
|
230
|
+
* immutable-settings read and release write; no setting is mutated.
|
|
231
|
+
* @param {Candidate} candidate @param {string[]} reports @param {{run?:Run, verify?:Verify}} [adapters]
|
|
232
|
+
*/
|
|
233
|
+
export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
|
|
234
|
+
const run = adapters.run ?? runCommand
|
|
235
|
+
const qualification = qualifyArchive(candidate, reports, run)
|
|
236
|
+
const api = (/** @type {string} */ endpoint) =>
|
|
237
|
+
JSON.parse(run('gh', ['api', '--hostname', 'github.com', endpoint]))
|
|
238
|
+
const prefix = `repos/${candidate.repository}`
|
|
239
|
+
ensure(
|
|
240
|
+
api(`${prefix}/immutable-releases`).enabled === true,
|
|
241
|
+
'Release immutability is not confirmed enabled; no release write performed'
|
|
242
|
+
)
|
|
243
|
+
const checkTag = () => {
|
|
244
|
+
let object = api(`${prefix}/git/ref/tags/${encodeURIComponent(candidate.tag)}`).object
|
|
245
|
+
for (let depth = 0; object?.type === 'tag' && depth < 8; depth++) {
|
|
246
|
+
ensure(/^[a-f0-9]{40}$/.test(object.sha ?? ''), 'Invalid annotated tag')
|
|
247
|
+
object = api(`${prefix}/git/tags/${object.sha}`).object
|
|
248
|
+
}
|
|
249
|
+
ensure(
|
|
250
|
+
object?.type === 'commit' && object.sha === candidate.sourceSha,
|
|
251
|
+
'Release tag does not identify the qualified commit'
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
checkTag()
|
|
255
|
+
const endpoint = `${prefix}/releases/tags/${encodeURIComponent(candidate.tag)}`
|
|
256
|
+
const release = api(endpoint)
|
|
257
|
+
ensure(
|
|
258
|
+
Number.isSafeInteger(release.id) &&
|
|
259
|
+
release.draft === true &&
|
|
260
|
+
release.tag_name === candidate.tag &&
|
|
261
|
+
Array.isArray(release.assets),
|
|
262
|
+
'An existing draft release with the exact tag is required'
|
|
263
|
+
)
|
|
264
|
+
const temporary = mkdtempSync(join(tmpdir(), 'foundry-release-receipt-'))
|
|
265
|
+
try {
|
|
266
|
+
const receipt = join(temporary, 'qualification.json')
|
|
267
|
+
writeFileSync(receipt, JSON.stringify(qualification, null, 2) + '\n')
|
|
268
|
+
const files = [join(realpathSync(candidate.directory), candidate.asset), receipt]
|
|
269
|
+
const expected = [qualification.digest, `sha256:${sha256(receipt)}`]
|
|
270
|
+
const names = [candidate.asset, 'qualification.json']
|
|
271
|
+
for (let index = 0; index < files.length; index++) {
|
|
272
|
+
const existing = release.assets.filter(
|
|
273
|
+
(/** @type {Record<string, any>} */ asset) => asset.name === names[index]
|
|
274
|
+
)
|
|
275
|
+
ensure(existing.length <= 1, 'Ambiguous existing release asset')
|
|
276
|
+
if (existing.length)
|
|
277
|
+
ensure(
|
|
278
|
+
existing[0].state === 'uploaded' && existing[0].digest === expected[index],
|
|
279
|
+
'Existing draft asset differs; refusing to overwrite it'
|
|
280
|
+
)
|
|
281
|
+
else
|
|
282
|
+
run('gh', [
|
|
283
|
+
'release',
|
|
284
|
+
'upload',
|
|
285
|
+
candidate.tag,
|
|
286
|
+
files[index],
|
|
287
|
+
'--repo',
|
|
288
|
+
candidate.repository,
|
|
289
|
+
])
|
|
290
|
+
}
|
|
291
|
+
const uploaded = api(endpoint)
|
|
292
|
+
ensure(
|
|
293
|
+
uploaded.id === release.id && uploaded.draft === true && uploaded.tag_name === candidate.tag,
|
|
294
|
+
'Draft changed during staging'
|
|
295
|
+
)
|
|
296
|
+
for (let index = 0; index < names.length; index++) {
|
|
297
|
+
const assets =
|
|
298
|
+
uploaded.assets?.filter(
|
|
299
|
+
(/** @type {Record<string, any>} */ asset) => asset.name === names[index]
|
|
300
|
+
) ?? []
|
|
301
|
+
ensure(
|
|
302
|
+
assets.length === 1 &&
|
|
303
|
+
assets[0].state === 'uploaded' &&
|
|
304
|
+
assets[0].digest === expected[index],
|
|
305
|
+
'Uploaded release asset digest mismatch'
|
|
306
|
+
)
|
|
307
|
+
ensure(
|
|
308
|
+
`sha256:${sha256(files[index])}` === expected[index],
|
|
309
|
+
'Local asset changed during staging'
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
checkTag()
|
|
313
|
+
run('gh', [
|
|
314
|
+
'release',
|
|
315
|
+
'edit',
|
|
316
|
+
candidate.tag,
|
|
317
|
+
'--draft=false',
|
|
318
|
+
'--verify-tag',
|
|
319
|
+
'--repo',
|
|
320
|
+
candidate.repository,
|
|
321
|
+
])
|
|
322
|
+
const identity = await (adapters.verify ?? verifier)(candidate)
|
|
323
|
+
ensure(
|
|
324
|
+
identity.status === 'passed' &&
|
|
325
|
+
identity.immutable === true &&
|
|
326
|
+
identity.sourceSha === qualification.sourceSha &&
|
|
327
|
+
identity.assets?.length === 1 &&
|
|
328
|
+
identity.assets[0].digest === qualification.digest,
|
|
329
|
+
'Published release verification failed; npm remains blocked'
|
|
330
|
+
)
|
|
331
|
+
return { ...qualification, status: 'release-published-and-verified' }
|
|
332
|
+
} finally {
|
|
333
|
+
rmSync(temporary, { recursive: true, force: true })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
338
|
+
try {
|
|
339
|
+
const [command, repository, tag, sourceSha, asset, directory, ...reports] =
|
|
340
|
+
process.argv.slice(2)
|
|
341
|
+
if (!directory || !['download', 'qualify', 'stage', 'publish'].includes(command))
|
|
342
|
+
throw new Error(
|
|
343
|
+
'Usage: qualified-publication.mjs download|qualify|stage|publish REPO TAG SOURCE_SHA ASSET DIRECTORY [REPORT.json ...]'
|
|
344
|
+
)
|
|
345
|
+
const candidate = { repository, tag, sourceSha, asset, directory: resolve(directory) }
|
|
346
|
+
if (command === 'download' && reports.length)
|
|
347
|
+
throw new Error('download does not take report paths')
|
|
348
|
+
const result =
|
|
349
|
+
command === 'download'
|
|
350
|
+
? await downloadVerifiedArchive(candidate)
|
|
351
|
+
: command === 'qualify'
|
|
352
|
+
? qualifyArchive(candidate, reports)
|
|
353
|
+
: command === 'stage'
|
|
354
|
+
? await stageQualifiedRelease(candidate, reports)
|
|
355
|
+
: await publishQualifiedArchive(candidate, reports)
|
|
356
|
+
console.log(JSON.stringify(result, null, 2))
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
359
|
+
process.exitCode = 1
|
|
360
|
+
}
|
|
361
|
+
}
|