code-foundry 0.27.18 → 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
@@ -168,6 +178,47 @@ jobs:
168
178
  --delete-branch
169
179
  done
170
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
+
171
222
  npm:
172
223
  name: Release / Publish npm
173
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,12 @@
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
+
3
10
  ## [0.27.18](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.17...v0.27.18) (2026-07-29)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.27.18",
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 }