code-foundry 0.28.0 → 0.30.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,48 @@
1
+ name: Code Foundry
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [staging]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ detect:
13
+ name: OpenCode Security / Detect
14
+ if: >-
15
+ github.event_name == 'workflow_dispatch' ||
16
+ startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')
17
+ runs-on: ubuntu-slim
18
+ outputs:
19
+ enabled: ${{ steps.detect.outputs.enabled }}
20
+ token: ${{ steps.detect.outputs.token }}
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v7
24
+ - name: Detect configuration and token
25
+ id: detect
26
+ env:
27
+ OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
28
+ run: |
29
+ enabled=false
30
+ token=false
31
+ if grep -Eq '^opencode_security:\s*(true|auto)\s*$' .github/code-foundry.yml 2>/dev/null; then enabled=true; fi
32
+ if [ -n "$OPENCODE_API_KEY" ]; then token=true; fi
33
+ echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
34
+ echo "token=$token" >> "$GITHUB_OUTPUT"
35
+
36
+ scan:
37
+ name: OpenCode Security / Scan
38
+ needs: detect
39
+ if: needs.detect.outputs.enabled == 'true' && needs.detect.outputs.token == 'true'
40
+ uses: 0xPlayerOne/opencode-security/.github/workflows/opencode-security.yml@main
41
+ with:
42
+ source_ref: main
43
+ runner: ubuntu-latest
44
+ mode: standard
45
+ fail_on_severity: high
46
+ max_cost_usd: 1
47
+ secrets:
48
+ OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
@@ -0,0 +1,48 @@
1
+ name: Code Foundry
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [staging]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ detect:
13
+ name: OpenCode Security / Detect
14
+ if: >-
15
+ github.event_name == 'workflow_dispatch' ||
16
+ startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')
17
+ runs-on: ubuntu-slim
18
+ outputs:
19
+ enabled: ${{ steps.detect.outputs.enabled }}
20
+ token: ${{ steps.detect.outputs.token }}
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v7
24
+ - name: Detect configuration and token
25
+ id: detect
26
+ env:
27
+ OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
28
+ run: |
29
+ enabled=false
30
+ token=false
31
+ if grep -Eq '^opencode_security:\s*(true|auto)\s*$' .github/code-foundry.yml 2>/dev/null; then enabled=true; fi
32
+ if [ -n "$OPENCODE_API_KEY" ]; then token=true; fi
33
+ echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
34
+ echo "token=$token" >> "$GITHUB_OUTPUT"
35
+
36
+ scan:
37
+ name: OpenCode Security / Scan
38
+ needs: detect
39
+ if: needs.detect.outputs.enabled == 'true' && needs.detect.outputs.token == 'true'
40
+ uses: 0xPlayerOne/opencode-security/.github/workflows/opencode-security.yml@main
41
+ with:
42
+ source_ref: main
43
+ runner: ubuntu-latest
44
+ mode: standard
45
+ fail_on_severity: high
46
+ max_cost_usd: 1
47
+ secrets:
48
+ OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
@@ -20,6 +20,7 @@ on:
20
20
  default: v0.27.18
21
21
 
22
22
  permissions:
23
+ actions: write
23
24
  contents: write
24
25
  issues: write
25
26
  pull-requests: write
@@ -49,6 +50,13 @@ jobs:
49
50
  uses: actions/checkout@v7
50
51
  with:
51
52
  filter: blob:none
53
+ - name: Checkout runtime
54
+ uses: actions/checkout@v7
55
+ with:
56
+ repository: ${{ inputs.runtime-repository }}
57
+ ref: ${{ inputs.runtime-ref }}
58
+ path: .code-foundry
59
+ sparse-checkout: src
52
60
  - name: Detect release profile
53
61
  id: profile
54
62
  run: |
@@ -109,43 +117,7 @@ jobs:
109
117
  GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
110
118
  run: |
111
119
  set -euo pipefail
112
-
113
- allowed_paths="$(
114
- node <<'NODE'
115
- const fs = require('node:fs')
116
- const config = JSON.parse(fs.readFileSync('release-please-config.json', 'utf8'))
117
- const common = [
118
- '.release-please-manifest.json',
119
- 'CHANGELOG.md',
120
- 'Cargo.lock',
121
- 'Cargo.toml',
122
- 'bun.lock',
123
- 'bun.lockb',
124
- 'package-lock.json',
125
- 'package.json',
126
- 'pnpm-lock.yaml',
127
- 'pyproject.toml',
128
- 'uv.lock',
129
- 'version.txt',
130
- 'yarn.lock',
131
- ]
132
- const allowed = new Set(common)
133
- const addExtraFiles = (files = [], prefix = '') => {
134
- for (const entry of files) {
135
- const path = typeof entry === 'string' ? entry : entry.path
136
- if (path) allowed.add(prefix ? `${prefix}/${path}` : path)
137
- }
138
- }
139
- addExtraFiles(config['extra-files'])
140
- for (const [directory, packageConfig] of Object.entries(config.packages || {})) {
141
- const prefix = directory === '.' ? '' : directory.replace(/\/$/, '')
142
- for (const path of common) allowed.add(prefix ? `${prefix}/${path}` : path)
143
- addExtraFiles(packageConfig['extra-files'], prefix)
144
- }
145
- process.stdout.write(JSON.stringify([...allowed]))
146
- NODE
147
- )"
148
-
120
+ node .code-foundry/src/cli.mjs release validate-prs
149
121
  mapfile -t release_prs < <(
150
122
  gh pr list \
151
123
  --repo "$GITHUB_REPOSITORY" \
@@ -154,23 +126,7 @@ jobs:
154
126
  --json number,title,headRefName \
155
127
  --jq '.[] | select(.title | startswith("chore(main): release ")) | select(.headRefName | startswith("release-please--branches--main")) | .number'
156
128
  )
157
- [ "${#release_prs[@]}" -gt 0 ] || {
158
- echo "Release Please reported a pull request, but no generated release PR was found." >&2
159
- exit 1
160
- }
161
-
162
129
  for pr in "${release_prs[@]}"; do
163
- mapfile -t changed_paths < <(gh pr diff "$pr" --repo "$GITHUB_REPOSITORY" --name-only)
164
- [ "${#changed_paths[@]}" -gt 0 ] || {
165
- echo "Generated release PR #$pr has no changed files." >&2
166
- exit 1
167
- }
168
- for path in "${changed_paths[@]}"; do
169
- jq -e --arg path "$path" 'index($path) != null' <<<"$allowed_paths" >/dev/null || {
170
- echo "Refusing to auto-merge release PR #$pr with unexpected path: $path" >&2
171
- exit 1
172
- }
173
- done
174
130
  gh pr merge "$pr" \
175
131
  --repo "$GITHUB_REPOSITORY" \
176
132
  --admin \
@@ -219,6 +175,57 @@ jobs:
219
175
  if: steps.staging.outputs.exists != 'true'
220
176
  run: echo 'No staging branch exists; release reconciliation is not applicable.'
221
177
 
178
+ post-release:
179
+ name: Release / Post Hook
180
+ needs: release
181
+ if: needs.release.outputs.release_created == 'true'
182
+ runs-on: ${{ inputs.runner }}
183
+ timeout-minutes: 15
184
+ concurrency:
185
+ group: code-foundry-post-release-${{ github.repository }}-${{ needs.release.outputs.tag_name }}
186
+ cancel-in-progress: false
187
+ permissions:
188
+ actions: write
189
+ contents: read
190
+ env:
191
+ RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
192
+ steps:
193
+ - name: Checkout repository
194
+ uses: actions/checkout@v7
195
+ with:
196
+ ref: ${{ needs.release.outputs.tag_name }}
197
+ filter: blob:none
198
+ - name: Checkout runtime
199
+ uses: actions/checkout@v7
200
+ with:
201
+ repository: ${{ inputs.runtime-repository }}
202
+ ref: ${{ inputs.runtime-ref }}
203
+ path: .code-foundry
204
+ sparse-checkout: src
205
+ - name: Detect post-release hook
206
+ id: hook
207
+ run: |
208
+ node <<'NODE' >> "$GITHUB_OUTPUT"
209
+ const fs = require('node:fs')
210
+ const file = '.github/code-foundry.yml'
211
+ const config = fs.existsSync(file)
212
+ ? Object.fromEntries(fs.readFileSync(file, 'utf8').split(/\r?\n/).flatMap((line) => {
213
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
214
+ return match ? [[match[1], match[2].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')]] : []
215
+ }))
216
+ : {}
217
+ const enabled = ['true', 'auto'].includes(config.post_release ?? 'false')
218
+ console.log(`enabled=${enabled}`)
219
+ console.log(`workflow=${config.post_release_workflow ?? ''}`)
220
+ console.log(`mode=${config.post_release_mode ?? 'auto'}`)
221
+ NODE
222
+ - name: Dispatch post-release hook once
223
+ if: steps.hook.outputs.enabled == 'true' && steps.hook.outputs.workflow != ''
224
+ run: node .code-foundry/src/cli.mjs release hook --tag '${{ needs.release.outputs.tag_name }}' --workflow '${{ steps.hook.outputs.workflow }}' --mode '${{ steps.hook.outputs.mode }}'
225
+ - name: Explain disabled post-release hook
226
+ if: steps.hook.outputs.enabled != 'true' || steps.hook.outputs.workflow == ''
227
+ run: echo 'No post-release workflow configured; artifact delivery is intentionally skipped.'
228
+
222
229
  npm:
223
230
  name: Release / Publish npm
224
231
  needs: release
@@ -6,6 +6,7 @@ on:
6
6
  workflow_dispatch:
7
7
 
8
8
  permissions:
9
+ actions: write
9
10
  contents: write
10
11
  issues: write
11
12
  pull-requests: write
@@ -18,5 +19,5 @@ jobs:
18
19
  with:
19
20
  runner: ubuntu-slim
20
21
  runtime-repository: 0xPlayerOne/code-foundry
21
- runtime-ref: v0.27.18
22
+ runtime-ref: main
22
23
  secrets: inherit
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.29.0...v0.30.0) (2026-07-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **ci:** recommend stack-aware runners ([83df327](https://github.com/0xPlayerOne/code-foundry/commit/83df32777344898651d71af28496202337ae6788))
9
+ * **doctor:** validate GitHub release configuration ([877525c](https://github.com/0xPlayerOne/code-foundry/commit/877525c11f6713b235aa3945dd98d017bef5732e))
10
+ * **fleet:** add isolated repository upgrades ([76d1765](https://github.com/0xPlayerOne/code-foundry/commit/76d176593ea025a2090bd62f21b275d261d3a9d5))
11
+ * **release:** add exactly-once post-release hooks ([f4ffa13](https://github.com/0xPlayerOne/code-foundry/commit/f4ffa136ef972665fe269b6685f9c4b840fc95a9))
12
+ * **release:** add non-destructive recovery planning ([6c45592](https://github.com/0xPlayerOne/code-foundry/commit/6c45592358d9ceb0335acc6584ac565971433737))
13
+ * **security:** add opt-in OpenCode release scan ([34512c1](https://github.com/0xPlayerOne/code-foundry/commit/34512c1507519d2b37a9bd665243490fa888beb9))
14
+ * **sync:** formalize custom workflow overlays ([ee540f9](https://github.com/0xPlayerOne/code-foundry/commit/ee540f9a80c0b128100e795471042fc12aefc226))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **release:** grant post-release dispatch permission ([c18a52d](https://github.com/0xPlayerOne/code-foundry/commit/c18a52dff2ac76e648eeb9fa1f92fab8a01dafec))
20
+
21
+ ## [0.29.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.28.0...v0.29.0) (2026-07-29)
22
+
23
+
24
+ ### Features
25
+
26
+ * **release:** detect mixed-language release manifests ([f2daa61](https://github.com/0xPlayerOne/code-foundry/commit/f2daa616f2d8cfd2883f960823e862768454fbd6))
27
+
3
28
  ## [0.28.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.18...v0.28.0) (2026-07-29)
4
29
 
5
30
 
@@ -0,0 +1,19 @@
1
+ # Code Foundry extension points
2
+
3
+ Code Foundry uses an overlay model. The files in its documented baseline are managed by `sync`; repository-owned files outside that baseline remain yours.
4
+
5
+ ## Managed files
6
+
7
+ The standard workflows, hooks, governance documents, language configuration, and release configuration are refreshed from the configured runtime. Keep repository-specific behavior in separate files.
8
+
9
+ ## Custom workflows
10
+
11
+ Any workflow not named by the baseline is preserved automatically. This is the supported place for project-specific workflows such as Slither, search indexing, deployment, Docker publishing, or Vercel tasks.
12
+
13
+ Set `custom_workflows: preserve` in `.github/code-foundry.yml` (the default). Code Foundry intentionally has no prune mode for custom workflows; remove those files explicitly when they are no longer needed.
14
+
15
+ ## Release and deployment hooks
16
+
17
+ Use `post_release`, `post_release_workflow`, and `post_release_mode` for a post-release artifact workflow. The hook receives `release-tag` and `delivery-key` inputs and is dispatched at most once per tag when a release token is available.
18
+
19
+ Keep deployment credentials, environment files, and project-specific secrets in the repository or organization configuration. Code Foundry never copies secret values or overwrites custom workflows in overlay mode.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
package/src/cli.mjs CHANGED
@@ -2,14 +2,16 @@
2
2
  // @ts-check
3
3
 
4
4
  import { resolve } from 'node:path'
5
+ import { readFileSync } from 'node:fs'
5
6
  import { fileURLToPath } from 'node:url'
6
7
  import { doctor } from './commands/doctor.mjs'
7
- import { reconcileRelease } from './commands/release.mjs'
8
+ import { dispatchPostReleaseHook, reconcileRelease, releaseRecoveryPlan, validateReleasePullRequestDiffs } from './commands/release.mjs'
9
+ import { discoverRepositories, upgradeFleet } from './commands/fleet.mjs'
8
10
  import { syncRepository } from './commands/sync.mjs'
9
11
 
10
12
  const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
11
13
 
12
- /** @typedef {{ target: string, dryRun: boolean, force: boolean, github: boolean, base: string, head: string }} Options */
14
+ /** @typedef {{ target: string, root: string, dryRun: boolean, force: boolean, github: boolean, createPr: boolean, base: string, head: string, tag: string, workflow: string, mode: string, version: string, releaseSubcommand?: string, fleetSubcommand?: string }} Options */
13
15
  /** @typedef {{ command: string, options: Options }} ParsedArgs */
14
16
 
15
17
  const usage = `code-foundry — initialize and maintain agent-ready repositories
@@ -19,6 +21,9 @@ Usage:
19
21
  npx code-foundry sync [--target PATH]
20
22
  npx code-foundry doctor [--target PATH]
21
23
  npx code-foundry release reconcile [--github] [--base BRANCH] [--head BRANCH]
24
+ npx code-foundry release hook --tag TAG --workflow WORKFLOW
25
+ npx code-foundry fleet status [--root PATH]
26
+ npx code-foundry fleet upgrade [--root PATH] [--dry-run] [--create-pr]
22
27
 
23
28
  The repository configuration lives in .github/code-foundry.yml.
24
29
  init detects the repository, creates that file, and renders the baseline.
@@ -31,6 +36,12 @@ Options:
31
36
  --github Apply a verified fast-forward through GitHub
32
37
  --base BRANCH Release source branch (default: main)
33
38
  --head BRANCH Branch to reconcile (default: staging)
39
+ --tag TAG Published release tag for a post-release hook
40
+ --workflow FILE Workflow to dispatch for a post-release hook
41
+ --mode MODE auto, workflow-dispatch, release-event, or disabled
42
+ --root PATH Fleet root containing repositories (default: current directory)
43
+ --create-pr Create isolated upgrade branches and pull requests
44
+ --version TAG Runtime tag to report in fleet upgrade branches
34
45
  -h, --help Show this help
35
46
  `
36
47
 
@@ -45,11 +56,17 @@ function parseArgs(argv) {
45
56
  const first = argv[0]
46
57
  const hasCommand = Boolean(first && !first.startsWith('-'))
47
58
  const command = hasCommand ? /** @type {string} */ (argv.shift()) : 'init'
48
- const options = { target: process.cwd(), dryRun: false, force: false, github: false, base: 'main', head: 'staging' }
59
+ /** @type {Options} */
60
+ const options = { target: process.cwd(), root: process.cwd(), dryRun: false, force: false, github: false, createPr: false, base: 'main', head: 'staging', tag: '', workflow: '', mode: 'auto', version: `v${readPackageVersion(packageRoot)}` }
49
61
 
50
62
  if (command === 'release') {
51
- const subcommand = argv.shift()
52
- if (subcommand !== 'reconcile') fail(`unknown release command: ${subcommand ?? '(missing)'}; use release reconcile`)
63
+ const subcommand = argv.shift() ?? ''
64
+ if (!['reconcile', 'hook', 'validate-prs', 'recovery-plan'].includes(subcommand)) fail(`unknown release command: ${subcommand ?? '(missing)'}; use release reconcile, release hook, release validate-prs, or release recovery-plan`)
65
+ options.releaseSubcommand = subcommand
66
+ } else if (command === 'fleet') {
67
+ const subcommand = argv.shift() ?? ''
68
+ if (subcommand !== 'status' && subcommand !== 'upgrade') fail(`unknown fleet command: ${subcommand ?? '(missing)'}; use fleet status or fleet upgrade`)
69
+ options.fleetSubcommand = subcommand
53
70
  }
54
71
 
55
72
  while (argv.length) {
@@ -67,6 +84,12 @@ function parseArgs(argv) {
67
84
  else if (arg === '--github') options.github = true
68
85
  else if (arg === '--base') options.base = argv.shift() ?? fail('--base requires a branch')
69
86
  else if (arg === '--head') options.head = argv.shift() ?? fail('--head requires a branch')
87
+ else if (arg === '--tag') options.tag = argv.shift() ?? fail('--tag requires a tag')
88
+ else if (arg === '--workflow') options.workflow = argv.shift() ?? fail('--workflow requires a workflow')
89
+ else if (arg === '--mode') options.mode = argv.shift() ?? fail('--mode requires a delivery mode')
90
+ else if (arg === '--root') options.root = argv.shift() ?? fail('--root requires a path')
91
+ else if (arg === '--create-pr') options.createPr = true
92
+ else if (arg === '--version') options.version = argv.shift() ?? fail('--version requires a tag')
70
93
  else fail(`unknown option: ${arg}; run --help for the supported options`)
71
94
  }
72
95
 
@@ -91,14 +114,25 @@ function main() {
91
114
  }
92
115
  } else if (command === 'doctor') {
93
116
  try {
94
- doctor(target)
117
+ doctor(target, { github: options.github })
95
118
  } catch (error) {
96
119
  console.error(`code-foundry: ${error instanceof Error ? error.message : String(error)}`)
97
120
  process.exitCode = 1
98
121
  }
99
122
  } else if (command === 'release') {
100
123
  try {
101
- reconcileRelease(target, options)
124
+ if (options.releaseSubcommand === 'hook') dispatchPostReleaseHook(target, options)
125
+ else if (options.releaseSubcommand === 'validate-prs') validateReleasePullRequestDiffs(target)
126
+ else if (options.releaseSubcommand === 'recovery-plan') releaseRecoveryPlan(target)
127
+ else reconcileRelease(target, options)
128
+ } catch (error) {
129
+ fail(error instanceof Error ? error.message : String(error))
130
+ }
131
+ } else if (command === 'fleet') {
132
+ try {
133
+ const root = resolve(options.root)
134
+ if (options.fleetSubcommand === 'status') console.log(JSON.stringify(discoverRepositories(root), null, 2))
135
+ else upgradeFleet(root, packageRoot, { createPr: options.createPr, dryRun: options.dryRun, force: options.force, version: options.version })
102
136
  } catch (error) {
103
137
  fail(error instanceof Error ? error.message : String(error))
104
138
  }
@@ -107,3 +141,8 @@ function main() {
107
141
  }
108
142
 
109
143
  main()
144
+
145
+ /** @param {string} root @returns {string} */
146
+ function readPackageVersion(root) {
147
+ try { return JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version ?? '0.0.0' } catch { return '0.0.0' }
148
+ }
@@ -4,13 +4,15 @@ import { existsSync, readFileSync } from 'node:fs'
4
4
  import { join, resolve } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { includesValue, readConfig } from '../lib/config.mjs'
7
- import { resolveProfile } from '../lib/profile.mjs'
7
+ import { recommendRunners, resolveProfile } from '../lib/profile.mjs'
8
+ import { doctorGithub } from '../lib/github-doctor.mjs'
8
9
 
9
- /** @param {string} root */
10
- export function doctor(root) {
10
+ /** @param {string} root @param {{ github?: boolean }} [options] */
11
+ export function doctor(root, options = {}) {
11
12
  const target = resolve(root)
12
13
  const config = readConfig(join(target, '.github/code-foundry.yml'))
13
14
  const profile = resolveProfile(target)
15
+ const recommendations = recommendRunners(target)
14
16
  let errors = 0
15
17
  /** @param {string} message */
16
18
  const error = (message) => { console.error(`ERROR: ${message}`); errors += 1 }
@@ -27,6 +29,9 @@ export function doctor(root) {
27
29
  }
28
30
  const hooks = git(target, ['config', '--get', 'core.hooksPath'])
29
31
  if (hooks !== '.githooks') warn('Git hooks are not enabled; run `npx code-foundry init`')
32
+ if (['rust', 'python', 'solidity'].some((language) => profile.languages.split(',').includes(language)) && (config.unit_runner ?? recommendations.unit_runner) === 'ubuntu-slim') {
33
+ warn('unit_runner is ubuntu-slim for a native-toolchain repository; ubuntu-latest is recommended.')
34
+ }
30
35
 
31
36
  const packageFile = join(target, 'package.json')
32
37
  if (existsSync(packageFile)) {
@@ -55,6 +60,12 @@ export function doctor(root) {
55
60
  if (includesValue(config.features ?? 'all', workflow) && !existsSync(join(target, `.github/workflows/${workflow}.yml`))) error(`missing enabled workflow: ${workflow}.yml`)
56
61
  }
57
62
  console.log('Remote CI, Test, Security, CodeQL, and release runtimes are loaded by reusable workflow wrappers.')
63
+ if (options.github) {
64
+ const github = doctorGithub(target)
65
+ for (const message of github.warnings) warn(`GitHub: ${message}`)
66
+ for (const message of github.errors) error(`GitHub: ${message}`)
67
+ console.log(`GitHub doctor inspected ${github.details.repository}.`)
68
+ }
58
69
  if (errors) throw new Error(`Repository doctor found ${errors} error(s).`)
59
70
  console.log('Repository doctor passed.')
60
71
  }
@@ -0,0 +1,111 @@
1
+ // @ts-check
2
+
3
+ import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { syncRepository } from './sync.mjs'
8
+
9
+ /** @typedef {{ path: string, repository: string, runtimeRef: string, dirty: boolean, configured: boolean }} FleetRepository */
10
+
11
+ /** @param {string} root @returns {FleetRepository[]} */
12
+ export function discoverRepositories(root) {
13
+ /** @type {FleetRepository[]} */
14
+ const result = []
15
+ const candidates = [root, ...children(root), ...children(join(root, 'NiftyLeague'))]
16
+ for (const candidate of [...new Set(candidates)]) {
17
+ if (!existsSync(join(candidate, '.git'))) continue
18
+ const configured = existsSync(join(candidate, '.github/code-foundry.yml'))
19
+ const config = readConfig(join(candidate, '.github/code-foundry.yml'))
20
+ const remote = git(candidate, ['remote', 'get-url', 'origin'])
21
+ result.push({
22
+ path: candidate,
23
+ repository: normalizeRemote(remote),
24
+ runtimeRef: config.runtime_ref ?? '',
25
+ dirty: Boolean(git(candidate, ['status', '--porcelain'])),
26
+ configured,
27
+ })
28
+ }
29
+ return result.sort((a, b) => a.path.localeCompare(b.path))
30
+ }
31
+
32
+ /** @param {string} root @param {string} source @param {{ createPr?: boolean, dryRun?: boolean, force?: boolean, version: string }} options */
33
+ export function upgradeFleet(root, source, options) {
34
+ const repositories = discoverRepositories(root)
35
+ const report = []
36
+ for (const repository of repositories) {
37
+ if (!repository.configured) {
38
+ report.push({ path: repository.path, status: 'skipped', reason: 'missing .github/code-foundry.yml' })
39
+ continue
40
+ }
41
+ if (repository.dirty && !options.force) {
42
+ report.push({ path: repository.path, status: 'skipped', reason: 'working tree is dirty' })
43
+ continue
44
+ }
45
+ if (!options.createPr || options.dryRun) {
46
+ const result = syncRepository({ target: repository.path, source, dryRun: options.dryRun, force: false })
47
+ report.push({ path: repository.path, status: options.dryRun ? 'preview' : 'synced', changed: result.changed })
48
+ continue
49
+ }
50
+ if (!repository.repository) {
51
+ report.push({ path: repository.path, status: 'skipped', reason: 'origin is not a GitHub remote' })
52
+ continue
53
+ }
54
+ report.push(upgradeRepository(repository, source, options.version))
55
+ }
56
+ console.log(JSON.stringify(report, null, 2))
57
+ return report
58
+ }
59
+
60
+ /** @param {FleetRepository} repository @param {string} source @param {string} version */
61
+ function upgradeRepository(repository, source, version) {
62
+ const branch = `codex/code-foundry-upgrade-${version.replace(/^v/, '')}`
63
+ const temporary = mkdtempSync(join(tmpdir(), 'code-foundry-fleet-'))
64
+ try {
65
+ const add = spawnSync('git', ['-C', repository.path, 'worktree', 'add', '-b', branch, temporary, 'HEAD'], { encoding: 'utf8' })
66
+ if (add.status !== 0) return { path: repository.path, status: 'skipped', reason: add.stderr.trim() || 'unable to create isolated worktree' }
67
+ const result = syncRepository({ target: temporary, source, force: false })
68
+ if (!result.changed.length) return { path: repository.path, status: 'unchanged', branch }
69
+ spawnSync('git', ['-C', temporary, 'add', '-A'], { stdio: 'ignore' })
70
+ const commit = spawnSync('git', ['-C', temporary, 'commit', '-m', `chore(code-foundry): upgrade runtime to ${version}`], { encoding: 'utf8' })
71
+ if (commit.status !== 0) return { path: repository.path, status: 'failed', reason: commit.stderr.trim() || 'commit failed' }
72
+ const push = spawnSync('git', ['-C', temporary, 'push', '-u', 'origin', branch], { encoding: 'utf8' })
73
+ if (push.status !== 0) return { path: repository.path, status: 'failed', reason: push.stderr.trim() || 'push failed' }
74
+ const pr = spawnSync('gh', ['pr', 'create', '--repo', repository.repository, '--base', 'staging', '--head', branch, '--title', `chore(code-foundry): upgrade to ${version}`, '--body', `Automated isolated Code Foundry runtime upgrade to ${version}.\n\nThe sync preserved protected repository-owned documents and custom workflows.`], { encoding: 'utf8' })
75
+ return pr.status === 0
76
+ ? { path: repository.path, status: 'pr-created', branch, pullRequest: pr.stdout.trim() }
77
+ : { path: repository.path, status: 'failed', branch, reason: pr.stderr.trim() || 'pull request creation failed' }
78
+ } finally {
79
+ spawnSync('git', ['-C', repository.path, 'worktree', 'remove', '--force', temporary], { stdio: 'ignore' })
80
+ }
81
+ }
82
+
83
+ /** @param {string} root @returns {string[]} */
84
+ function children(root) {
85
+ try {
86
+ return readdirSync(root, { withFileTypes: true })
87
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && !['node_modules', 'target'].includes(entry.name))
88
+ .map((entry) => join(root, entry.name))
89
+ } catch { return [] }
90
+ }
91
+
92
+ /** @param {string} file @returns {Record<string, string>} */
93
+ function readConfig(file) {
94
+ try {
95
+ return Object.fromEntries(requireText(file).split(/\r?\n/).flatMap((line) => {
96
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
97
+ return match ? [[match[1], match[2].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')]] : []
98
+ }))
99
+ } catch { return {} }
100
+ }
101
+
102
+ /** @param {string} file */
103
+ function requireText(file) {
104
+ return readFileSync(file, 'utf8')
105
+ }
106
+
107
+ /** @param {string} root @param {string[]} args */
108
+ function git(root, args) { return spawnSync('git', args, { cwd: root, encoding: 'utf8' }).stdout?.trim() ?? '' }
109
+
110
+ /** @param {string} remote */
111
+ function normalizeRemote(remote) { return remote.replace(/^git@github\.com:/, '').replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '') }
@@ -1,9 +1,10 @@
1
1
  // @ts-check
2
2
 
3
- import { existsSync } from 'node:fs'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
4
  import { join, resolve } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
- import { approvedReleaseFiles, classifyReconciliation, readReleaseConfig } from '../lib/release-policy.mjs'
6
+ import { approvedReleaseFiles, buildReleaseRecoveryPlan, classifyReconciliation, readReleaseConfig, selectGeneratedReleasePrs, validateReleasePullRequests } from '../lib/release-policy.mjs'
7
+ import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib/release-hook.mjs'
7
8
 
8
9
  /** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
9
10
 
@@ -45,6 +46,75 @@ export function reconcileRelease(root, options) {
45
46
  return plan
46
47
  }
47
48
 
49
+ /**
50
+ * Dispatch a configured post-release workflow at most once for a tag.
51
+ * @param {string} root
52
+ * @param {{ tag: string, workflow: string, mode?: string, dryRun?: boolean }} options
53
+ */
54
+ export function dispatchPostReleaseHook(root, options) {
55
+ const repository = process.env.GITHUB_REPOSITORY
56
+ if (!repository) throw new Error('GITHUB_REPOSITORY is required for post-release hooks.')
57
+ if (!options.tag) throw new Error('--tag is required for post-release hooks.')
58
+ const decision = selectHookDelivery({ mode: options.mode, tokenPresent: Boolean(process.env.RELEASE_PLEASE_TOKEN) })
59
+ console.log(JSON.stringify({ repository, tag: options.tag, workflow: options.workflow, ...decision }, null, 2))
60
+ if (decision.delivery === 'disabled' || decision.delivery === 'release-event') return decision
61
+ if (decision.delivery === 'unavailable') throw new Error(decision.reason)
62
+ if (!options.workflow) throw new Error('--workflow is required for workflow-dispatch hooks.')
63
+ const key = releaseDeliveryKey(repository, options.tag)
64
+ const runs = ghJson(root, ['run', 'list', '--repo', repository, '--workflow', options.workflow, '--limit', '100', '--json', 'headBranch,displayTitle,status'])
65
+ if (hasDeliveredHook(Array.isArray(runs) ? runs : [], options.tag)) {
66
+ console.log(`Post-release hook already delivered for ${options.tag}; skipping duplicate dispatch.`)
67
+ return { ...decision, deliveryKey: key, skipped: true }
68
+ }
69
+ if (options.dryRun) return { ...decision, deliveryKey: key, dispatched: false }
70
+ const result = spawnSync('gh', [
71
+ 'workflow', 'run', options.workflow,
72
+ '--repo', repository,
73
+ '--ref', options.tag,
74
+ '--field', `release-tag=${options.tag}`,
75
+ '--field', `delivery-key=${key}`,
76
+ ], { cwd: resolve(root), stdio: 'inherit', env: { ...process.env, GH_TOKEN: process.env.RELEASE_PLEASE_TOKEN } })
77
+ if (result.status !== 0) throw new Error(`Failed to dispatch post-release workflow ${options.workflow}.`)
78
+ return { ...decision, deliveryKey: key, dispatched: true }
79
+ }
80
+
81
+ /** @param {string} root */
82
+ export function validateReleasePullRequestDiffs(root) {
83
+ const repository = process.env.GITHUB_REPOSITORY
84
+ if (!repository) throw new Error('GITHUB_REPOSITORY is required for release PR validation.')
85
+ const prs = ghJson(root, ['pr', 'list', '--repo', repository, '--state', 'open', '--base', 'main', '--json', 'number,title,headRefName'])
86
+ const generated = selectGeneratedReleasePrs(Array.isArray(prs) ? prs : [])
87
+ /** @type {Map<number, string[]>} */
88
+ const paths = new Map()
89
+ for (const pr of generated) {
90
+ const number = Number(pr.number)
91
+ const result = spawnSync('gh', ['pr', 'diff', String(number), '--repo', repository, '--name-only'], { cwd: resolve(root), encoding: 'utf8' })
92
+ paths.set(number, result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : [])
93
+ }
94
+ const validation = validateReleasePullRequests(Array.isArray(prs) ? prs : [], paths, approvedReleaseFiles(readReleaseConfig(root)))
95
+ console.log(JSON.stringify(validation, null, 2))
96
+ if (!validation.valid) throw new Error(validation.errors.join(' '))
97
+ return validation
98
+ }
99
+
100
+ /** @param {string} root */
101
+ export function releaseRecoveryPlan(root) {
102
+ const repository = process.env.GITHUB_REPOSITORY
103
+ if (!repository) throw new Error('GITHUB_REPOSITORY is required for release recovery planning.')
104
+ const tags = ghJson(root, ['api', `repos/${repository}/tags?per_page=100`])
105
+ const releases = ghJson(root, ['release', 'list', '--repo', repository, '--limit', '100', '--json', 'tagName,name,isDraft,isPrerelease'])
106
+ const releasePrs = ghJson(root, ['pr', 'list', '--repo', repository, '--state', 'open', '--base', 'main', '--json', 'number,title'])
107
+ const packageVersions = localPackageVersions(root)
108
+ const plan = buildReleaseRecoveryPlan({
109
+ tags: Array.isArray(tags) ? tags.map((tag) => tag.name).filter(Boolean) : [],
110
+ releases: Array.isArray(releases) ? releases : [],
111
+ releasePrs: Array.isArray(releasePrs) ? releasePrs.filter((pr) => /^chore\(main\): release /.test(pr.title ?? '')) : [],
112
+ packageVersions,
113
+ })
114
+ console.log(JSON.stringify(plan, null, 2))
115
+ return plan
116
+ }
117
+
48
118
  /** @param {string} root @param {string[]} args @returns {string} */
49
119
  function git(root, args) {
50
120
  const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' })
@@ -58,6 +128,32 @@ function diffNames(root, from, to) {
58
128
  return result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : []
59
129
  }
60
130
 
131
+ /** @param {string} root @param {string[]} args @returns {unknown} */
132
+ function ghJson(root, args) {
133
+ const result = spawnSync('gh', args, { cwd: resolve(root), encoding: 'utf8', env: { ...process.env, GH_TOKEN: process.env.RELEASE_PLEASE_TOKEN } })
134
+ if (result.status !== 0) return []
135
+ try { return JSON.parse(result.stdout) }
136
+ catch { return [] }
137
+ }
138
+
139
+ /** @param {string} root @returns {string[]} */
140
+ function localPackageVersions(root) {
141
+ const versions = []
142
+ const packageJson = join(resolve(root), 'package.json')
143
+ if (existsSync(packageJson)) {
144
+ try { versions.push(JSON.parse(readFileSync(packageJson, 'utf8')).version) } catch { /* doctor handles malformed manifests */ }
145
+ }
146
+ /** @type {Array<[string, RegExp]>} */
147
+ const manifests = [['Cargo.toml', /^version\s*=\s*["']([^"']+)["']/m], ['pyproject.toml', /^(?:version|version\s*)\s*=\s*["']([^"']+)["']/m]]
148
+ for (const [file, pattern] of manifests) {
149
+ const path = join(resolve(root), file)
150
+ if (!existsSync(path)) continue
151
+ const match = readFileSync(path, 'utf8').match(pattern)
152
+ if (match?.[1]) versions.push(match[1])
153
+ }
154
+ return versions.filter(Boolean)
155
+ }
156
+
61
157
  /** @param {string} root */
62
158
  export function releaseConfigExists(root) {
63
159
  return existsSync(join(resolve(root), 'release-please-config.json'))
@@ -3,11 +3,14 @@
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
4
  import { dirname, join, resolve } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
- import { detectLanguages, detectPackageManager, detectProfile } from '../lib/profile.mjs'
6
+ import { detectLanguages, detectPackageManager, detectProfile, recommendRunners } from '../lib/profile.mjs'
7
7
  import { configured, includesValue, readConfig } from '../lib/config.mjs'
8
+ import { buildReleaseConfig } from '../lib/release-manifest.mjs'
9
+ import { customWorkflowFiles, overlayPolicy } from '../lib/overlay.mjs'
8
10
 
9
11
  const standardFiles = [
10
12
  '.editorconfig', '.gitattributes', '.gitignore', 'release-please-config.json',
13
+ 'docs/EXTENSIONS.md',
11
14
  '.githooks/pre-commit', 'AGENTS.md', 'LICENSE', 'NOTICE', 'ruff.toml', '.prettierrc',
12
15
  '.github/CODEOWNERS', '.github/CODE_OF_CONDUCT.md', '.github/CONTRIBUTING.md',
13
16
  '.github/PULL_REQUEST_TEMPLATE.md', '.github/SECURITY.md', '.github/dependabot.yml',
@@ -15,7 +18,7 @@ const standardFiles = [
15
18
  '.github/ISSUE_TEMPLATE/feature_request.yml',
16
19
  '.github/workflows/ci.yml', '.github/workflows/codeql.yml', '.github/workflows/draft-pr.yml',
17
20
  '.github/workflows/release-pr.yml', '.github/workflows/release.yml',
18
- '.github/workflows/security.yml', '.github/workflows/test.yml',
21
+ '.github/workflows/security.yml', '.github/workflows/test.yml', '.github/workflows/opencode-security.yml',
19
22
  ]
20
23
 
21
24
  const protectedFiles = new Set([
@@ -63,6 +66,7 @@ export function syncRepository(options) {
63
66
  const runtimeRepository = configured(config.runtime_repository, '0xPlayerOne/code-foundry')
64
67
  const runtimeRef = configured(config.runtime_ref, `v${readPackageVersion(source)}`)
65
68
  const toolchain = configured(config.toolchain, 'auto')
69
+ const overlays = overlayPolicy(target, config)
66
70
  if (!['auto', 'native', 'mise'].includes(toolchain)) {
67
71
  throw new Error(`Unsupported toolchain: ${toolchain}; use auto, native, or mise.`)
68
72
  }
@@ -70,7 +74,7 @@ export function syncRepository(options) {
70
74
  const changed = []
71
75
 
72
76
  for (const file of standardFiles) {
73
- if (!shouldInclude(file, languages, features)) continue
77
+ if (!shouldInclude(file, languages, features, config)) continue
74
78
  const sourceFile = sourcePath(source, file)
75
79
  if (!existsSync(sourceFile)) throw new Error(`Template file missing: ${file}`)
76
80
  const destination = join(target, file)
@@ -82,6 +86,9 @@ export function syncRepository(options) {
82
86
  if ((file === 'LICENSE' || file === 'NOTICE') && license === 'none') continue
83
87
  if (file === '.github/CODEOWNERS' && existsSync(destination)) continue
84
88
  let content = readFileSync(sourceFile)
89
+ if (file === 'release-please-config.json') {
90
+ content = Buffer.from(renderReleaseConfig(target, sourceFile))
91
+ }
85
92
  if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
86
93
  content = Buffer.from(renderWorkflow(content.toString('utf8'), config, runtimeRepository, runtimeRef))
87
94
  }
@@ -130,14 +137,34 @@ export function syncRepository(options) {
130
137
  git(target, ['config', 'core.hooksPath', '.githooks'])
131
138
  }
132
139
  console.log(`${changed.length} baseline file(s) differ.`)
140
+ if (overlays.custom_workflows === 'preserve') {
141
+ const custom = customWorkflowFiles(target, standardFiles)
142
+ if (custom.length) console.log(`Preserved ${custom.length} repository-owned workflow(s).`)
143
+ }
133
144
  return { changed, config }
134
145
  }
135
146
 
136
- /** @param {string} file @param {string} languages @param {string} features */
137
- function shouldInclude(file, languages, features) {
147
+ /** @param {string} target @param {string} sourceFile @returns {string} */
148
+ function renderReleaseConfig(target, sourceFile) {
149
+ let baseline = {}
150
+ try { baseline = JSON.parse(readFileSync(sourceFile, 'utf8')) }
151
+ catch { baseline = {} }
152
+ const destination = join(target, 'release-please-config.json')
153
+ let existing = baseline
154
+ if (existsSync(destination)) {
155
+ try { existing = JSON.parse(readFileSync(destination, 'utf8')) }
156
+ catch { existing = baseline }
157
+ }
158
+ const merged = { ...baseline, ...existing }
159
+ return `${JSON.stringify(buildReleaseConfig(target, merged), null, 2)}\n`
160
+ }
161
+
162
+ /** @param {string} file @param {string} languages @param {string} features @param {Record<string, string>} config */
163
+ function shouldInclude(file, languages, features, config) {
138
164
  if (file === 'ruff.toml') return includesValue(languages, 'python')
139
165
  if (file === '.prettierrc') return includesValue(languages, 'typescript')
140
166
  if (file === '.github/dependabot.yml') return includesValue(features, 'dependabot')
167
+ if (file === '.github/workflows/opencode-security.yml') return ['true', 'auto'].includes(config.opencode_security ?? 'false')
141
168
  const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
142
169
  return !workflow || includesValue(features, workflow)
143
170
  }
@@ -216,14 +243,17 @@ function buffersEqual(a, b) { return a.equals(b) }
216
243
  function createDefaultConfig(root, source) {
217
244
  const languages = detectLanguages(root).join(',')
218
245
  const packageManager = detectPackageManager(root)
246
+ const runners = recommendRunners(root)
219
247
  return {
220
248
  version: '1', profile: detectProfile(root), languages, features: 'all', codeql: 'auto', dependency_review: 'auto', package_manager: packageManager,
221
249
  runtime_repository: '0xPlayerOne/code-foundry', runtime_ref: `v${readPackageVersion(source)}`,
222
- runner: 'ubuntu-latest', unit_runner: 'ubuntu-slim', ci_runner: 'ubuntu-latest', test_runner: 'ubuntu-latest',
250
+ ...runners,
223
251
  toolchain: 'auto',
224
- security_runner: 'ubuntu-slim', codeql_runner: 'ubuntu-latest', pr_runner: 'ubuntu-slim', release_runner: 'ubuntu-slim',
225
252
  prune_standard: 'false', cache_packages: 'auto', cache_build: 'auto', coverage_minimum: '80', turbo_remote: 'auto',
226
253
  release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node', npm_publish: 'false',
254
+ post_release: 'false', post_release_workflow: '', post_release_mode: 'auto',
255
+ opencode_security: 'false',
256
+ sync_mode: 'overlay', custom_workflows: 'preserve',
227
257
  license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'staging-release', merge_strategy: 'rebase',
228
258
  }
229
259
  }
@@ -0,0 +1,114 @@
1
+ // @ts-check
2
+
3
+ import { readFileSync, readdirSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+
7
+ /** @param {string} root @returns {{ errors: string[], warnings: string[], details: Record<string, unknown> }} */
8
+ export function doctorGithub(root) {
9
+ const repository = process.env.GITHUB_REPOSITORY || remoteRepository(root)
10
+ if (!repository) throw new Error('Unable to determine GitHub repository; set GITHUB_REPOSITORY.')
11
+ if (!commandExists('gh')) throw new Error('GitHub-aware doctor requires the `gh` CLI.')
12
+ /** @type {string[]} */
13
+ const errors = []
14
+ /** @type {string[]} */
15
+ const warnings = []
16
+ /** @type {Record<string, any>} */
17
+ const details = { repository }
18
+ const protection = ghJson(['api', `repos/${repository}/branches/main/protection`])
19
+ if (!protection) warnings.push('main branch protection is not readable or is not configured.')
20
+ else {
21
+ const required = [
22
+ ...(protection.required_status_checks?.contexts ?? []),
23
+ ...(protection.required_status_checks?.checks ?? []).map(/** @param {any} check */ (check) => check.context),
24
+ ].filter(Boolean)
25
+ details.requiredChecks = required
26
+ const duplicateNames = required.filter((name) => /\b([^/]+) \/ \1\b/i.test(name))
27
+ if (duplicateNames.length) errors.push(`required checks contain duplicate workflow prefixes: ${duplicateNames.join(', ')}`)
28
+ if (!protection.required_status_checks) warnings.push('main protection has no required status checks.')
29
+ }
30
+
31
+ const sha = String(ghJson(['api', `repos/${repository}/git/ref/heads/main`])?.object?.sha ?? '')
32
+ const checks = sha ? ghJson(['api', `repos/${repository}/commits/${sha}/check-runs?per_page=100`])?.check_runs ?? [] : []
33
+ const observed = checks.map(/** @param {any} check */ (check) => check.name).filter(Boolean)
34
+ details.observedChecks = observed
35
+ if (!observed.length) warnings.push('no check runs were observed on the current main commit; exact check validation is deferred until CI runs.')
36
+ if (protection?.required_status_checks) {
37
+ const required = [
38
+ ...(protection.required_status_checks.contexts ?? []),
39
+ ...(protection.required_status_checks.checks ?? []).map(/** @param {any} check */ (check) => check.context),
40
+ ].filter(Boolean)
41
+ const missing = required.filter((name) => observed.length && !observed.includes(name))
42
+ if (missing.length) warnings.push(`required checks not observed on current main: ${missing.join(', ')}`)
43
+ }
44
+
45
+ const workflowIssues = inspectWorkflows(root)
46
+ errors.push(...workflowIssues.errors)
47
+ warnings.push(...workflowIssues.warnings)
48
+
49
+ const secrets = ghJson(['secret', 'list', '--repo', repository, '--json', 'name'])
50
+ const secretNames = Array.isArray(secrets) ? secrets.map(/** @param {any} secret */ (secret) => secret.name) : []
51
+ details.secrets = { releasePleaseTokenPresent: secretNames.includes('RELEASE_PLEASE_TOKEN') }
52
+ if (!details.secrets.releasePleaseTokenPresent) warnings.push('RELEASE_PLEASE_TOKEN is not configured; Release Please auto-merge and direct post-release dispatch are unavailable.')
53
+
54
+ const config = readConfig(root)
55
+ if (['workflow-dispatch', 'dispatch'].includes(config.post_release_mode) && config.post_release !== 'false' && !details.secrets.releasePleaseTokenPresent) {
56
+ errors.push('post-release workflow-dispatch mode requires RELEASE_PLEASE_TOKEN to be present.')
57
+ }
58
+
59
+ const prs = ghJson(['pr', 'list', '--repo', repository, '--state', 'open', '--base', 'main', '--json', 'number,title,headRefName'])
60
+ const openPrs = Array.isArray(prs) ? prs : []
61
+ details.openPromotionPrs = openPrs.filter(/** @param {any} pr */ (pr) => pr.headRefName === 'staging' || /promote staging/i.test(pr.title))
62
+ details.openReleasePrs = openPrs.filter(/** @param {any} pr */ (pr) => String(pr.headRefName).startsWith('release-please--') || /^chore\(main\): release /.test(pr.title))
63
+ if (details.openPromotionPrs.length > 1) errors.push('multiple staging promotion PRs are open.')
64
+ if (details.openReleasePrs.length > 1) errors.push('multiple Release Please PRs are open.')
65
+ return { errors, warnings, details }
66
+ }
67
+
68
+ /** @param {string} root @returns {{ errors: string[], warnings: string[] }} */
69
+ function inspectWorkflows(root) {
70
+ /** @type {string[]} */
71
+ const errors = []
72
+ /** @type {string[]} */
73
+ const warnings = []
74
+ const directory = join(root, '.github/workflows')
75
+ /** @type {string[]} */
76
+ let files = []
77
+ try { files = readdirSync(directory).filter((file) => file.endsWith('.yml') || file.endsWith('.yaml')) }
78
+ catch { return { errors: ['.github/workflows is missing.'], warnings } }
79
+ for (const file of files) {
80
+ const content = readFileSync(join(directory, file), 'utf8')
81
+ if (!/^permissions:\s*$/m.test(content) && /uses:.*\.github\/workflows\//.test(content)) warnings.push(`${file} does not declare top-level permissions.`)
82
+ if (file === 'release.yml' && /contents:\s+write/.test(content) && !/pull-requests:\s+write/.test(content)) errors.push('release.yml needs pull-requests: write for guarded Release Please PR handling.')
83
+ }
84
+ return { errors, warnings }
85
+ }
86
+
87
+ /** @param {string} root @returns {string} */
88
+ function remoteRepository(root) {
89
+ const result = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8' })
90
+ const value = result.status === 0 ? result.stdout.trim() : ''
91
+ return value.replace(/^git@github\.com:/, '').replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '')
92
+ }
93
+
94
+ /** @param {string[]} args @returns {any} */
95
+ function ghJson(args) {
96
+ const result = spawnSync('gh', args, { encoding: 'utf8' })
97
+ if (result.status !== 0) return null
98
+ try { return JSON.parse(result.stdout) }
99
+ catch { return null }
100
+ }
101
+
102
+ /** @param {string} command */
103
+ function commandExists(command) { return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0 }
104
+
105
+ /** @param {string} root @returns {Record<string, string>} */
106
+ function readConfig(root) {
107
+ const file = join(root, '.github/code-foundry.yml')
108
+ try {
109
+ return Object.fromEntries(readFileSync(file, 'utf8').split(/\r?\n/).flatMap((line) => {
110
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
111
+ return match ? [[match[1], match[2].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')]] : []
112
+ }))
113
+ } catch { return {} }
114
+ }
@@ -0,0 +1,29 @@
1
+ // @ts-check
2
+
3
+ import { existsSync, readdirSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ /** @param {string[]} standardFiles @param {string} file */
7
+ export function isManagedPath(standardFiles, file) {
8
+ return standardFiles.includes(file)
9
+ }
10
+
11
+ /** @param {string} root @param {string[]} standardFiles @returns {string[]} */
12
+ export function customWorkflowFiles(root, standardFiles) {
13
+ const directory = join(root, '.github/workflows')
14
+ if (!existsSync(directory)) return []
15
+ return readdirSync(directory)
16
+ .filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'))
17
+ .map((file) => `.github/workflows/${file}`)
18
+ .filter((file) => !isManagedPath(standardFiles, file))
19
+ .sort()
20
+ }
21
+
22
+ /** @param {string} root @param {Record<string, string>} config */
23
+ export function overlayPolicy(root, config) {
24
+ const mode = config.sync_mode ?? 'overlay'
25
+ const custom = config.custom_workflows ?? 'preserve'
26
+ if (!['overlay', 'strict'].includes(mode)) throw new Error(`Unsupported sync_mode: ${mode}; use overlay or strict.`)
27
+ if (custom !== 'preserve') throw new Error(`Unsupported custom_workflows: ${custom}; custom workflows are always preserved.`)
28
+ return { mode, custom_workflows: custom }
29
+ }
@@ -102,6 +102,32 @@ export function resolveProfile(root) {
102
102
  }
103
103
  }
104
104
 
105
+ /**
106
+ * Recommend runners from detected workload cost. Slim is reserved for short
107
+ * metadata/security/PR jobs; native toolchains and browser/contract tests use
108
+ * the full runner.
109
+ * @param {string} root
110
+ */
111
+ export function recommendRunners(root) {
112
+ const languages = detectLanguages(root)
113
+ const heavy = languages.some((language) => ['rust', 'python', 'solidity'].includes(language)) || hasBrowserProject(root)
114
+ return {
115
+ runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
116
+ ci_runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
117
+ test_runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
118
+ unit_runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
119
+ security_runner: 'ubuntu-slim',
120
+ codeql_runner: 'ubuntu-latest',
121
+ pr_runner: 'ubuntu-slim',
122
+ release_runner: 'ubuntu-slim',
123
+ }
124
+ }
125
+
126
+ /** @param {string} root */
127
+ function hasBrowserProject(root) {
128
+ return ['playwright.config.ts', 'playwright.config.js', 'cypress.config.ts', 'cypress.config.js'].some((file) => existsSync(join(root, file)))
129
+ }
130
+
105
131
  /** @param {string} root */
106
132
  function detectReleaseType(root) {
107
133
  if (existsSync(join(root, 'package.json'))) return 'node'
@@ -0,0 +1,40 @@
1
+ // @ts-check
2
+
3
+ import { createHash } from 'node:crypto'
4
+
5
+ /** @typedef {'disabled'|'workflow-dispatch'|'release-event'|'unavailable'} HookDelivery */
6
+
7
+ /**
8
+ * Select exactly one post-release delivery mechanism.
9
+ * @param {{ mode?: string, tokenPresent: boolean, releaseEventEnabled?: boolean }} input
10
+ * @returns {{ delivery: HookDelivery, reason: string }}
11
+ */
12
+ export function selectHookDelivery(input) {
13
+ const mode = input.mode ?? 'auto'
14
+ if (mode === 'false' || mode === 'disabled') return { delivery: 'disabled', reason: 'Post-release hooks are disabled by configuration.' }
15
+ if (mode === 'workflow-dispatch' || (mode === 'auto' && input.tokenPresent)) {
16
+ return input.tokenPresent
17
+ ? { delivery: 'workflow-dispatch', reason: 'A PAT is available; use one explicit workflow dispatch.' }
18
+ : { delivery: 'unavailable', reason: 'workflow-dispatch requires a PAT.' }
19
+ }
20
+ if (mode === 'release-event' || (mode === 'auto' && input.releaseEventEnabled !== false)) {
21
+ return { delivery: 'release-event', reason: 'Use the published-release event; do not issue a fallback dispatch.' }
22
+ }
23
+ return { delivery: 'unavailable', reason: 'No supported post-release delivery mechanism is configured.' }
24
+ }
25
+
26
+ /** @param {string} repository @param {string} tag @returns {string} */
27
+ export function releaseDeliveryKey(repository, tag) {
28
+ return createHash('sha256').update(`${repository}\0${tag}`).digest('hex').slice(0, 24)
29
+ }
30
+
31
+ /**
32
+ * A workflow dispatch is considered already delivered when a run exists for
33
+ * the release tag. Completed failures remain recorded so retries are explicit
34
+ * rather than accidentally duplicated by a second event path.
35
+ * @param {Array<{headBranch?: string, displayTitle?: string, status?: string}>} runs
36
+ * @param {string} tag
37
+ */
38
+ export function hasDeliveredHook(runs, tag) {
39
+ return runs.some((run) => run.headBranch === tag || run.displayTitle?.includes(`release-tag=${tag}`))
40
+ }
@@ -0,0 +1,121 @@
1
+ // @ts-check
2
+
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ const ignored = new Set(['.git', '.code-foundry', '.venv', 'node_modules', 'target', 'vendor', 'dist', 'build'])
7
+
8
+ /** @typedef {{ directory: string, manifest: string, releaseType: 'node'|'python'|'rust', packageName?: string, extraFiles: string[] }} ReleasePackage */
9
+
10
+ /** @param {string} root @returns {ReleasePackage[]} */
11
+ export function detectReleasePackages(root) {
12
+ /** @type {ReleasePackage[]} */
13
+ const packages = []
14
+ walk(root, (file) => {
15
+ const name = file.split('/').pop()
16
+ const directory = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '.'
17
+ if (name === 'package.json') {
18
+ const parsed = readJson(join(root, file))
19
+ packages.push({ directory, manifest: file, releaseType: 'node', packageName: typeof parsed?.name === 'string' ? parsed.name : undefined, extraFiles: detectExtraFiles(root, directory) })
20
+ } else if (name === 'Cargo.toml') {
21
+ packages.push({ directory, manifest: file, releaseType: 'rust', packageName: tomlPackageName(join(root, file)), extraFiles: detectExtraFiles(root, directory) })
22
+ } else if (name === 'pyproject.toml') {
23
+ packages.push({ directory, manifest: file, releaseType: 'python', packageName: pyprojectName(join(root, file)), extraFiles: detectExtraFiles(root, directory) })
24
+ }
25
+ })
26
+ return packages.sort((a, b) => a.directory.localeCompare(b.directory))
27
+ }
28
+
29
+ /**
30
+ * Merge automatic package and extra-file detection into a Release Please
31
+ * configuration without discarding repository-owned settings.
32
+ * @param {string} root
33
+ * @param {Record<string, any>} existing
34
+ */
35
+ export function buildReleaseConfig(root, existing = {}) {
36
+ const packages = detectReleasePackages(root)
37
+ const result = { ...existing }
38
+ if (!packages.length) return result
39
+ if (packages.length === 1 && packages[0].directory === '.') {
40
+ result['release-type'] ??= packages[0].releaseType
41
+ /** @type {any[]} */
42
+ const extra = Array.isArray(result['extra-files']) ? result['extra-files'] : []
43
+ result['extra-files'] = extra
44
+ for (const file of packages[0].extraFiles) {
45
+ if (!extra.some((entry) => (typeof entry === 'string' ? entry : entry?.path) === file)) extra.push(file)
46
+ }
47
+ return result
48
+ }
49
+ /** @type {Record<string, any>} */
50
+ const configuredPackages = { ...(result.packages ?? {}) }
51
+ for (const entry of packages) {
52
+ const current = { ...(configuredPackages[entry.directory] ?? {}) }
53
+ current['release-type'] ??= entry.releaseType
54
+ if (entry.packageName) current['package-name'] ??= entry.packageName
55
+ /** @type {any[]} */
56
+ const extra = Array.isArray(current['extra-files']) ? [...current['extra-files']] : []
57
+ for (const file of entry.extraFiles) if (!extra.some((item) => (typeof item === 'string' ? item : item?.path) === file)) extra.push(file)
58
+ if (extra.length) current['extra-files'] = extra
59
+ configuredPackages[entry.directory] = current
60
+ }
61
+ result.packages = configuredPackages
62
+ delete result['release-type']
63
+ return result
64
+ }
65
+
66
+ /** @param {string} root @param {Record<string, any>} config @returns {string[]} */
67
+ export function validateReleaseConfig(root, config) {
68
+ const detected = detectReleasePackages(root)
69
+ if (!detected.length) return []
70
+ const configured = config.packages && typeof config.packages === 'object' ? config.packages : null
71
+ if (detected.length > 1 && !configured) return ['mixed-language or multi-package repositories require release-please packages configuration']
72
+ if (!configured) return []
73
+ const errors = []
74
+ for (const entry of detected) {
75
+ const item = configured[entry.directory]
76
+ if (!item) errors.push(`release-please packages is missing ${entry.directory}`)
77
+ else if (item['release-type'] !== entry.releaseType) errors.push(`${entry.directory} release-type should be ${entry.releaseType}`)
78
+ }
79
+ return errors
80
+ }
81
+
82
+ /** @param {string} root @param {(file: string) => void} visit */
83
+ function walk(root, visit) {
84
+ /** @param {string} directory */
85
+ function descend(directory) {
86
+ for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) {
87
+ if (entry.isDirectory() && ignored.has(entry.name)) continue
88
+ const path = directory ? `${directory}/${entry.name}` : entry.name
89
+ if (entry.isDirectory()) descend(path)
90
+ else visit(path)
91
+ }
92
+ }
93
+ descend('')
94
+ }
95
+
96
+ /** @param {string} root @param {string} directory @returns {string[]} */
97
+ function detectExtraFiles(root, directory) {
98
+ const candidates = ['version.txt', 'VERSION', '.version', 'version.json', 'src/version.ts', 'src/version.js', 'src/version.py', 'src/version.rs']
99
+ return candidates
100
+ .map((file) => directory === '.' ? file : `${directory}/${file}`)
101
+ .filter((file) => existsSync(join(root, file)))
102
+ .map((file) => directory === '.' ? file : file.slice(directory.length + 1))
103
+ }
104
+
105
+ /** @param {string} file @returns {any} */
106
+ function readJson(file) {
107
+ try { return JSON.parse(readFileSync(file, 'utf8')) }
108
+ catch { return {} }
109
+ }
110
+
111
+ /** @param {string} file @returns {string|undefined} */
112
+ function tomlPackageName(file) {
113
+ const match = readFileSync(file, 'utf8').match(/^name\s*=\s*["']([^"']+)["']/m)
114
+ return match?.[1]
115
+ }
116
+
117
+ /** @param {string} file @returns {string|undefined} */
118
+ function pyprojectName(file) {
119
+ const match = readFileSync(file, 'utf8').match(/^(?:name|name\s*)\s*=\s*["']([^"']+)["']/m)
120
+ return match?.[1]
121
+ }
@@ -70,6 +70,81 @@ export function unexpectedReleasePaths(paths, allowed) {
70
70
  .filter((path) => !allowed.has(path))
71
71
  }
72
72
 
73
+ /** @param {{ releasePleaseToken?: string, githubToken?: string }} input */
74
+ export function selectReleaseCredential(input) {
75
+ if (input.releasePleaseToken) return { token: input.releasePleaseToken, source: 'release-please-token', autoMerge: true }
76
+ if (input.githubToken) return { token: input.githubToken, source: 'github-token', autoMerge: false }
77
+ return { token: '', source: 'missing', autoMerge: false }
78
+ }
79
+
80
+ /** @param {Array<{number?: number, title?: string, headRefName?: string}>} prs */
81
+ export function selectGeneratedReleasePrs(prs) {
82
+ return prs.filter((pr) => /^chore\(main\): release /.test(pr.title ?? '') && String(pr.headRefName ?? '').startsWith('release-please--branches--main'))
83
+ }
84
+
85
+ /**
86
+ * Validate all generated release PR diffs before any merge is attempted.
87
+ * @param {Array<{number?: number, title?: string, headRefName?: string}>} prs
88
+ * @param {Map<number, string[]>} changedPathsByPr
89
+ * @param {Set<string>} allowed
90
+ */
91
+ export function validateReleasePullRequests(prs, changedPathsByPr, allowed) {
92
+ const errors = []
93
+ const generated = selectGeneratedReleasePrs(prs)
94
+ if (!generated.length) errors.push('Release Please reported a PR, but no generated release PR was found.')
95
+ for (const pr of generated) {
96
+ const number = Number(pr.number)
97
+ const paths = changedPathsByPr.get(number)
98
+ if (!Array.isArray(paths) || !paths.length) {
99
+ errors.push(`Generated release PR #${number} has no changed files or its diff is malformed.`)
100
+ continue
101
+ }
102
+ const unexpected = unexpectedReleasePaths(paths, allowed)
103
+ if (unexpected.length) errors.push(`Generated release PR #${number} contains unexpected paths: ${unexpected.join(', ')}`)
104
+ }
105
+ return { valid: errors.length === 0, generated, errors }
106
+ }
107
+
108
+ /**
109
+ * Build a non-destructive release recovery plan from independent release
110
+ * metadata sources.
111
+ * @param {{ tags: string[], releases: Array<{tagName?: string}>, releasePrs: Array<{number?: number,title?: string}>, packageVersions: string[] }} input
112
+ */
113
+ export function buildReleaseRecoveryPlan(input) {
114
+ const tags = [...new Set(input.tags.filter((tag) => /^v?\d+\.\d+\.\d+/.test(tag)))]
115
+ /** @type {string[]} */
116
+ const releaseTags = [...new Set(input.releases.map((release) => release.tagName).filter((tag) => typeof tag === 'string'))]
117
+ const tagSet = new Set(tags)
118
+ const releaseSet = new Set(releaseTags)
119
+ const missingGitHubReleases = tags.filter((tag) => !releaseSet.has(tag) && !releaseSet.has(tag.replace(/^v/, '')))
120
+ const orphanGitHubReleases = releaseTags.filter((tag) => !tagSet.has(tag) && !tagSet.has(tag.replace(/^v/, '')))
121
+ const latestTag = [...tags].sort(compareVersions).at(-1) ?? ''
122
+ const latestPackageVersion = [...input.packageVersions].sort(compareVersions).at(-1) ?? ''
123
+ return {
124
+ latestTag,
125
+ latestPackageVersion,
126
+ missingGitHubReleases,
127
+ orphanGitHubReleases,
128
+ pendingReleasePullRequests: input.releasePrs,
129
+ packageVersionMismatch: Boolean(latestTag && latestPackageVersion && normalizeVersion(latestTag) !== normalizeVersion(latestPackageVersion)),
130
+ actions: [
131
+ ...missingGitHubReleases.map((tag) => `create GitHub release metadata for ${tag}`),
132
+ ...(latestTag && latestPackageVersion && normalizeVersion(latestTag) !== normalizeVersion(latestPackageVersion) ? [`review package/tag mismatch (${latestPackageVersion} vs ${latestTag})`] : []),
133
+ ],
134
+ }
135
+ }
136
+
137
+ /** @param {string} value */
138
+ function normalizeVersion(value) { return value.replace(/^v/, '').split('-')[0] }
139
+
140
+ /** @param {string} a @param {string} b */
141
+ function compareVersions(a, b) {
142
+ const left = normalizeVersion(a).split('.').map(Number)
143
+ const right = normalizeVersion(b).split('.').map(Number)
144
+ for (let index = 0; index < 3; index += 1) if ((left[index] ?? 0) !== (right[index] ?? 0)) return (left[index] ?? 0) - (right[index] ?? 0)
145
+ return a.localeCompare(b)
146
+ }
147
+
73
148
  /**
74
149
  * Classify the relationship between main and staging. A fast-forward is safe
75
150
  * only when main is the ancestor of staging or staging is the ancestor of main