code-foundry 1.13.0 → 1.15.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,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,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 }}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.15.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.14.0...v1.15.0) (2026-09-09)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
10
+ ## [1.14.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.13.0...v1.14.0) (2026-09-09)
11
+
12
+
13
+ ### Features
14
+
15
+ * **cli:** add shared validation plans and agent check evidence ([#538](https://github.com/0xPlayerOne/code-foundry/issues/538)) ([95af9ed](https://github.com/0xPlayerOne/code-foundry/commit/95af9ed42682ff3ed1dc74609ea4628d0c95a799))
16
+
3
17
  ## [1.13.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.12.0...v1.13.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) and
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.
@@ -16,8 +16,8 @@ belong to the run that produced them, not the source tree.
16
16
  | Runtime dependencies | 0 | Keep the installed CLI dependency-free |
17
17
  | Development dependencies | 4 | Prevent unreviewed toolchain growth |
18
18
  | Packed artifact | 210 kB | Bound registry transfer and install cost |
19
- | Unpacked artifact | 800 kB | Bound installed footprint |
20
- | Packed files | 90 | Detect accidental release contents |
19
+ | Unpacked artifact | 820 kB | Bound installed footprint |
20
+ | Packed files | 92 | 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
package/docs/README.md CHANGED
@@ -10,10 +10,12 @@ 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)
16
17
  - [Required capabilities and task evidence](required-capabilities.md)
18
+ - [Agent-facing validation commands](agent-validation.md)
17
19
  - [Declarative fleet inventory and staged rollouts](fleet-rollouts.md)
18
20
 
19
21
  ## Repository-specific documentation
@@ -0,0 +1,94 @@
1
+ # Agent-facing validation commands
2
+
3
+ The public CLI now exposes a stable plan/check interface using the same discovery,
4
+ required-capability policy, ecosystem executor, and task receipts as reusable CI.
5
+
6
+ ```sh
7
+ code-foundry plan --changed --base origin/main --json
8
+ code-foundry check --tier fast --json
9
+ code-foundry check --tier audit --json
10
+ ```
11
+
12
+ Both commands accept `--target PATH`. Use the Code Foundry version pinned by the
13
+ repository, not an unreviewed floating installation. `plan` executes discovery
14
+ only: it does not install dependencies, run project checks, modify source files,
15
+ or emit skip-receipt files. Required entrypoints are validated across the complete
16
+ task set, including tasks deferred from the selected local tier.
17
+
18
+ ## Tiers and change awareness
19
+
20
+ `fast` selects formatting, linting, type checking, build, unit tests, and performance.
21
+ `audit` adds integration, E2E, and smoke tests. Ecosystem commands and applicability
22
+ come from the shared runtime; the CLI does not invent alternate test commands.
23
+ Repository-owned scripts remain authoritative. Missing required tasks fail
24
+ planning instead of returning a misleading successful subset.
25
+
26
+ `--changed` includes paths changed from the selected base, staged changes,
27
+ unstaged changes, and untracked files. Committed paths use the merge-base with
28
+ that ref, so an independently advanced base branch does not make base-only
29
+ changes look like head changes. `--base` defaults to HEAD and requires `--changed`.
30
+ Invalid or unavailable base revisions fail rather than being treated as no changes.
31
+ Paths use Git's NUL-separated format, preserving whitespace and unusual filenames.
32
+ Generated `.code-foundry/` evidence is excluded from change annotations. Change awareness is **annotation-only**: without a verified dependency
33
+ graph it does not skip required tasks or assume a documentation change cannot
34
+ affect a custom command.
35
+
36
+ Local tiers are not a replacement for the complete GitHub Validation / Gate.
37
+ Security scans, CodeQL, release-diff policy where applicable, protected-environment
38
+ checks, and required reviews remain separate. Every plan/result explicitly marks
39
+ remote validation as required. A fast result also names deferred local tasks.
40
+ A local audit result is not permission to mark a PR ready or merge it.
41
+
42
+ ## Execution and evidence
43
+
44
+ Install the repository's locked dependencies through its normal setup process
45
+ before running checks. This CLI does not add an implicit install step or grant
46
+ credentials. It runs trusted repository scripts in the existing environment;
47
+ those scripts are not sandboxed and retain their normal tool/network behavior.
48
+ The formatter/runtime's existing semantics are unchanged: a configured command
49
+ that rewrites files still rewrites files. Prefer check-only scripts in CI.
50
+
51
+ Task stdout/stderr goes to stderr, keeping `--json` stdout parseable. Each selected
52
+ applicable task runs through the public runtime. A zero exit without a fresh,
53
+ matching, successful task receipt fails. A failed task blocks remaining applicable
54
+ tasks; optional inapplicable tasks are reported as skipped. An entirely skipped
55
+ check is labeled `skipped`, never `passed`.
56
+
57
+ A run stores `.code-foundry/agent-results/check-*/summary.json` and snapshots of
58
+ its task receipts. The result includes the source commit, dirty-tree indication,
59
+ selected/deferred tasks, reasons, exit codes, signals, receipt paths, and repository
60
+ artifact paths such as coverage or browser evidence. HEAD identifies the base
61
+ commit; a dirty working tree is not a cryptographically identified immutable
62
+ source snapshot. Reports are local evidence, not signed provenance.
63
+
64
+ The aggregate and task receipts are retained per run so later checks do not
65
+ overwrite the earlier JSON evidence. Artifact paths inside receipts point to
66
+ repository-owned files and may be overwritten by subsequent test runs; copy them
67
+ into a CI artifact for long-term retention. Do not upload the whole hidden
68
+ repository tree or secrets. The CLI neither captures environment variables into
69
+ reports nor grants publish/merge/deploy access.
70
+
71
+ An exclusive local `agent-results/active.lock` prevents overlapping agent checks
72
+ from confusing receipts. After a killed process, inspect the checkout and confirm
73
+ there is no active check before manually removing a stale lock. Normal completion
74
+ and failures clean the lock. This is a local CLI lock, not a distributed lock
75
+ against every other tool writing into the repository.
76
+
77
+ `--timeout SECONDS` bounds each check subprocess (default 600, range 1–3600).
78
+ A timeout or missing receipt fails. Project scripts remain responsible for
79
+ cleaning up their own servers and subprocesses. Plans do not accept an execution
80
+ timeout. Unknown, duplicate, or ignored arguments fail rather than silently alter
81
+ validation behavior.
82
+
83
+ ## Integration and tests
84
+
85
+ This change depends on the required-capabilities/task-evidence runtime change.
86
+ It deliberately imports shared policy instead of maintaining a competing copy.
87
+ Merge that prerequisite before this CLI change and retain `runtime-core.mjs` when
88
+ vendoring the runtime.
89
+
90
+ Run `node --test test/agent-check.test.mjs`. Tests exercise real Git change
91
+ inspection and the actual policy/evidence wrapper with a deterministic ecosystem
92
+ executor, including read-only planning, required deferred tasks, failed commands,
93
+ stale evidence, receipt snapshots, locking, and separation of JSON from task logs.
94
+ Full ecosystem dependencies and GitHub-side checks still need their own CI run.
@@ -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.13.0",
3
+ "version": "1.15.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
@@ -16,9 +16,14 @@ Usage:
16
16
  npx code-foundry init [--target PATH]
17
17
  npx code-foundry sync [--target PATH]
18
18
  npx code-foundry doctor [--target PATH]
19
+ npx code-foundry plan [--target PATH] [--tier fast|audit] [--changed] [--base REF] [--json]
20
+ npx code-foundry check [--target PATH] [--tier fast|audit] [--changed] [--base REF] [--timeout SECONDS] [--json]
19
21
  npx code-foundry ci pause|resume|status [--target PATH]
20
22
  npx code-foundry release reconcile [--github] [--base BRANCH] [--head BRANCH]
21
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]
22
27
  npx code-foundry fleet status [--root PATH]
23
28
  npx code-foundry fleet upgrade [--root PATH] [--dry-run] [--create-pr]
24
29
 
@@ -36,6 +41,9 @@ Options:
36
41
  --tag TAG Published release tag for a post-release hook
37
42
  --workflow FILE Workflow to dispatch for a post-release hook
38
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)
39
47
  --root PATH Fleet root containing repositories (default: current directory)
40
48
  --create-pr Create isolated upgrade branches and pull requests
41
49
  --version TAG Runtime tag to report in fleet upgrade branches
@@ -69,6 +77,10 @@ function parseArgs(argv) {
69
77
  workflow: '',
70
78
  mode: 'auto',
71
79
  version: `v${readPackageVersion(packageRoot)}`,
80
+ repository: '',
81
+ expectedSha: '',
82
+ assets: [],
83
+ rootProvided: false,
72
84
  }
73
85
 
74
86
  if (command === 'release') {
@@ -78,6 +90,13 @@ function parseArgs(argv) {
78
90
  `unknown release command: ${subcommand ?? '(missing)'}; use release reconcile, release hook, release validate-prs, or release recovery-plan`
79
91
  )
80
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)
81
100
  } else if (command === 'fleet') {
82
101
  const subcommand = argv.shift() ?? ''
83
102
  if (subcommand !== 'status' && subcommand !== 'upgrade')
@@ -108,12 +127,26 @@ function parseArgs(argv) {
108
127
  else if (arg === '--base') options.base = argv.shift() ?? fail('--base requires a branch')
109
128
  else if (arg === '--head') options.head = argv.shift() ?? fail('--head requires a branch')
110
129
  else if (arg === '--tag') options.tag = argv.shift() ?? fail('--tag requires a tag')
111
- else if (arg === '--workflow')
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')
112
143
  options.workflow = argv.shift() ?? fail('--workflow requires a workflow')
113
144
  else if (arg === '--mode')
114
145
  options.mode = argv.shift() ?? fail('--mode requires a delivery mode')
115
- else if (arg === '--root') options.root = argv.shift() ?? fail('--root requires a path')
116
- else if (arg === '--create-pr') options.createPr = true
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
117
150
  else if (arg === '--version') options.version = argv.shift() ?? fail('--version requires a tag')
118
151
  else if (arg === '--exclude')
119
152
  options.exclude.push(argv.shift() ?? fail('--exclude requires a name'))
@@ -124,6 +157,11 @@ function parseArgs(argv) {
124
157
  }
125
158
 
126
159
  async function main() {
160
+ if (['plan', 'check'].includes(process.argv[2])) {
161
+ const { agentCommand } = await import('./commands/agent-check.mjs')
162
+ process.exitCode = agentCommand(process.argv.slice(2))
163
+ return
164
+ }
127
165
  const { command, options } = parseArgs(process.argv.slice(2))
128
166
  const target = resolve(options.target)
129
167
 
@@ -170,6 +208,32 @@ async function main() {
170
208
  } catch (error) {
171
209
  fail(error instanceof Error ? error.message : String(error))
172
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
+ }
173
237
  } else if (command === 'fleet') {
174
238
  try {
175
239
  const { discoverRepositories, upgradeFleet } = await import('./commands/fleet.mjs')
@@ -0,0 +1,309 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from 'node:fs'
12
+ import { join, resolve } from 'node:path'
13
+ import { spawnSync } from 'node:child_process'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { taskProfile } from '../runtime.mjs'
16
+ import { evidencePath, fingerprint, TASKS } from '../lib/task-policy.mjs'
17
+
18
+ const runtime = fileURLToPath(new URL('../runtime.mjs', import.meta.url))
19
+ const fastTasks = new Set(['format', 'lint', 'type_check', 'build', 'unit', 'performance'])
20
+ export const agentUsage = `code-foundry plan [--target PATH] [--tier fast|audit] [--changed] [--base REF] [--json]
21
+ code-foundry check [--target PATH] [--tier fast|audit] [--changed] [--base REF] [--timeout SECONDS] [--json]
22
+
23
+ plan discovers tasks without executing them. check invokes the shared CI runtime.
24
+ --changed annotates affected paths; it never prunes required or custom checks.
25
+ fast includes CI tasks, unit tests, and performance; audit adds integration/E2E/smoke.
26
+ Neither command substitutes for GitHub Security, CodeQL, release policy, or review.
27
+ `
28
+
29
+ /** @typedef {{command: 'plan'|'check', target: string, tier: 'fast'|'audit', changed: boolean, base: string, json: boolean, timeout: number}} AgentOptions */
30
+ /** @typedef {{runtime?: string, core?: string}} RuntimePaths */
31
+
32
+ /** @param {string[]} argv @returns {AgentOptions} */
33
+ export function parseAgentArgs(argv) {
34
+ const [command, ...args] = argv
35
+ if (command !== 'plan' && command !== 'check') throw new Error('Expected plan or check')
36
+ /** @type {AgentOptions} */
37
+ const options = {
38
+ command,
39
+ target: process.cwd(),
40
+ tier: 'fast',
41
+ changed: false,
42
+ base: 'HEAD',
43
+ json: false,
44
+ timeout: 600000,
45
+ }
46
+ const seen = new Set()
47
+ while (args.length) {
48
+ const key = args.shift()
49
+ if (!key || seen.has(key)) throw new Error(`Duplicate or missing option: ${key}`)
50
+ seen.add(key)
51
+ if (key === '--json') options.json = true
52
+ else if (key === '--changed') options.changed = true
53
+ else {
54
+ if (!['--target', '--tier', '--base', '--timeout'].includes(key))
55
+ throw new Error(`Unknown option: ${key}`)
56
+ const value = args.shift()
57
+ if (!value || value.startsWith('-')) throw new Error(`Missing value for ${key}`)
58
+ if (key === '--target') options.target = resolve(value)
59
+ if (key === '--base') options.base = value
60
+ if (key === '--tier') {
61
+ if (!['fast', 'audit'].includes(value)) throw new Error('tier must be fast or audit')
62
+ options.tier = /** @type {'fast'|'audit'} */ (value)
63
+ }
64
+ if (key === '--timeout') {
65
+ const seconds = Number(value)
66
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 3600)
67
+ throw new Error('timeout must be 1–3600 whole seconds')
68
+ options.timeout = seconds * 1000
69
+ }
70
+ }
71
+ }
72
+ if (seen.has('--base') && !options.changed) throw new Error('--base requires --changed')
73
+ if (command === 'plan' && seen.has('--timeout'))
74
+ throw new Error('--timeout applies only to check execution')
75
+ return options
76
+ }
77
+
78
+ /** @param {string} root @param {string[]} args */
79
+ function git(root, args) {
80
+ const result = spawnSync('git', args, {
81
+ cwd: root,
82
+ encoding: 'utf8',
83
+ timeout: 10000,
84
+ maxBuffer: 8 * 1024 * 1024,
85
+ })
86
+ if (result.error || result.status !== 0)
87
+ throw new Error(`Unable to inspect repository: git ${args[0]}`)
88
+ return result.stdout
89
+ }
90
+
91
+ /** @param {string} value */
92
+ function hasInvalidBaseRefCharacter(value) {
93
+ return [...value].some((character) => {
94
+ const code = character.codePointAt(0) ?? 0
95
+ return code <= 0x20 || code === 0x7f
96
+ })
97
+ }
98
+
99
+ /** @param {AgentOptions} options */
100
+ export function sourceContext(options) {
101
+ const sourceSha = git(options.target, ['rev-parse', '--verify', 'HEAD']).trim()
102
+ if (!/^[0-9a-f]{40}$/i.test(sourceSha))
103
+ throw new Error('Repository HEAD must resolve to a commit')
104
+ const dirty = git(options.target, ['status', '--porcelain=v1', '-z'])
105
+ .split('\0')
106
+ .filter(Boolean)
107
+ .some((line) => !line.slice(3).startsWith('.code-foundry/'))
108
+ let baseSha = null
109
+ /** @type {string[]} */
110
+ let changedFiles = []
111
+ if (options.changed) {
112
+ if (options.base.startsWith('-') || hasInvalidBaseRefCharacter(options.base))
113
+ throw new Error('Invalid base ref')
114
+ baseSha = git(options.target, ['rev-parse', '--verify', `${options.base}^{commit}`]).trim()
115
+ const lists = [
116
+ git(options.target, ['diff', '--name-only', '-z', `${baseSha}...HEAD`, '--']),
117
+ git(options.target, ['diff', '--cached', '--name-only', '-z', '--']),
118
+ git(options.target, ['diff', '--name-only', '-z', '--']),
119
+ git(options.target, ['ls-files', '--others', '--exclude-standard', '-z']),
120
+ ]
121
+ changedFiles = [
122
+ ...new Set(
123
+ lists
124
+ .flatMap((value) => value.split('\0'))
125
+ .filter((file) => file && !file.startsWith('.code-foundry/'))
126
+ ),
127
+ ]
128
+ // This freshly created array is local to the plan.
129
+ // oxlint-disable-next-line unicorn/no-array-sort
130
+ changedFiles.sort()
131
+ }
132
+ return { sourceSha, dirty, baseSha, changedFiles }
133
+ }
134
+
135
+ /** @param {AgentOptions} options @param {RuntimePaths} [paths] */
136
+ export function planChecks(options, paths = {}) {
137
+ const source = sourceContext(options)
138
+ // Direct discovery shares CI policy, but unlike ci task_profile it writes no skip receipts.
139
+ const tasks = TASKS.map((task) => ({
140
+ ...taskProfile(options.target, task, paths.core),
141
+ selected: options.tier === 'audit' || fastTasks.has(task),
142
+ argv: [process.execPath, paths.runtime ?? runtime, 'ci', task],
143
+ }))
144
+ return {
145
+ schemaVersion: 1,
146
+ kind: 'code-foundry-validation-plan',
147
+ status: 'planned',
148
+ tier: options.tier,
149
+ ...source,
150
+ tasks,
151
+ changePolicy: 'annotation-only; no tasks pruned without a proven dependency graph',
152
+ remoteValidationRequired: true,
153
+ deferredRemoteChecks: [
154
+ 'Security',
155
+ 'CodeQL',
156
+ 'release policy when applicable',
157
+ 'required reviews',
158
+ ],
159
+ }
160
+ }
161
+
162
+ /** @param {string} root */
163
+ function outputDirectory(root) {
164
+ let directory = resolve(root)
165
+ for (const component of ['.code-foundry', 'agent-results']) {
166
+ directory = join(directory, component)
167
+ if (!existsSync(directory)) mkdirSync(directory)
168
+ if (lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory())
169
+ throw new Error('Agent evidence directory must be a real directory inside the repository')
170
+ }
171
+ return directory
172
+ }
173
+
174
+ /** @param {AgentOptions} options @param {RuntimePaths} [paths] */
175
+ export function checkRepository(options, paths = {}) {
176
+ const plan = planChecks(options, paths)
177
+ const directory = outputDirectory(options.target)
178
+ const lock = join(directory, 'active.lock')
179
+ try {
180
+ mkdirSync(lock)
181
+ } catch {
182
+ throw new Error(
183
+ 'Another agent check may be running; inspect agent-results/active.lock before recovery'
184
+ )
185
+ }
186
+ let runDirectory
187
+ try {
188
+ runDirectory = mkdtempSync(join(directory, 'check-'))
189
+ } catch (error) {
190
+ try {
191
+ rmSync(lock, { recursive: true, force: true })
192
+ } catch {
193
+ // Preserve the original temporary-directory error.
194
+ }
195
+ throw error
196
+ }
197
+ const reportFile = join(runDirectory, 'summary.json')
198
+ /** @type {Record<string, any>} */
199
+ const report = {
200
+ ...plan,
201
+ kind: 'code-foundry-validation-result',
202
+ status: 'failed',
203
+ startedAt: new Date().toISOString(),
204
+ completedAt: null,
205
+ tasks: [],
206
+ report: reportFile,
207
+ deferredTasks: plan.tasks.filter((task) => !task.selected).map((task) => task.task),
208
+ }
209
+ let failed = false
210
+ try {
211
+ for (const item of plan.tasks.filter((task) => task.selected)) {
212
+ if (!item.applicable || failed) {
213
+ report.tasks.push({
214
+ ...item,
215
+ status: failed && item.applicable ? 'blocked' : 'skipped',
216
+ reason: failed && item.applicable ? 'an earlier task failed' : item.reason,
217
+ })
218
+ continue
219
+ }
220
+ const receiptFile = evidencePath(options.target, `.code-foundry/results/${item.task}.json`)
221
+ const before = fingerprint(receiptFile)
222
+ const result = spawnSync(item.argv[0], item.argv.slice(1), {
223
+ cwd: options.target,
224
+ timeout: options.timeout,
225
+ // Keep stdout exclusively available for the public JSON contract.
226
+ stdio: ['ignore', 2, 2],
227
+ env: { ...process.env, GITHUB_OUTPUT: '', GITHUB_STEP_SUMMARY: '' },
228
+ })
229
+ let receipt = null
230
+ let reason = result.error
231
+ ? `Runtime execution failed: ${/** @type {NodeJS.ErrnoException} */ (result.error).code ?? result.error.name}`
232
+ : ''
233
+ if (fingerprint(receiptFile) !== before && existsSync(receiptFile)) {
234
+ try {
235
+ receipt = JSON.parse(readFileSync(receiptFile, 'utf8'))
236
+ if (
237
+ receipt.kind !== 'code-foundry-task-result' ||
238
+ receipt.schemaVersion !== 1 ||
239
+ receipt.task !== item.task ||
240
+ receipt.sourceSha !== plan.sourceSha
241
+ )
242
+ throw new Error('Receipt identity does not match the planned task/source')
243
+ } catch (error) {
244
+ reason = error instanceof Error ? error.message : String(error)
245
+ receipt = null
246
+ }
247
+ }
248
+ const passed = result.status === 0 && receipt?.status === 'passed' && !reason
249
+ const snapshot = receipt ? join(runDirectory, `${item.task}.json`) : null
250
+ if (snapshot) writeFileSync(snapshot, `${JSON.stringify(receipt, null, 2)}\n`)
251
+ failed = !passed
252
+ report.tasks.push({
253
+ ...item,
254
+ status: passed ? 'passed' : 'failed',
255
+ exitCode: result.status,
256
+ signal: result.signal,
257
+ reason: reason || receipt?.reason || 'missing fresh successful task evidence',
258
+ receipt: snapshot,
259
+ artifacts: receipt?.artifacts ?? [],
260
+ })
261
+ }
262
+ report.status = failed
263
+ ? 'failed'
264
+ : report.tasks.some((/** @type {any} */ task) => task.status === 'passed')
265
+ ? 'passed'
266
+ : 'skipped'
267
+ return report
268
+ } finally {
269
+ report.completedAt = new Date().toISOString()
270
+ try {
271
+ writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`)
272
+ } finally {
273
+ rmSync(lock, { recursive: true })
274
+ }
275
+ }
276
+ }
277
+
278
+ /** @param {string[]} argv */
279
+ export function agentCommand(argv) {
280
+ const jsonOutput = argv.includes('--json')
281
+ if (argv.includes('--help') || argv.includes('-h')) {
282
+ console.log(agentUsage)
283
+ return 0
284
+ }
285
+ try {
286
+ const options = parseAgentArgs(argv)
287
+ const result = options.command === 'plan' ? planChecks(options) : checkRepository(options)
288
+ if (options.json) console.log(JSON.stringify(result, null, 2))
289
+ else {
290
+ console.log(`${options.command}: ${result.status} (${options.tier}, ${result.sourceSha})`)
291
+ for (const item of result.tasks)
292
+ console.log(
293
+ `${item.task}: ${item.status ?? (item.selected ? (item.applicable ? 'planned' : 'skipped') : 'deferred')} — ${item.reason}`
294
+ )
295
+ console.log('GitHub security checks and review requirements remain separate.')
296
+ }
297
+ return result.status === 'failed' ? 1 : 0
298
+ } catch (error) {
299
+ const result = {
300
+ schemaVersion: 1,
301
+ kind: 'code-foundry-validation-error',
302
+ status: 'failed',
303
+ reason: error instanceof Error ? error.message : String(error),
304
+ }
305
+ if (jsonOutput) console.log(JSON.stringify(result, null, 2))
306
+ else console.error(result.reason)
307
+ return 1
308
+ }
309
+ }
@@ -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
+ }
@@ -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
 
@@ -492,6 +493,7 @@ function shouldInclude(file, languages, features, config) {
492
493
  // without a configuration change. The detect job keeps the scan off unless
493
494
  // the configuration or the variable enables it and the API key exists.
494
495
  if (file === '.github/workflows/opencode-security.yml') return true
496
+ if (file === '.github/workflows/release-integrity.yml') return true
495
497
  const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
496
498
  if (workflow === 'draft-control' || workflow === 'draft-enforcement') {
497
499
  return (