code-foundry 0.32.1 → 0.32.2

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.
@@ -16,8 +16,8 @@ release_type: node
16
16
  npm_publish: true
17
17
  license: agpl-3.0-or-later
18
18
  git_workflow: staging-release
19
- merge_strategy: merge
20
- release_merge_strategy: squash
19
+ merge_strategy: rebase
20
+ release_merge_strategy: rebase
21
21
  runner: ubuntu-latest
22
22
  unit_runner: ubuntu-slim
23
23
  cache_packages: auto
@@ -157,7 +157,7 @@ jobs:
157
157
  ### Validation
158
158
 
159
159
  - Required CI, test, security, and CodeQL checks must pass before merge.
160
- - Merge using the repository's configured \`merge_strategy\` (default: **merge**).
160
+ - Merge using the repository's configured \`merge_strategy\` (default: **rebase**).
161
161
  - After merge, Release Please opens a separate versioned release PR with the changelog.
162
162
  EOF
163
163
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.32.2](https://github.com/0xPlayerOne/code-foundry/compare/v0.32.1...v0.32.2) (2026-07-31)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release:** mirror staging onto main and require rebase merges on main ([31ab9b1](https://github.com/0xPlayerOne/code-foundry/commit/31ab9b125e66ae35f7d2a2c6893d0a3aec5d4560))
9
+ * **release:** synchronize staging with a linear commit instead of a blocked fast-forward ([b808dff](https://github.com/0xPlayerOne/code-foundry/commit/b808dffeae38ec8211f8f0dee79e19d60f70b708))
10
+
3
11
  ## [0.32.1](https://github.com/0xPlayerOne/code-foundry/compare/v0.32.0...v0.32.1) (2026-07-31)
4
12
 
5
13
 
package/docs/RELEASES.md CHANGED
@@ -30,16 +30,21 @@ Set these values in `.github/code-foundry.yml`:
30
30
  ```yaml
31
31
  release_type: auto # auto, node, python, rust, simple, or none
32
32
  npm_publish: false # true only for an npm package
33
- merge_strategy: merge # merge, squash, or rebase; used for promotion PRs
34
- release_merge_strategy: squash # merge, squash, or rebase; used for Release Please PRs
33
+ merge_strategy: rebase # merge, squash, or rebase; used for promotion PRs
34
+ release_merge_strategy: rebase # merge, squash, or rebase; used for Release Please PRs
35
35
  ```
36
36
 
37
- `merge_strategy` applies to promotion PRs (`staging` into `main`); merging
38
- those with a merge commit keeps `staging` an ancestor of `main`.
39
- `release_merge_strategy` applies to Release Please version PRs; squash-merging
40
- them keeps `main`'s tip linear so the post-release reconciliation can
41
- fast-forward `staging` to `main` without violating `staging`'s required-linear-
42
- history rule. Defaults to `merge` when unset.
37
+ `merge_strategy` applies to promotion PRs (`staging` into `main`) and
38
+ `release_merge_strategy` to Release Please version PRs; both default to
39
+ `merge` when unset. Use **rebase** for both. Rebase merges keep `main` fully
40
+ linear, which is what makes the post-release reconciliation possible:
41
+ `staging` requires linear history, and any merge commit on `main` becomes an
42
+ uncrossable barrier for `staging` (the linear-history rule rejects pushes
43
+ whose new commits contain merge commits). With a linear `main`, the release
44
+ workflow mirrors `staging` onto `main`'s tip after every release — a verified
45
+ fast-forward when possible, otherwise a forced linear update, otherwise a
46
+ single-parent synchronization commit — so `staging` is never left behind and
47
+ never needs a manual rebase.
43
48
 
44
49
  `auto` selects a supported manifest. Use `simple` with `version.txt` for a
45
50
  repository without a package manifest and `none` for a repository that should
@@ -71,6 +76,6 @@ the generated version pull request before using the token to merge it.
71
76
 
72
77
  1. Merge tested changes from `staging` into `main`.
73
78
  2. Review the generated Release Please PR and changelog.
74
- 3. Merge the release PR with the repository.s configured `release_merge_strategy` (default `merge`, recommended `squash`).
79
+ 3. Merge the release PR with the repository's configured `release_merge_strategy` (default `merge`, recommended `rebase`).
75
80
  4. Confirm the GitHub Release and any package publication.
76
81
  5. Synchronize `staging` with the new `main` release commit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.32.1",
3
+ "version": "0.32.2",
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",
@@ -10,8 +10,9 @@ import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib
10
10
 
11
11
  /**
12
12
  * Reconcile staging after Release Please updates main. The local mode is
13
- * deterministic and suitable for CI; --github applies only a verified
14
- * fast-forward through the GitHub API and otherwise fails closed.
13
+ * deterministic and suitable for CI; --github mirrors staging onto main's
14
+ * tip through the GitHub API (fast-forward, then forced, then a linear sync
15
+ * commit) and otherwise fails closed.
15
16
  * @param {string} root
16
17
  * @param {ReleaseOptions} options
17
18
  */
@@ -38,55 +39,31 @@ export function reconcileRelease(root, options) {
38
39
  })
39
40
  console.log(JSON.stringify({ base, head, ...plan }, null, 2))
40
41
  if (plan.action === 'fail') throw new Error(plan.reason + (plan.unexpected?.length ? ` Unexpected paths: ${plan.unexpected.join(', ')}` : ''))
41
- if (plan.action === 'pull-request' && options.github && !options.dryRun) {
42
- if (!process.env.GITHUB_REPOSITORY) throw new Error('GITHUB_REPOSITORY is required for synchronization PRs.')
43
- const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
44
- if (!token) throw new Error('GH_TOKEN or GITHUB_TOKEN is required for synchronization PRs.')
45
- const existing = ghJson(target, ['pr', 'list', '--repo', process.env.GITHUB_REPOSITORY, '--state', 'open', '--base', head, '--search', `chore(release): synchronize ${base} -> ${head} in:title`, '--json', 'number,url,headRefName'])
46
- if (Array.isArray(existing) && existing.length) {
47
- return { ...plan, synchronizationPullRequest: existing[0].url ?? existing[0].number }
48
- }
49
- const synchronizationPullRequest = createSynchronizationPullRequest(target, process.env.GITHUB_REPOSITORY, base, head, mainSha, stagingSha, mainChangedPaths, token)
50
- return { ...plan, synchronizationPullRequest }
51
- }
52
- if (plan.action !== 'fast-forward' || !options.github || options.dryRun) return plan
42
+ if (!['fast-forward', 'pull-request', 'aligned'].includes(plan.action) || !options.github || options.dryRun) return plan
53
43
  if (!process.env.GITHUB_REPOSITORY) throw new Error('GITHUB_REPOSITORY is required for --github reconciliation.')
54
44
  const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
55
45
  if (!token) throw new Error('GH_TOKEN or GITHUB_TOKEN is required for --github reconciliation.')
56
- const result = spawnSync('gh', [
46
+ const targetSha = /** @type {string} */ (plan.targetSha)
47
+ // 1. Verified fast-forward through the API (linear, no force).
48
+ const fastForward = spawnSync('gh', [
57
49
  'api', '--method', 'PATCH', `repos/${process.env.GITHUB_REPOSITORY}/git/refs/heads/${head}`,
58
- '-f', `sha=${plan.targetSha}`, '-F', 'force=false',
50
+ '-f', `sha=${targetSha}`, '-F', 'force=false',
59
51
  ], { cwd: target, stdio: 'inherit', env: { ...process.env, GH_TOKEN: token } })
60
- if (result.status !== 0) throw new Error(`GitHub refused the protected fast-forward of ${head}.`)
61
- return plan
62
- }
63
-
64
- /** @param {string} root @param {string} repository @param {string} base @param {string} head @param {string} mainSha @param {string} stagingSha @param {string[]} changedPaths @param {string} token */
65
- function createSynchronizationPullRequest(root, repository, base, head, mainSha, stagingSha, changedPaths, token) {
66
- const branch = `codex/release-sync-${head}-${mainSha.slice(0, 12)}`
67
- const remote = spawnSync('git', ['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${branch}`], { cwd: root, encoding: 'utf8' })
68
- if (remote.status !== 0) {
69
- const checkout = spawnSync('git', ['switch', '--create', branch, `origin/${head}`], { cwd: root, encoding: 'utf8' })
70
- if (checkout.status !== 0) throw new Error(`Failed to create synchronization branch: ${checkout.stderr.trim()}`)
71
- const patch = spawnSync('git', ['diff', '--binary', stagingSha, mainSha, '--', ...changedPaths], { cwd: root, encoding: 'utf8' })
72
- if (patch.status !== 0) throw new Error(`Failed to prepare synchronization patch: ${patch.stderr.trim()}`)
73
- const apply = spawnSync('git', ['apply', '--whitespace=nowarn'], { cwd: root, input: patch.stdout, encoding: 'utf8' })
74
- if (apply.status !== 0) throw new Error(`Synchronization patch did not apply cleanly: ${apply.stderr.trim()}`)
75
- const add = spawnSync('git', ['add', '--', ...changedPaths], { cwd: root, encoding: 'utf8' })
76
- if (add.status !== 0) throw new Error(`Failed to stage synchronization metadata: ${add.stderr.trim()}`)
77
- const identity = spawnSync('git', ['-c', 'user.name=github-actions[bot]', '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com', 'commit', '-m', `chore(release): synchronize ${base} -> ${head}`], { cwd: root, encoding: 'utf8' })
78
- if (identity.status !== 0) throw new Error(`Failed to commit synchronization metadata: ${identity.stderr.trim()}`)
79
- const push = spawnSync('git', ['push', '--set-upstream', 'origin', branch], { cwd: root, encoding: 'utf8', env: { ...process.env, GH_TOKEN: token } })
80
- if (push.status !== 0) throw new Error(`Failed to publish synchronization branch: ${push.stderr.trim()}`)
81
- }
82
- const result = spawnSync('gh', [
83
- 'pr', 'create', '--repo', repository,
84
- '--base', head, '--head', branch,
85
- '--title', `chore(release): synchronize ${base} -> ${head}`,
86
- '--body', `Synchronize ${head} with ${base} after Release Please publication.\n\nCode Foundry verified that only approved release metadata differs between the branch tips, then applied that metadata to an isolated synchronization branch.`,
87
- ], { cwd: resolve(root), encoding: 'utf8', env: { ...process.env, GH_TOKEN: token } })
88
- if (result.status !== 0) throw new Error(`Failed to create synchronization PR: ${result.stderr.trim()}`)
89
- return result.stdout.trim()
52
+ if (fastForward.status === 0) return { ...plan, synchronization: 'fast-forward' }
53
+ // 2. Forced update when staging may be rewritten without violating the
54
+ // linear-history rule (main's tip is linear and already contains staging's
55
+ // content, so nothing unpromoted is lost).
56
+ const forced = spawnSync('gh', [
57
+ 'api', '--method', 'PATCH', `repos/${process.env.GITHUB_REPOSITORY}/git/refs/heads/${head}`,
58
+ '-f', `sha=${targetSha}`, '-F', 'force=true',
59
+ ], { cwd: target, stdio: 'inherit', env: { ...process.env, GH_TOKEN: token } })
60
+ if (forced.status === 0) return { ...plan, synchronization: 'forced' }
61
+ // 3. Linear sync commit fallback: staging keeps its history and receives
62
+ // main's tree as a single-parent commit, which the ruleset always accepts.
63
+ const syncSha = createLinearSyncCommit(target, stagingSha, targetSha)
64
+ const push = spawnSync('git', ['push', 'origin', `${syncSha}:refs/heads/${head}`], { cwd: target, stdio: 'inherit', env: { ...process.env, GH_TOKEN: token } })
65
+ if (push.status !== 0) throw new Error(`GitHub refused the linear synchronization of ${head}.`)
66
+ return { ...plan, syncSha, synchronization: 'linear-commit' }
90
67
  }
91
68
 
92
69
  /**
@@ -170,6 +147,24 @@ function git(root, args) {
170
147
  return result.status === 0 ? result.stdout.trim() : ''
171
148
  }
172
149
 
150
+ /**
151
+ * Build a linear commit whose tree equals main's tip and whose parent is the
152
+ * current staging tip. Staging requires linear history and main's tip almost
153
+ * always sits behind promotion merge commits, so a direct fast-forward to
154
+ * main's tip is rejected by the branch ruleset ("must not contain merge
155
+ * commits"). A single-parent commit is always pushable and keeps staging
156
+ * content-identical to main.
157
+ * @param {string} root @param {string} parentSha @param {string} mainSha @returns {string}
158
+ */
159
+ function createLinearSyncCommit(root, parentSha, mainSha) {
160
+ const tree = git(root, ['rev-parse', `${mainSha}^{tree}`])
161
+ if (!tree) throw new Error('Failed to resolve the target tree.')
162
+ const identity = ['-c', 'user.name=github-actions[bot]', '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com']
163
+ const result = spawnSync('git', [...identity, 'commit-tree', tree, '-p', parentSha, '-m', 'chore(release): synchronize staging with main'], { cwd: resolve(root), encoding: 'utf8' })
164
+ if (result.status !== 0) throw new Error(`Failed to create the synchronization commit: ${result.stderr.trim()}`)
165
+ return result.stdout.trim()
166
+ }
167
+
173
168
  /** @param {string} root @param {string} from @param {string} to @returns {string[]} */
174
169
  function diffNames(root, from, to) {
175
170
  if (!from || !to || from === to) return []
@@ -364,7 +364,7 @@ function createDefaultConfig(root, source) {
364
364
  post_release: 'false', post_release_workflow: '', post_release_mode: 'auto',
365
365
  opencode_security: 'false',
366
366
  sync_mode: 'overlay', custom_workflows: 'preserve',
367
- license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'staging-release', merge_strategy: 'merge', release_merge_strategy: 'squash',
367
+ license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'staging-release', merge_strategy: 'rebase', release_merge_strategy: 'rebase',
368
368
  }
369
369
  }
370
370
 
@@ -186,7 +186,7 @@ export function classifyReconciliation(input) {
186
186
  return { action: 'none', reason: 'staging contains commits that are not on main; no release-only reconciliation is needed.' }
187
187
  }
188
188
  if (!mainChangedPaths.length && !stagingChangedPaths.length) {
189
- return { action: 'aligned', reason: 'Branches have different history but identical content.' }
189
+ return { action: 'aligned', targetSha: mainSha, reason: 'Branches have different history but identical content.' }
190
190
  }
191
191
  const unexpectedMain = unexpectedReleasePaths(mainChangedPaths, allowed)
192
192
  const unexpectedStaging = unexpectedReleasePaths(stagingChangedPaths, allowed)