code-foundry 0.35.0 → 0.36.1

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.
@@ -7,6 +7,12 @@ updates:
7
7
  interval: weekly
8
8
  day: sunday
9
9
  open-pull-requests-limit: 99
10
+ ignore:
11
+ # Code Foundry runtime pins are sync-managed (runtime_ref in
12
+ # code-foundry.yml). Dependabot must never bump them; a stale
13
+ # github-actions group PR branched before a sync can resurrect
14
+ # legacy caller files when merged.
15
+ - dependency-name: "0xPlayerOne/code-foundry*"
10
16
  groups:
11
17
  github-actions:
12
18
  applies-to: version-updates
@@ -8,6 +8,11 @@ on:
8
8
  required: false
9
9
  type: string
10
10
  default: ubuntu-slim
11
+ base:
12
+ description: Base branch for the draft pull request (staging for the staging-release topology, main for the direct topology).
13
+ required: false
14
+ type: string
15
+ default: staging
11
16
  secrets:
12
17
  CODE_FOUNDRY_TOKEN:
13
18
  required: false
@@ -34,7 +39,7 @@ jobs:
34
39
  GH_TOKEN: ${{ github.token }}
35
40
  run: |
36
41
  BRANCH="$HEAD_REF"
37
- EXISTING=$(gh pr list --repo "$GITHUB_REPOSITORY" --limit 1 --base staging --head "$BRANCH" --state open --json number,title 2>/dev/null | node -e 'let d=""; process.stdin.on("data", c => d += c).on("end", () => console.log(JSON.parse(d || "[]").length))')
42
+ EXISTING=$(gh pr list --repo "$GITHUB_REPOSITORY" --limit 1 --base "${{ inputs.base }}" --head "$BRANCH" --state open --json number,title 2>/dev/null | node -e 'let d=""; process.stdin.on("data", c => d += c).on("end", () => console.log(JSON.parse(d || "[]").length))')
38
43
  echo "existing=$EXISTING" >> "$GITHUB_OUTPUT"
39
44
 
40
45
  - name: Create
@@ -86,7 +91,7 @@ jobs:
86
91
  CREATE_ARGS=(
87
92
  "repos/${GITHUB_REPOSITORY}/pulls"
88
93
  --method POST
89
- --field base=staging
94
+ --field base="${{ inputs.base }}"
90
95
  --field head="$BRANCH"
91
96
  --field title="$PR_TITLE"
92
97
  --field body="@$BODY_FILE"
@@ -21,6 +21,7 @@ jobs:
21
21
  uses: ./.github/workflows/draft-pr.yml
22
22
  with:
23
23
  runner: ubuntu-slim
24
+ base: staging
24
25
  secrets:
25
26
  CODE_FOUNDRY_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN }}
26
27
  RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.36.1](https://github.com/0xPlayerOne/code-foundry/compare/v0.36.0...v0.36.1) (2026-08-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **sync:** prettier-canonical direct doc tables + dependabot ignore for runtime pins ([f4286b6](https://github.com/0xPlayerOne/code-foundry/commit/f4286b62ac06145b250d02ede9421f36df72a344))
9
+
10
+ ## [0.36.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.35.0...v0.36.0) (2026-08-07)
11
+
12
+
13
+ ### Features
14
+
15
+ * make staging-release opt-in and direct-to-main the default ([#370](https://github.com/0xPlayerOne/code-foundry/issues/370)) ([7c66507](https://github.com/0xPlayerOne/code-foundry/commit/7c665071769f0b0332e2f47929ee0c19bbfd5af3))
16
+
3
17
  ## [0.35.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.34.14...v0.35.0) (2026-08-04)
4
18
 
5
19
 
package/README.md CHANGED
@@ -68,8 +68,8 @@ when present and otherwise uses native language tooling; choose `native` or
68
68
 
69
69
  The standard workflow triggers are:
70
70
 
71
- - Pushes to `main` and `staging`.
72
- - Pull requests targeting `staging`.
71
+ - Pushes to `main` (and `staging` when `git_workflow: staging-release` is configured).
72
+ - Pull requests targeting `main` (and `staging` in the staging-release topology).
73
73
  - Draft PR automation for supported feature/fix branches.
74
74
 
75
75
  Jobs are language-aware and skip irrelevant setup while remaining visible as
@@ -88,20 +88,25 @@ GitHub Code Quality or other paid GitHub features.
88
88
  See [Workflow and CI conventions](docs/WORKFLOWS.md) for triggers, required
89
89
  checks, runners, coverage, caching, and custom workflow extensions.
90
90
 
91
- The contribution policy uses the `staging-release` workflow: feature PRs squash
92
- into `staging`, the promotion PR rebases into `main` (`merge_strategy: rebase`),
93
- and Release Please version PRs rebase into `main` (`release_merge_strategy:
94
- rebase`). Release automation never defaults to a merge method and never merges
95
- with `--admin`; `code-foundry doctor` and `code-foundry sync` fail closed on
96
- any other strategy. GitHub Stacks is not part of this topology and does not
97
- reduce the required workflow runs.
91
+ The contribution policy defaults to the `direct` workflow: feature PRs squash
92
+ into `main`, and Release Please version PRs rebase into `main`
93
+ (`release_merge_strategy: rebase`). Repositories with a preview/staging
94
+ environment opt into `git_workflow: staging-release`, where feature PRs squash
95
+ into `staging`, the promotion PR rebases into `main` (`merge_strategy:
96
+ rebase`), and Release Please version PRs rebase into `main`. Release automation
97
+ never defaults to a merge method and never merges with `--admin`;
98
+ `code-foundry doctor` and `code-foundry sync` fail closed on any other
99
+ strategy. GitHub Stacks is not part of this topology and does not reduce the
100
+ required workflow runs.
98
101
 
99
102
  ## Releases and publishing
100
103
 
101
- The standard flow promotes `staging` into `main`, lets Release Please open a
102
- versioned release PR, and creates a GitHub release after that PR is merged.
103
- npm publication is opt-in through `npm_publish: true` and supports npm trusted
104
- publishing or an `NPM_TOKEN` fallback.
104
+ In the default `direct` flow, changes reach `main` through feature pull
105
+ requests and Release Please opens a versioned release PR against `main`. In
106
+ the `staging-release` flow, a promotion PR promotes `staging` into `main`
107
+ first. Either way, Release Please creates a GitHub release after the version
108
+ PR is merged. npm publication is opt-in through `npm_publish: true` and
109
+ supports npm trusted publishing or an `NPM_TOKEN` fallback.
105
110
 
106
111
  Read [Release management](docs/RELEASES.md) and
107
112
  [Publishing packages](docs/PUBLISHING.md) before enabling automated
@@ -47,14 +47,37 @@ repository manifests and source
47
47
  | `release_type` | `node`, `python`, `rust`, `simple`, `none` | Release strategy |
48
48
  | `npm_publish` | `true` or `false` | Opt into npm publication |
49
49
  | `license` | `gpl-3.0-or-later`, `agpl-3.0-or-later`, `apache-2.0`, `mit`, `preserve`, `none` | License policy; new repositories default to GPLv3 |
50
- | `git_workflow` | `staging-release` | Branch/release model; the standard model promotes `staging` into `main` |
51
- | `merge_strategy` | `rebase` | Promotion merge method for `staging` → `main`; the staging-release topology requires rebase |
50
+ | `git_workflow` | `direct` (default), `staging-release` | Branch/release model; `direct` opens feature branches into `main`, `staging-release` promotes `staging` into `main` |
51
+ | `merge_strategy` | `rebase` | Promotion merge method for `staging` → `main`; only enforced by the `staging-release` topology |
52
52
  | `release_merge_strategy` | `rebase` | Merge method for Release Please version PRs into `main`; release automation fails closed unless rebase |
53
53
  | `runner` fields | GitHub runner names | Per-workflow runner policy |
54
54
 
55
55
  Supported features are `ci`, `codeql`, `security`, `test`, `draft-pr`,
56
56
  `release-pr`, `release`, and `dependabot`.
57
57
 
58
+ ## Git workflow
59
+
60
+ `git_workflow` selects the branch topology:
61
+
62
+ - `direct` (default): feature branches open pull requests directly into
63
+ `main`. Validation and security scans run on every PR. No `staging` branch
64
+ exists, no promotion caller is generated, and `merge_strategy` is not
65
+ enforced. Dependabot updates target `main`. This is the right choice when a
66
+ repository has no preview or staging environment.
67
+ - `staging-release` (opt-in): feature branches squash into `staging`, a
68
+ promotion PR rebases validated changes into `main` (`merge_strategy:
69
+ rebase`), and Release Please version PRs rebase into `main`
70
+ (`release_merge_strategy: rebase`). Choose this only when the repository
71
+ maintains a preview/staging environment that needs validated integration
72
+ before release.
73
+
74
+ ```yaml
75
+ # A repository with a preview/staging environment
76
+ git_workflow: staging-release
77
+ ```
78
+
79
+ Any other value is rejected by `code-foundry sync` and `code-foundry doctor`.
80
+
58
81
  ## Editing workflow
59
82
 
60
83
  `init` creates the file and renders the baseline. `sync` reads the file and
package/docs/RELEASES.md CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  ## Branch flow
4
4
 
5
- The standard environment flow is:
5
+ The default `direct` flow is:
6
+
7
+ ```text
8
+ topic branch -> main -> versioned release
9
+ ```
10
+
11
+ Repositories that maintain a preview/staging environment opt into the
12
+ `staging-release` flow:
6
13
 
7
14
  ```text
8
15
  topic branch -> staging -> main -> versioned release
@@ -12,13 +19,15 @@ Keep commits Conventional Commit-shaped (`feat:`, `fix:`, `docs:`, `ci:`,
12
19
  `chore:`, and so on). Release Please uses them to select patch/minor/major
13
20
  versions and generate grouped changelog notes.
14
21
 
15
- The merge audit pins one merge method per transition in this topology. Feature
16
- and fix branches land on `staging` with **squash** merges, the `staging` → `main`
17
- promotion PR merges with **rebase** (`merge_strategy: rebase`), and Release
18
- Please version PRs merge with **rebase** (`release_merge_strategy: rebase`).
19
- Release automation never defaults to a merge method and never merges with
20
- `--admin`: the release workflow fails closed unless `release_merge_strategy` is
21
- exactly `rebase`. The current supported `git_workflow` is `staging-release`.
22
+ The merge audit pins one merge method per transition. In the `staging-release`
23
+ topology, feature and fix branches land on `staging` with **squash** merges,
24
+ the `staging` → `main` promotion PR merges with **rebase** (`merge_strategy:
25
+ rebase`), and Release Please version PRs merge with **rebase**
26
+ (`release_merge_strategy: rebase`). In the `direct` topology, feature and fix
27
+ branches squash straight into `main` and only Release Please version PRs merge
28
+ with **rebase**; `merge_strategy` is not enforced. Release automation never
29
+ defaults to a merge method and never merges with `--admin`: the release
30
+ workflow fails closed unless `release_merge_strategy` is exactly `rebase`.
22
31
 
23
32
  The release workflow opens or updates a versioned PR after changes reach
24
33
  `main`. Merging that PR updates the changelog, creates the Git tag and GitHub
@@ -34,27 +43,32 @@ Set these values in `.github/code-foundry.yml`:
34
43
  ```yaml
35
44
  release_type: auto # auto, node, python, rust, simple, or none
36
45
  npm_publish: false # true only for an npm package
37
- merge_strategy: rebase # required: staging -> main promotion PRs rebase
46
+ git_workflow: direct # direct (default) or staging-release
47
+ merge_strategy: rebase # staging-release only: staging -> main promotion PRs rebase
38
48
  release_merge_strategy: rebase # required: Release Please version PRs rebase only
39
49
  ```
40
50
 
41
- `merge_strategy` applies to promotion PRs (`staging` into `main`) and
42
- `release_merge_strategy` to Release Please version PRs; feature PRs into
43
- `staging` use squash merges. The topology requires `merge_strategy: rebase`
44
- and `release_merge_strategy: rebase`; `code-foundry doctor` and
45
- `code-foundry sync` reject any other value, and the release workflow fails
46
- closed instead of falling back to `merge`. Both keep `main` fully linear,
47
- which is what makes the post-release reconciliation possible: release-only
48
- main commits cannot be discarded because they are allowed metadata-only, and
49
- all non-metadata drift is rejected before mutation.
50
-
51
- Patch-equivalent divergence between `main` and `staging` is treated as aligned.
52
- When `staging` has pending commits that are not yet represented on `main`, the
53
- release workflow replays those staging-only commits in order onto a detached
54
- worktree rooted at `main`, and then updates `staging` with an exact
51
+ `git_workflow: staging-release` is opt-in; without it, repositories use the
52
+ `direct` flow and no promotion PR exists. When the staging-release topology is
53
+ selected, `merge_strategy` applies to promotion PRs (`staging` into `main`)
54
+ and `release_merge_strategy` to Release Please version PRs; feature PRs into
55
+ `staging` use squash merges. `code-foundry doctor` and `code-foundry sync`
56
+ reject any non-`rebase` `merge_strategy` only when `staging-release` is
57
+ configured, and always reject a non-`rebase` `release_merge_strategy`; the
58
+ release workflow fails closed instead of falling back to `merge`. Both keep
59
+ `main` fully linear, which is what makes the post-release reconciliation
60
+ possible: release-only main commits cannot be discarded because they are
61
+ allowed metadata-only, and all non-metadata drift is rejected before mutation.
62
+
63
+ The `staging` `main` reconciliation exists only in the `staging-release`
64
+ topology. Patch-equivalent divergence between `main` and `staging` is treated
65
+ as aligned. When `staging` has pending commits that are not yet represented on
66
+ `main`, the release workflow replays those staging-only commits in order onto a
67
+ detached worktree rooted at `main`, and then updates `staging` with an exact
55
68
  `--force-with-lease` to prevent
56
69
  unintended branch rewrites. There is no unconditional mirror force-push and no
57
- fallback synchronization commit path.
70
+ fallback synchronization commit path. `direct` repositories skip this step
71
+ entirely.
58
72
 
59
73
  `auto` selects a supported manifest. Use `simple` with `version.txt` for a
60
74
  repository without a package manifest and `none` for a repository that should
@@ -101,8 +115,8 @@ already passed).
101
115
 
102
116
  ## Operational checklist
103
117
 
104
- 1. Merge tested changes from `staging` into `main`.
118
+ 1. Merge tested changes into `main` (direct: feature PRs; staging-release: promote `staging` into `main`).
105
119
  2. Review the generated Release Please PR and changelog.
106
120
  3. Merge the release PR with the repository's configured `release_merge_strategy` (**rebase**; the release workflow fails closed on any other value).
107
121
  4. Confirm the GitHub Release and any package publication.
108
- 5. Synchronize `staging` with the new `main` release commit.
122
+ 5. staging-release only: synchronize `staging` with the new `main` release commit.
package/docs/WORKFLOWS.md CHANGED
@@ -7,18 +7,22 @@ points; it does not run the same suites again on branch pushes:
7
7
 
8
8
  ```yaml
9
9
  pull_request:
10
- branches: [main, staging]
10
+ branches: [main, staging] # staging-release topology
11
+ # direct topology: branches: [main]
11
12
  schedule:
12
13
  - cron: '31 6 * * 1'
13
14
  workflow_dispatch:
14
15
  ```
15
16
 
16
- Pull requests into `staging` run the fast tier, ordinary pull requests into
17
- `main` run the full audit tier, and exact Release Please pull requests into
18
- `main` run only release policy. Scheduled and manual runs select the audit
19
- tier. Draft PR automation separately listens to supported topic-branch pushes,
20
- promotion automation listens to `staging` pushes, and release automation
21
- listens to `main` pushes.
17
+ In the `staging-release` topology, pull requests into `staging` run the fast
18
+ tier, ordinary pull requests into `main` run the full audit tier, and exact
19
+ Release Please pull requests into `main` run only release policy. In the
20
+ `direct` topology (the default) every pull request targets `main` and runs the
21
+ full audit tier, because there is no integration branch for a fast pass.
22
+ Scheduled and manual runs select the audit tier in both topologies. Draft PR
23
+ automation separately listens to supported topic-branch pushes, promotion
24
+ automation listens to `staging` pushes (staging-release only), and release
25
+ automation listens to `main` pushes.
22
26
  Custom deployment, indexing, search, Slither, or other workflows are
23
27
  repository-owned extensions and should keep their own triggers and permissions.
24
28
 
@@ -31,7 +35,7 @@ repository-owned extensions and should keep their own triggers and permissions.
31
35
  | Security | Profile, audits, and public-only Dependency Review |
32
36
  | CodeQL | GitHub-native code scanning, kept separate from CI |
33
37
  | Draft PR | Create/update development pull requests |
34
- | Release PR | Promote `staging` into `main` |
38
+ | Release PR | Promote `staging` into `main` (staging-release topology only) |
35
39
  | Release | Release Please, GitHub release, and optional npm publication |
36
40
 
37
41
  Use concise job names such as `CI / Format`, `Test / Unit`, and
@@ -46,10 +50,15 @@ workflow refuses to run unless its strategy is exactly `rebase`.
46
50
 
47
51
  | Transition | Merge method | Enforcement |
48
52
  | --- | --- | --- |
49
- | Feature/fix PR into `staging` | Squash | Contribution policy; see `CONTRIBUTING.md` |
50
- | `staging` `main` promotion PR | Rebase (`merge_strategy: rebase`) | `merge_strategy` must be `rebase`; merge commits are rejected |
53
+ | Feature/fix PR into `main` (direct topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
54
+ | Feature/fix PR into `staging` (staging-release topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
55
+ | `staging` → `main` promotion PR (staging-release topology) | Rebase (`merge_strategy: rebase`) | `merge_strategy` must be `rebase` when `git_workflow: staging-release`; merge commits are rejected |
51
56
  | Release Please version PR into `main` | Rebase (`release_merge_strategy: rebase`) | Release automation fails closed unless `rebase`; never defaults to `merge`, never uses `--admin` |
52
57
 
58
+ The promotion rows above apply only to `staging-release`; `direct`
59
+ repositories never generate a promotion caller and `merge_strategy` is not
60
+ enforced for them.
61
+
53
62
  Release auto-merge waits for required checks and then polls `mergeStateStatus`
54
63
  until it is `CLEAN`, or `UNSTABLE` with `mergeable` `MERGEABLE`, before
55
64
  merging. Non-required checks that branch policy does not require (for example
@@ -60,10 +69,13 @@ merge policy-blocked. The mergeability poll is bounded and fails closed on
60
69
  conflicts or timeout; releases without an automation token remain manual.
61
70
 
62
71
  Keeping `main` linear — rebase promotions and rebase release PRs — is what
63
- lets the post-release reconciliation fast-forward or replay `staging` safely.
72
+ lets the post-release reconciliation fast-forward or replay `staging` safely
73
+ in the `staging-release` topology. `direct` repositories have no reconciliation
74
+ step: releases merge straight into `main` with `release_merge_strategy: rebase`.
64
75
 
65
- Protect `staging` with the aggregate `Validation / Gate`, squash-only pull
66
- requests, and a single GitHub Actions integration path. That path uses the
76
+ Protect `main` with the aggregate `Validation / Gate` and squash-only pull
77
+ requests. In the `staging-release` topology, protect `staging` the same way
78
+ with a single GitHub Actions integration path. That path uses the
67
79
  GitHub Actions integration token by default, and optionally an SSH deploy key
68
80
  when `STAGING_DEPLOY_KEY` is configured. The deploy key is required only when
69
81
  a personal-repository ruleset for `staging` enforces a Deploy Key bypass for
@@ -99,8 +111,8 @@ GitHub Stacks (stacked pull requests) is not part of this topology and does
99
111
  not reduce required workflow runs. Every pull request in a stack still
100
112
  triggers its own validation run, and each branch keeps its own required
101
113
  checks; stacking never collapses or skips a required check in the tiered
102
- validation gate. Land changes through the standard `staging-release` flow
103
- instead.
114
+ validation gate. Land changes through the standard `direct` or
115
+ `staging-release` flow instead.
104
116
 
105
117
  ## Language defaults
106
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
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",
@@ -70,16 +70,21 @@ export function doctor(root, options = {}) {
70
70
  }
71
71
 
72
72
  const features = config.features ?? 'all'
73
+ const workflow = config.git_workflow ?? 'direct'
74
+ if (!['direct', 'staging-release'].includes(workflow)) {
75
+ error(`unsupported git_workflow: ${workflow}; use direct or staging-release`)
76
+ }
73
77
  const mergeStrategy = config.merge_strategy ?? 'rebase'
74
- if (mergeStrategy !== 'rebase') {
78
+ if (workflow === 'staging-release' && mergeStrategy !== 'rebase') {
75
79
  error(`merge_strategy must be "rebase" for the staging-release promotion topology; got "${mergeStrategy}".`)
76
80
  }
77
81
  const releaseMergeStrategy = config.release_merge_strategy ?? ''
78
82
  if (includesValue(features, 'release') && releaseMergeStrategy !== 'rebase') {
79
83
  error(`release_merge_strategy must be "rebase" for automated release merges; got "${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}".`)
80
84
  }
81
- for (const workflow of ['validation', 'draft-pr', 'release-pr', 'release']) {
82
- if (includesValue(features, workflow) && !existsSync(join(target, `.github/workflows/${workflow}.yml`))) error(`missing enabled workflow: ${workflow}.yml`)
85
+ for (const name of ['validation', 'draft-pr', 'release-pr', 'release']) {
86
+ if (name === 'release-pr' && workflow !== 'staging-release') continue
87
+ if (includesValue(features, name) && !existsSync(join(target, `.github/workflows/${name}.yml`))) error(`missing enabled workflow: ${name}.yml`)
83
88
  }
84
89
  const validationEnabled = includesValue(features, 'validation') || ['ci', 'test', 'security', 'codeql'].some((legacy) => includesValue(features, legacy))
85
90
  const validationCaller = ['validation.yml', 'validation_self-ci.yml']
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
6
6
  import { spawnSync } from 'node:child_process'
7
7
  import { syncRepository } from './sync.mjs'
8
8
 
9
- /** @typedef {{ path: string, repository: string, runtimeRef: string, dirty: boolean, configured: boolean }} FleetRepository */
9
+ /** @typedef {{ path: string, repository: string, runtimeRef: string, dirty: boolean, configured: boolean, gitWorkflow: string }} FleetRepository */
10
10
 
11
11
  /** @param {string} root @returns {FleetRepository[]} */
12
12
  export function discoverRepositories(root) {
@@ -24,6 +24,7 @@ export function discoverRepositories(root) {
24
24
  runtimeRef: config.runtime_ref ?? '',
25
25
  dirty: Boolean(git(candidate, ['status', '--porcelain'])),
26
26
  configured,
27
+ gitWorkflow: config.git_workflow ?? 'direct',
27
28
  })
28
29
  }
29
30
  return result.sort((a, b) => a.path.localeCompare(b.path))
@@ -64,12 +65,13 @@ export function upgradeFleet(root, source, options) {
64
65
  /** @param {FleetRepository} repository @param {string} source @param {string} version */
65
66
  function upgradeRepository(repository, source, version) {
66
67
  const branch = `codex/code-foundry-upgrade-${version.replace(/^v/, '')}`
68
+ const base = repository.gitWorkflow === 'staging-release' ? 'staging' : 'main'
67
69
  const temporary = mkdtempSync(join(tmpdir(), 'code-foundry-fleet-'))
68
70
  try {
69
- const fetch = spawnSync('git', ['-C', repository.path, 'fetch', 'origin', 'staging', '--quiet'], { encoding: 'utf8' })
70
- if (fetch.status !== 0) return { path: repository.path, status: 'skipped', reason: fetch.stderr.trim() || 'unable to refresh staging baseline' }
71
- const baseline = spawnSync('git', ['-C', repository.path, 'rev-parse', 'origin/staging'], { encoding: 'utf8' })
72
- if (baseline.status !== 0) return { path: repository.path, status: 'skipped', reason: 'remote staging branch is unavailable' }
71
+ const fetch = spawnSync('git', ['-C', repository.path, 'fetch', 'origin', base, '--quiet'], { encoding: 'utf8' })
72
+ if (fetch.status !== 0) return { path: repository.path, status: 'skipped', reason: fetch.stderr.trim() || `unable to refresh ${base} baseline` }
73
+ const baseline = spawnSync('git', ['-C', repository.path, 'rev-parse', `origin/${base}`], { encoding: 'utf8' })
74
+ if (baseline.status !== 0) return { path: repository.path, status: 'skipped', reason: `remote ${base} branch is unavailable` }
73
75
  const add = spawnSync('git', ['-C', repository.path, 'worktree', 'add', '-b', branch, temporary, baseline.stdout.trim()], { encoding: 'utf8' })
74
76
  if (add.status !== 0) return { path: repository.path, status: 'skipped', reason: add.stderr.trim() || 'unable to create isolated worktree' }
75
77
  const result = syncRepository({ target: temporary, source, force: false })
@@ -90,7 +92,7 @@ function upgradeRepository(repository, source, version) {
90
92
  if (commit.status !== 0) return { path: repository.path, status: 'failed', reason: commit.stderr.trim() || 'commit failed' }
91
93
  const push = spawnSync('git', ['-C', temporary, 'push', '-u', 'origin', branch], { encoding: 'utf8' })
92
94
  if (push.status !== 0) return { path: repository.path, status: 'failed', reason: push.stderr.trim() || 'push failed' }
93
- 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' })
95
+ const pr = spawnSync('gh', ['pr', 'create', '--repo', repository.repository, '--base', base, '--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' })
94
96
  return pr.status === 0
95
97
  ? { path: repository.path, status: 'pr-created', branch, pullRequest: pr.stdout.trim() }
96
98
  : { path: repository.path, status: 'failed', branch, reason: pr.stderr.trim() || 'pull request creation failed' }
@@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
4
4
  import { dirname, join, resolve } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { detectLanguages, detectPackageManager, detectProfile, recommendRunners } from '../lib/profile.mjs'
7
- import { configured, includesValue, readConfig } from '../lib/config.mjs'
7
+ import { configured, gitWorkflow, includesValue, isStagingRelease, readConfig } from '../lib/config.mjs'
8
8
  import { buildReleaseConfig, buildReleaseManifest } from '../lib/release-manifest.mjs'
9
9
  import { customWorkflowFiles, overlayPolicy } from '../lib/overlay.mjs'
10
10
 
@@ -96,8 +96,12 @@ export function syncRepository(options) {
96
96
  if (!['auto', 'native', 'mise'].includes(toolchain)) {
97
97
  throw new Error(`Unsupported toolchain: ${toolchain}; use auto, native, or mise.`)
98
98
  }
99
+ const workflow = gitWorkflow(config.git_workflow)
100
+ if (!['direct', 'staging-release'].includes(workflow)) {
101
+ throw new Error(`Unsupported git_workflow: ${workflow}; use direct or staging-release.`)
102
+ }
99
103
  const mergeStrategy = configured(config.merge_strategy, 'rebase')
100
- if (mergeStrategy !== 'rebase') {
104
+ if (workflow === 'staging-release' && mergeStrategy !== 'rebase') {
101
105
  throw new Error(`Unsupported merge_strategy: ${mergeStrategy}; the staging-release topology requires rebase for staging to main promotions.`)
102
106
  }
103
107
  const releaseMergeStrategy = configured(config.release_merge_strategy, '')
@@ -141,6 +145,12 @@ export function syncRepository(options) {
141
145
  if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
142
146
  content = Buffer.from(renderWorkflow(content.toString('utf8'), config, runtimeRepository, runtimeRef, rustCodeql))
143
147
  }
148
+ if (file === '.github/dependabot.yml') {
149
+ content = Buffer.from(renderDependabot(content.toString('utf8'), config))
150
+ }
151
+ if (['AGENTS.md', '.github/CONTRIBUTING.md', '.github/SECURITY.md'].includes(file)) {
152
+ content = Buffer.from(renderContributionDocs(content.toString('utf8'), file, config))
153
+ }
144
154
  if (file === '.gitignore' && existsSync(destination)) {
145
155
  content = Buffer.from(mergeGitignore(content.toString('utf8'), readFileSync(destination, 'utf8')))
146
156
  }
@@ -165,6 +175,18 @@ export function syncRepository(options) {
165
175
  }
166
176
  }
167
177
 
178
+ // A repository that no longer opts into the staging-release topology must
179
+ // not keep a generated staging promotion caller that would otherwise
180
+ // linger dormant (it triggers on pushes to a branch that does not exist).
181
+ if (!isStagingRelease(config.git_workflow)) {
182
+ const promotion = join(target, '.github/workflows/release-pr.yml')
183
+ if (existsSync(promotion) && isGeneratedEventCaller(readFileSync(promotion, 'utf8'), 'release-pr', runtimeRepository)) {
184
+ changed.push('.github/workflows/release-pr.yml')
185
+ if (dryRun) console.log('Would remove generated release-pr caller; the direct topology targets pull requests at main.')
186
+ else rmSync(promotion, { force: true })
187
+ }
188
+ }
189
+
168
190
  const releaseManifest = buildReleaseManifest(target, mergeReleaseConfig(target, sourcePath(source, 'release-please-config.json')))
169
191
  if (releaseManifest) {
170
192
  const manifestPath = join(target, '.release-please-manifest.json')
@@ -267,6 +289,9 @@ function shouldInclude(file, languages, features, config) {
267
289
  if (file === '.github/dependabot.yml') return includesValue(features, 'dependabot')
268
290
  if (file === '.github/workflows/opencode-security.yml') return ['true', 'auto'].includes(config.opencode_security ?? 'false')
269
291
  const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
292
+ // The staging promotion caller only exists in the staging-release topology;
293
+ // direct repositories open feature branches into main and need no promotion.
294
+ if (workflow === 'release-pr' && !isStagingRelease(config.git_workflow)) return false
270
295
  // The tiered validation caller supersedes the legacy ci/test/security/codeql
271
296
  // event callers, so legacy feature names keep selecting it.
272
297
  if (workflow === 'validation') {
@@ -318,6 +343,18 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
318
343
  release: config.release_runner ?? config.runner,
319
344
  }
320
345
  const workflow = content.match(/\.github\/workflows\/([^/]+)\.yml/)?.[1]
346
+ // The staging-release topology validates and scans pull requests against
347
+ // both main and the integration branch; direct repositories only ever
348
+ // target main, so their callers trigger on main alone.
349
+ if (!isStagingRelease(config.git_workflow)) {
350
+ rendered = rendered.replace(/^(\s+branches:)\s*\[main,\s*staging\]\s*$/gm, `$1 [main]`)
351
+ }
352
+ if (workflow === 'draft-pr') {
353
+ // The draft PR caller states the PR base explicitly so the shared
354
+ // reusable workflow creates pull requests against the repository's
355
+ // configured integration branch (staging) or main (direct).
356
+ rendered = rendered.replace(/^(\s+base:)\s+.*$/m, `$1 ${isStagingRelease(config.git_workflow) ? 'staging' : 'main'}`)
357
+ }
321
358
  const runner = workflow ? runners[workflow] : undefined
322
359
  if (runner) rendered = rendered.replace(/^(\s+runner:)\s+.*$/m, `$1 ${runner}`)
323
360
  if (workflow === 'test' && config.unit_runner) {
@@ -348,6 +385,122 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
348
385
  return rendered
349
386
  }
350
387
 
388
+ /**
389
+ * Dependabot updates land on the repository's integration branch. Direct
390
+ * repositories have no staging branch, so every update targets main.
391
+ * @param {string} content
392
+ * @param {Record<string,string>} config
393
+ * @returns {string}
394
+ */
395
+ function renderDependabot(content, config) {
396
+ if (isStagingRelease(config.git_workflow)) return content
397
+ return content.replaceAll('target-branch: staging', 'target-branch: main')
398
+ }
399
+
400
+ /**
401
+ * Contribution policy documents describe the repository's branch flow. The
402
+ * canonical templates describe the staging-release topology (this runtime
403
+ * itself uses it); direct repositories render the equivalent main-targeting
404
+ * policy. The transformation is exact-string based so any template drift
405
+ * fails loudly (a missed replacement leaves staging prose intact) instead of
406
+ * producing a partial hybrid.
407
+ * @param {string} content
408
+ * @param {string} file
409
+ * @param {Record<string,string>} config
410
+ * @returns {string}
411
+ */
412
+ export function renderContributionDocs(content, file, config) {
413
+ if (isStagingRelease(config.git_workflow)) return content
414
+ const replacements = DIRECT_DOC_REPLACEMENTS[file]
415
+ if (!replacements) return content
416
+ let rendered = content
417
+ for (const [from, to] of replacements) {
418
+ if (!rendered.includes(from)) {
419
+ throw new Error(`Missing direct-workflow template marker in ${file}: ${JSON.stringify(from)}`)
420
+ }
421
+ rendered = rendered.replace(from, to)
422
+ }
423
+ return rendered
424
+ }
425
+
426
+ /** @type {Record<string, Array<[string, string]>>} */
427
+ const DIRECT_DOC_REPLACEMENTS = {
428
+ 'AGENTS.md': [
429
+ [
430
+ 'For normal feature work, branch from `staging` and target pull requests at `staging`. Treat `main` as the protected release branch.',
431
+ 'For normal feature work, branch from `main` and target pull requests at `main`. Treat `main` as the protected release branch.',
432
+ ],
433
+ [
434
+ 'Use `push` for `main, staging` and `pull_request` for `staging` unless a workflow has a documented event-specific reason.',
435
+ 'Use `push` for `main` and `pull_request` for `main` unless a workflow has a documented event-specific reason.',
436
+ ],
437
+ ],
438
+ '.github/CONTRIBUTING.md': [
439
+ [
440
+ '4. Branch from `staging` and target pull requests at `staging`; do not work directly on `main`.',
441
+ '4. Branch from `main` and target pull requests at `main`; do not push directly to `main`.',
442
+ ],
443
+ [
444
+ '```text\n release PR\n ┌──────────────┐\n │ ▼\nfeat/* fix/* chore/* ──PR──▶ staging ──PR──▶ main\ndocs/* test/* refactor/* │ │\n │ └── protected release branch\n └── integration branch\n```',
445
+ '```text\n release PR\n ┌──────────────┐\n │ ▼\nfeat/* fix/* chore/* ──PR──▶ main\ndocs/* test/* refactor/* │\n └── protected release branch\n```',
446
+ ],
447
+ [
448
+ '| Branch | Purpose | Contribution rule |\n| -------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------- |\n| `main` | Protected release branch | Merge through the `staging` → `main` release PR. No direct pushes. |\n| `staging` | Integration branch | Target normal pull requests here. Required checks must pass before merge. |\n| `feat/*`, `fix/*`, `chore/*`, `refactor/*`, `docs/*`, `test/*` | Focused work | Branch from `staging`; keep changes small and reviewable. |\n',
449
+ '| Branch | Purpose | Contribution rule |\n| -------------------------------------------------------------- | ------------------------ | ------------------------------------------------------ |\n| `main` | Protected release branch | Merge through pull requests only. No direct pushes. |\n| `feat/*`, `fix/*`, `chore/*`, `refactor/*`, `docs/*`, `test/*` | Focused work | Branch from `main`; keep changes small and reviewable. |\n',
450
+ ],
451
+ [
452
+ 'The Git workflow is `staging-release`: topic branches **squash** into `staging`, a promotion PR **rebases** validated changes into `main` (`merge_strategy: rebase`), and the Release Please version PR **rebases** into `main` (`release_merge_strategy: rebase`). Release automation never defaults to a merge method and never merges with `--admin`; `code-foundry doctor` and `code-foundry sync` fail closed on any other merge strategy. Re-align `staging` with `main` after a release when needed.',
453
+ 'The Git workflow is `direct`: topic branches **squash** directly into `main`, and the Release Please version PR **rebases** into `main` (`release_merge_strategy: rebase`). Release automation never defaults to a merge method and never merges with `--admin`; `code-foundry doctor` and `code-foundry sync` fail closed on any other release merge strategy. Feature branches never touch `staging`; repositories with a preview/staging environment opt into `git_workflow: staging-release` explicitly.',
454
+ ],
455
+ [
456
+ 'git switch staging\ngit pull --ff-only origin staging',
457
+ 'git switch main\ngit pull --ff-only origin main',
458
+ ],
459
+ [
460
+ '1. Start from an up-to-date `staging` branch.',
461
+ '1. Start from an up-to-date `main` branch.',
462
+ ],
463
+ [
464
+ '8. Push the branch and open a pull request into `staging`.',
465
+ '8. Push the branch and open a pull request into `main`.',
466
+ ],
467
+ [
468
+ '10. Merge with a squash after required checks pass and the change is ready; feature PRs land on `staging` with squash merges.',
469
+ '10. Merge with a squash after required checks pass and the change is ready; feature PRs land on `main` with squash merges.',
470
+ ],
471
+ [
472
+ '3. Branch from the upstream `staging` branch.',
473
+ '3. Branch from the upstream `main` branch.',
474
+ ],
475
+ [
476
+ '7. Push to the fork and open a pull request targeting `staging`.',
477
+ '7. Push to the fork and open a pull request targeting `main`.',
478
+ ],
479
+ [
480
+ '| Event | Expected automation |\n| --- | --- |\n| Pull request targeting `staging` | Fast validation: CI plus unit tests, ending in `Validation / Gate` |\n| Ordinary pull request targeting `main` | Audit validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |\n| Exact Release Please pull request targeting `main` | Release-policy validation only, ending in `Validation / Gate` |\n| Scheduled or manual validation | Full audit tier |\n| Push to a working branch | Draft PR workflow |\n| Push to `staging` | Promotion PR workflow; canonical validation waits for the PR event |\n| Push to `main` | Release workflow; canonical validation already ran on the merged PR |\n',
481
+ '| Event | Expected automation |\n| -------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| Pull request targeting `main` | Audit validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |\n| Exact Release Please pull request targeting `main` | Release-policy validation only, ending in `Validation / Gate` |\n| Scheduled or manual validation | Full audit tier |\n| Push to a working branch | Draft PR workflow |\n| Push to `main` | Release workflow; canonical validation already ran on the merged PR |\n',
482
+ ],
483
+ [
484
+ '| Change | Target | Merge method | Merge gate |\n| ----------------- | --------- | ------------------------------------------------------- | --------------------------------------------------------- |\n| Working branch | `staging` | Squash | All applicable required checks pass |\n| `staging` → `main` promotion | `main` | Rebase (`merge_strategy`) | Current staging checks, release review, and rollout notes |\n| Release Please version PR | `main` | Rebase (`release_merge_strategy`, fails closed) | Validation gate and release policy pass |\n',
485
+ '| Change | Target | Merge method | Merge gate |\n| ------------------------- | ------ | ----------------------------------------------- | --------------------------------------- |\n| Working branch | `main` | Squash | All applicable required checks pass |\n| Release Please version PR | `main` | Rebase (`release_merge_strategy`, fails closed) | Validation gate and release policy pass |\n',
486
+ ],
487
+ [
488
+ '1. Create a focused branch from `staging`.',
489
+ '1. Create a focused branch from `main`.',
490
+ ],
491
+ ],
492
+ '.github/SECURITY.md': [
493
+ [
494
+ 'The latest commit on `staging` receives security patches. Patches are promoted to `main` through the next release cycle.',
495
+ 'The latest commit on `main` receives security patches.',
496
+ ],
497
+ [
498
+ '| `staging` | ✅ |\n',
499
+ '',
500
+ ],
501
+ ],
502
+ }
503
+
351
504
  /** @param {Record<string,string>} config */
352
505
  function validateRustCodeqlConfig(config) {
353
506
  const threads = configured(config.codeql_rust_threads, '1')
@@ -475,7 +628,7 @@ function createDefaultConfig(root, source) {
475
628
  post_release: 'false', post_release_workflow: '', post_release_mode: 'auto',
476
629
  opencode_security: 'false',
477
630
  sync_mode: 'overlay', custom_workflows: 'preserve',
478
- license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'staging-release', merge_strategy: 'rebase', release_merge_strategy: 'rebase',
631
+ license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'direct', merge_strategy: 'rebase', release_merge_strategy: 'rebase',
479
632
  }
480
633
  }
481
634
 
@@ -22,7 +22,7 @@ export function readConfig(file) {
22
22
  return config
23
23
  }
24
24
 
25
- /** @param {string} value @param {string} fallback */
25
+ /** @param {string | undefined} value @param {string} fallback */
26
26
  export function configured(value, fallback) {
27
27
  return value === undefined || value === '' ? fallback : value
28
28
  }
@@ -36,3 +36,20 @@ export function listValue(value) {
36
36
  export function includesValue(value, item) {
37
37
  return value === 'all' || listValue(value).includes(item)
38
38
  }
39
+
40
+ /**
41
+ * Supported Git workflows. `direct` (the default) opens feature branches
42
+ * directly into `main`; `staging-release` is opt-in for repositories that
43
+ * maintain a preview/staging integration branch.
44
+ */
45
+ export const GIT_WORKFLOWS = ['direct', 'staging-release']
46
+
47
+ /** @param {string|undefined} value @returns {string} */
48
+ export function gitWorkflow(value) {
49
+ return configured(value, 'direct')
50
+ }
51
+
52
+ /** @param {string|undefined} value @returns {boolean} */
53
+ export function isStagingRelease(value) {
54
+ return gitWorkflow(value) === 'staging-release'
55
+ }