code-foundry 0.27.17 → 0.28.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.
@@ -8,6 +8,16 @@ on:
8
8
  required: false
9
9
  type: string
10
10
  default: ubuntu-slim
11
+ runtime-repository:
12
+ description: Repository containing the Code Foundry runtime.
13
+ required: false
14
+ type: string
15
+ default: 0xPlayerOne/code-foundry
16
+ runtime-ref:
17
+ description: Code Foundry runtime tag or ref.
18
+ required: false
19
+ type: string
20
+ default: v0.27.18
11
21
 
12
22
  permissions:
13
23
  contents: write
@@ -71,6 +81,16 @@ jobs:
71
81
  `release_type=${releaseType}\nlegacy_release_type=${legacyReleaseType}\nnpm_publish=${npmPublish}\n`,
72
82
  )
73
83
  NODE
84
+ - name: Detect release credentials
85
+ id: credentials
86
+ env:
87
+ RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
88
+ run: |
89
+ if [ -n "$RELEASE_PLEASE_TOKEN" ]; then
90
+ echo "auto_merge=true" >> "$GITHUB_OUTPUT"
91
+ else
92
+ echo "auto_merge=false" >> "$GITHUB_OUTPUT"
93
+ fi
74
94
  - name: Release Please
75
95
  id: release
76
96
  if: steps.profile.outputs.release_type != 'none'
@@ -79,10 +99,14 @@ jobs:
79
99
  token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }}
80
100
  config-file: release-please-config.json
81
101
  release-type: ${{ steps.profile.outputs.legacy_release_type }}
102
+ - name: Leave release pull request for manual merge
103
+ if: steps.release.outputs.prs_created == 'true' && steps.credentials.outputs.auto_merge != 'true'
104
+ run: |
105
+ echo "::notice title=Manual release merge required::Release Please created or updated a version pull request. RELEASE_PLEASE_TOKEN is not configured, so Code Foundry skipped guarded auto-merge. Configure the token before merging when downstream release workflows must run automatically."
82
106
  - name: Merge generated version pull requests
83
- if: steps.release.outputs.prs_created == 'true'
107
+ if: steps.release.outputs.prs_created == 'true' && steps.credentials.outputs.auto_merge == 'true'
84
108
  env:
85
- GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }}
109
+ GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
86
110
  run: |
87
111
  set -euo pipefail
88
112
 
@@ -154,6 +178,47 @@ jobs:
154
178
  --delete-branch
155
179
  done
156
180
 
181
+ reconcile:
182
+ name: Release / Reconcile
183
+ needs: release
184
+ if: needs.release.outputs.release_created == 'true'
185
+ runs-on: ${{ inputs.runner }}
186
+ timeout-minutes: 15
187
+ permissions:
188
+ contents: write
189
+ pull-requests: write
190
+ env:
191
+ GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }}
192
+ steps:
193
+ - name: Checkout repository
194
+ uses: actions/checkout@v7
195
+ with:
196
+ fetch-depth: 0
197
+ - name: Checkout runtime
198
+ uses: actions/checkout@v7
199
+ with:
200
+ repository: ${{ inputs.runtime-repository }}
201
+ ref: ${{ inputs.runtime-ref }}
202
+ path: .code-foundry
203
+ sparse-checkout: |
204
+ src
205
+ release-please-config.json
206
+ - name: Check staging branch
207
+ id: staging
208
+ run: |
209
+ if git ls-remote --exit-code origin refs/heads/staging >/dev/null 2>&1; then
210
+ echo "exists=true" >> "$GITHUB_OUTPUT"
211
+ git fetch --no-tags origin main staging
212
+ else
213
+ echo "exists=false" >> "$GITHUB_OUTPUT"
214
+ fi
215
+ - name: Reconcile release metadata
216
+ if: steps.staging.outputs.exists == 'true'
217
+ run: node .code-foundry/src/cli.mjs release reconcile --github --base main --head staging
218
+ - name: Skip without staging
219
+ if: steps.staging.outputs.exists != 'true'
220
+ run: echo 'No staging branch exists; release reconciliation is not applicable.'
221
+
157
222
  npm:
158
223
  name: Release / Publish npm
159
224
  needs: release
@@ -17,4 +17,6 @@ jobs:
17
17
  uses: ./.github/workflows/release.yml
18
18
  with:
19
19
  runner: ubuntu-slim
20
+ runtime-repository: 0xPlayerOne/code-foundry
21
+ runtime-ref: v0.27.18
20
22
  secrets: inherit
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.28.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.18...v0.28.0) (2026-07-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **release:** reconcile staging after release metadata ([4de1ad6](https://github.com/0xPlayerOne/code-foundry/commit/4de1ad64bd9276344368934ab555e6cb31a90784))
9
+
10
+ ## [0.27.18](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.17...v0.27.18) (2026-07-29)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **release:** degrade gracefully without release token ([69ac43a](https://github.com/0xPlayerOne/code-foundry/commit/69ac43acfa2813495b8da0535942afbbedae3017))
16
+
3
17
  ## [0.27.17](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.16...v0.27.17) (2026-07-29)
4
18
 
5
19
 
package/docs/RELEASES.md CHANGED
@@ -29,8 +29,8 @@ body. Existing `CHANGELOG.md` history remains repository-owned.
29
29
  Set these values in `.github/code-foundry.yml`:
30
30
 
31
31
  ```yaml
32
- release_type: auto # auto, node, python, rust, simple, or none
33
- npm_publish: false # true only for an npm package
32
+ release_type: auto # auto, node, python, rust, simple, or none
33
+ npm_publish: false # true only for an npm package
34
34
  ```
35
35
 
36
36
  `auto` selects a supported manifest. Use `simple` with `version.txt` for a
@@ -39,9 +39,16 @@ not release automatically.
39
39
 
40
40
  ## Pull request permissions
41
41
 
42
- If the default `GITHUB_TOKEN` cannot create pull requests, configure a narrowly
43
- scoped `RELEASE_PLEASE_TOKEN` repository or organization secret. The release
44
- workflow needs `contents`, `issues`, and `pull-requests` permissions.
42
+ Release Please falls back to the repository's `GITHUB_TOKEN` when a
43
+ `RELEASE_PLEASE_TOKEN` secret is unavailable. In that mode, Code Foundry opens
44
+ or updates the version pull request, leaves it for manual merge, and completes
45
+ the release job successfully.
46
+
47
+ Configure a narrowly scoped `RELEASE_PLEASE_TOKEN` repository or organization
48
+ secret to enable guarded automatic merging and downstream workflows triggered
49
+ by the resulting release. The token needs `contents`, `issues`, and
50
+ `pull-requests` write permissions. Code Foundry validates every changed path in
51
+ the generated version pull request before using the token to merge it.
45
52
 
46
53
  ## Operational checklist
47
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.27.17",
3
+ "version": "0.28.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
@@ -4,11 +4,12 @@
4
4
  import { resolve } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { doctor } from './commands/doctor.mjs'
7
+ import { reconcileRelease } from './commands/release.mjs'
7
8
  import { syncRepository } from './commands/sync.mjs'
8
9
 
9
10
  const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
10
11
 
11
- /** @typedef {{ target: string, dryRun: boolean, force: boolean }} Options */
12
+ /** @typedef {{ target: string, dryRun: boolean, force: boolean, github: boolean, base: string, head: string }} Options */
12
13
  /** @typedef {{ command: string, options: Options }} ParsedArgs */
13
14
 
14
15
  const usage = `code-foundry — initialize and maintain agent-ready repositories
@@ -17,6 +18,7 @@ Usage:
17
18
  npx code-foundry init [--target PATH]
18
19
  npx code-foundry sync [--target PATH]
19
20
  npx code-foundry doctor [--target PATH]
21
+ npx code-foundry release reconcile [--github] [--base BRANCH] [--head BRANCH]
20
22
 
21
23
  The repository configuration lives in .github/code-foundry.yml.
22
24
  init detects the repository, creates that file, and renders the baseline.
@@ -26,6 +28,9 @@ Options:
26
28
  --target PATH Repository directory (default: current directory)
27
29
  --dry-run Preview changes without writing files
28
30
  --force Replace protected standard documents
31
+ --github Apply a verified fast-forward through GitHub
32
+ --base BRANCH Release source branch (default: main)
33
+ --head BRANCH Branch to reconcile (default: staging)
29
34
  -h, --help Show this help
30
35
  `
31
36
 
@@ -40,7 +45,12 @@ function parseArgs(argv) {
40
45
  const first = argv[0]
41
46
  const hasCommand = Boolean(first && !first.startsWith('-'))
42
47
  const command = hasCommand ? /** @type {string} */ (argv.shift()) : 'init'
43
- const options = { target: process.cwd(), dryRun: false, force: false }
48
+ const options = { target: process.cwd(), dryRun: false, force: false, github: false, base: 'main', head: 'staging' }
49
+
50
+ if (command === 'release') {
51
+ const subcommand = argv.shift()
52
+ if (subcommand !== 'reconcile') fail(`unknown release command: ${subcommand ?? '(missing)'}; use release reconcile`)
53
+ }
44
54
 
45
55
  while (argv.length) {
46
56
  const arg = argv.shift()
@@ -54,6 +64,9 @@ function parseArgs(argv) {
54
64
  options.target = value
55
65
  } else if (arg === '--dry-run') options.dryRun = true
56
66
  else if (arg === '--force') options.force = true
67
+ else if (arg === '--github') options.github = true
68
+ else if (arg === '--base') options.base = argv.shift() ?? fail('--base requires a branch')
69
+ else if (arg === '--head') options.head = argv.shift() ?? fail('--head requires a branch')
57
70
  else fail(`unknown option: ${arg}; run --help for the supported options`)
58
71
  }
59
72
 
@@ -83,6 +96,12 @@ function main() {
83
96
  console.error(`code-foundry: ${error instanceof Error ? error.message : String(error)}`)
84
97
  process.exitCode = 1
85
98
  }
99
+ } else if (command === 'release') {
100
+ try {
101
+ reconcileRelease(target, options)
102
+ } catch (error) {
103
+ fail(error instanceof Error ? error.message : String(error))
104
+ }
86
105
  }
87
106
  else fail(`unknown command: ${command}`)
88
107
  }
@@ -0,0 +1,64 @@
1
+ // @ts-check
2
+
3
+ import { existsSync } from 'node:fs'
4
+ import { join, resolve } from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { approvedReleaseFiles, classifyReconciliation, readReleaseConfig } from '../lib/release-policy.mjs'
7
+
8
+ /** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
9
+
10
+ /**
11
+ * Reconcile staging after Release Please updates main. The local mode is
12
+ * deterministic and suitable for CI; --github applies only a verified
13
+ * fast-forward through the GitHub API and otherwise fails closed.
14
+ * @param {string} root
15
+ * @param {ReleaseOptions} options
16
+ */
17
+ export function reconcileRelease(root, options) {
18
+ const target = resolve(root)
19
+ const base = options.base || 'main'
20
+ const head = options.head || 'staging'
21
+ const mainSha = git(target, ['rev-parse', `origin/${base}`]) || git(target, ['rev-parse', base])
22
+ const stagingSha = git(target, ['rev-parse', `origin/${head}`]) || git(target, ['rev-parse', head])
23
+ const mergeBaseSha = git(target, ['merge-base', mainSha, stagingSha])
24
+ const mainChangedPaths = diffNames(target, mergeBaseSha, mainSha)
25
+ const stagingChangedPaths = diffNames(target, mergeBaseSha, stagingSha)
26
+ const plan = classifyReconciliation({
27
+ mainSha,
28
+ stagingSha,
29
+ mergeBaseSha,
30
+ mainChangedPaths,
31
+ stagingChangedPaths,
32
+ allowed: approvedReleaseFiles(readReleaseConfig(target)),
33
+ })
34
+ console.log(JSON.stringify({ base, head, ...plan }, null, 2))
35
+ if (plan.action === 'fail') throw new Error(plan.reason + (plan.unexpected?.length ? ` Unexpected paths: ${plan.unexpected.join(', ')}` : ''))
36
+ if (plan.action !== 'fast-forward' || !options.github || options.dryRun) return plan
37
+ if (!process.env.GITHUB_REPOSITORY) throw new Error('GITHUB_REPOSITORY is required for --github reconciliation.')
38
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
39
+ if (!token) throw new Error('GH_TOKEN or GITHUB_TOKEN is required for --github reconciliation.')
40
+ const result = spawnSync('gh', [
41
+ 'api', '--method', 'PATCH', `repos/${process.env.GITHUB_REPOSITORY}/git/refs/heads/${head}`,
42
+ '-f', `sha=${plan.targetSha}`, '-F', 'force=false',
43
+ ], { cwd: target, stdio: 'inherit', env: { ...process.env, GH_TOKEN: token } })
44
+ if (result.status !== 0) throw new Error(`GitHub refused the protected fast-forward of ${head}.`)
45
+ return plan
46
+ }
47
+
48
+ /** @param {string} root @param {string[]} args @returns {string} */
49
+ function git(root, args) {
50
+ const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' })
51
+ return result.status === 0 ? result.stdout.trim() : ''
52
+ }
53
+
54
+ /** @param {string} root @param {string} from @param {string} to @returns {string[]} */
55
+ function diffNames(root, from, to) {
56
+ if (!from || !to || from === to) return []
57
+ const result = spawnSync('git', ['diff', '--name-only', from, to], { cwd: root, encoding: 'utf8' })
58
+ return result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : []
59
+ }
60
+
61
+ /** @param {string} root */
62
+ export function releaseConfigExists(root) {
63
+ return existsSync(join(resolve(root), 'release-please-config.json'))
64
+ }
@@ -0,0 +1,108 @@
1
+ // @ts-check
2
+
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ const DEFAULT_RELEASE_FILES = new Set([
7
+ '.release-please-manifest.json',
8
+ 'CHANGELOG.md',
9
+ 'Cargo.lock',
10
+ 'Cargo.toml',
11
+ 'bun.lock',
12
+ 'bun.lockb',
13
+ 'package-lock.json',
14
+ 'package.json',
15
+ 'pnpm-lock.yaml',
16
+ 'pyproject.toml',
17
+ 'uv.lock',
18
+ 'version.txt',
19
+ 'yarn.lock',
20
+ ])
21
+
22
+ /** @param {string} root @returns {Record<string, unknown>} */
23
+ export function readReleaseConfig(root) {
24
+ const file = join(root, 'release-please-config.json')
25
+ if (!existsSync(file)) return {}
26
+ try { return JSON.parse(readFileSync(file, 'utf8')) }
27
+ catch { return {} }
28
+ }
29
+
30
+ /**
31
+ * Return the release files allowed to change during an automated reconciliation.
32
+ * Package manifests are scoped beneath their Release Please package directory.
33
+ * @param {Record<string, unknown>} config
34
+ * @returns {Set<string>}
35
+ */
36
+ export function approvedReleaseFiles(config = {}) {
37
+ const allowed = new Set(DEFAULT_RELEASE_FILES)
38
+ addExtraFiles(allowed, config['extra-files'])
39
+ const packages = config.packages
40
+ if (packages && typeof packages === 'object' && !Array.isArray(packages)) {
41
+ for (const [directory, value] of Object.entries(packages)) {
42
+ const prefix = directory === '.' ? '' : directory.replace(/\/$/, '')
43
+ for (const file of DEFAULT_RELEASE_FILES) addPath(allowed, prefix, file)
44
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
45
+ addExtraFiles(allowed, value['extra-files'], prefix)
46
+ }
47
+ }
48
+ }
49
+ return allowed
50
+ }
51
+
52
+ /** @param {Set<string>} allowed @param {unknown} entries @param {string} [prefix] */
53
+ function addExtraFiles(allowed, entries, prefix = '') {
54
+ if (!Array.isArray(entries)) return
55
+ for (const entry of entries) {
56
+ const path = typeof entry === 'string' ? entry : entry && typeof entry === 'object' && 'path' in entry ? entry.path : ''
57
+ if (typeof path === 'string' && path) addPath(allowed, prefix, path)
58
+ }
59
+ }
60
+
61
+ /** @param {Set<string>} allowed @param {string} prefix @param {string} path */
62
+ function addPath(allowed, prefix, path) {
63
+ const clean = path.replace(/^\.\//, '').replace(/\\/g, '/')
64
+ allowed.add(prefix ? `${prefix}/${clean}` : clean)
65
+ }
66
+
67
+ /** @param {string[]} paths @param {Set<string>} allowed @returns {string[]} */
68
+ export function unexpectedReleasePaths(paths, allowed) {
69
+ return [...new Set(paths.map((path) => path.trim()).filter(Boolean))]
70
+ .filter((path) => !allowed.has(path))
71
+ }
72
+
73
+ /**
74
+ * Classify the relationship between main and staging. A fast-forward is safe
75
+ * only when main is the ancestor of staging or staging is the ancestor of main
76
+ * and the intervening main files are release metadata.
77
+ * @param {{ mainSha: string, stagingSha: string, mergeBaseSha?: string, mainChangedPaths?: string[], stagingChangedPaths?: string[], allowed?: Set<string> }} input
78
+ */
79
+ export function classifyReconciliation(input) {
80
+ const {
81
+ mainSha,
82
+ stagingSha,
83
+ mergeBaseSha = '',
84
+ mainChangedPaths = [],
85
+ stagingChangedPaths = [],
86
+ allowed = approvedReleaseFiles(),
87
+ } = input
88
+ if (!mainSha || !stagingSha) return { action: 'fail', reason: 'Missing branch SHA.' }
89
+ if (mainSha === stagingSha) return { action: 'aligned', reason: 'Branches already point at the same commit.' }
90
+ if (mainSha === mergeBaseSha) {
91
+ return { action: 'none', reason: 'staging contains commits that are not on main; no release-only reconciliation is needed.' }
92
+ }
93
+ const unexpectedMain = unexpectedReleasePaths(mainChangedPaths, allowed)
94
+ const unexpectedStaging = unexpectedReleasePaths(stagingChangedPaths, allowed)
95
+ if (unexpectedMain.length || unexpectedStaging.length) {
96
+ return {
97
+ action: 'fail',
98
+ reason: 'Branch divergence includes repository code or configuration.',
99
+ unexpected: [...new Set([...unexpectedMain, ...unexpectedStaging])],
100
+ }
101
+ }
102
+ if (stagingSha === mergeBaseSha) {
103
+ return { action: 'fast-forward', targetSha: mainSha, reason: 'main only added approved release metadata.' }
104
+ }
105
+ return { action: 'pull-request', targetSha: mainSha, reason: 'Branches diverged, but only approved release metadata changed.' }
106
+ }
107
+
108
+ export { DEFAULT_RELEASE_FILES }