code-foundry 0.27.18 → 0.29.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.29.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.28.0...v0.29.0) (2026-07-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **release:** detect mixed-language release manifests ([f2daa61](https://github.com/0xPlayerOne/code-foundry/commit/f2daa616f2d8cfd2883f960823e862768454fbd6))
9
+
10
+ ## [0.28.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.18...v0.28.0) (2026-07-29)
11
+
12
+
13
+ ### Features
14
+
15
+ * **release:** reconcile staging after release metadata ([4de1ad6](https://github.com/0xPlayerOne/code-foundry/commit/4de1ad64bd9276344368934ab555e6cb31a90784))
16
+
3
17
  ## [0.27.18](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.17...v0.27.18) (2026-07-29)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.27.18",
3
+ "version": "0.29.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
+ }
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { detectLanguages, detectPackageManager, detectProfile } from '../lib/profile.mjs'
7
7
  import { configured, includesValue, readConfig } from '../lib/config.mjs'
8
+ import { buildReleaseConfig } from '../lib/release-manifest.mjs'
8
9
 
9
10
  const standardFiles = [
10
11
  '.editorconfig', '.gitattributes', '.gitignore', 'release-please-config.json',
@@ -82,6 +83,9 @@ export function syncRepository(options) {
82
83
  if ((file === 'LICENSE' || file === 'NOTICE') && license === 'none') continue
83
84
  if (file === '.github/CODEOWNERS' && existsSync(destination)) continue
84
85
  let content = readFileSync(sourceFile)
86
+ if (file === 'release-please-config.json') {
87
+ content = Buffer.from(renderReleaseConfig(target, sourceFile))
88
+ }
85
89
  if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
86
90
  content = Buffer.from(renderWorkflow(content.toString('utf8'), config, runtimeRepository, runtimeRef))
87
91
  }
@@ -133,6 +137,21 @@ export function syncRepository(options) {
133
137
  return { changed, config }
134
138
  }
135
139
 
140
+ /** @param {string} target @param {string} sourceFile @returns {string} */
141
+ function renderReleaseConfig(target, sourceFile) {
142
+ let baseline = {}
143
+ try { baseline = JSON.parse(readFileSync(sourceFile, 'utf8')) }
144
+ catch { baseline = {} }
145
+ const destination = join(target, 'release-please-config.json')
146
+ let existing = baseline
147
+ if (existsSync(destination)) {
148
+ try { existing = JSON.parse(readFileSync(destination, 'utf8')) }
149
+ catch { existing = baseline }
150
+ }
151
+ const merged = { ...baseline, ...existing }
152
+ return `${JSON.stringify(buildReleaseConfig(target, merged), null, 2)}\n`
153
+ }
154
+
136
155
  /** @param {string} file @param {string} languages @param {string} features */
137
156
  function shouldInclude(file, languages, features) {
138
157
  if (file === 'ruff.toml') return includesValue(languages, 'python')
@@ -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
+ }
@@ -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 }