code-foundry 1.4.1 → 1.5.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.
@@ -25,5 +25,9 @@ runs:
25
25
  uses: github/codeql-action/analyze@v4
26
26
  with:
27
27
  category: ${{ inputs.category }}
28
+ # Pin pull-request uploads to the contributor's head SHA/ref so a
29
+ # moving merge ref cannot race the required code-scanning context.
30
+ ref: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
31
+ sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
28
32
  upload-database: false
29
33
  wait-for-processing: false
@@ -29,8 +29,10 @@ updates:
29
29
  applies-to: version-updates
30
30
  patterns: ['*']
31
31
  ignore:
32
- # TypeScript 7 cannot resolve node: builtins in this toolchain
33
- # (verified); revisit when the TS/oxlint toolchain supports TS 7.
32
+ # TypeScript 7 (the native compiler) is supported by the oxlint/oxfmt
33
+ # toolchain; the tsconfig must use NodeNext or bundler resolution since
34
+ # legacy node10 resolution was removed. Held with 5.9 until each
35
+ # repository's tsconfig is verified on 7.
34
36
  - dependency-name: 'typescript'
35
37
  versions: ['>=7.0.0']
36
38
  # Held with TypeScript 5.9 above.
@@ -0,0 +1,98 @@
1
+ name: Code Foundry Cloudflare Deploy
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ mode:
7
+ description: 'Deploy mode: preview (wrangler versions upload) or production (wrangler deploy).'
8
+ required: false
9
+ type: string
10
+ default: preview
11
+ working-directory:
12
+ description: 'Directory containing the wrangler configuration.'
13
+ required: false
14
+ type: string
15
+ default: '.'
16
+ runner:
17
+ required: false
18
+ type: string
19
+ default: ubuntu-latest
20
+ wrangler-version:
21
+ required: false
22
+ type: string
23
+ default: 'latest'
24
+ environment:
25
+ description: 'GitHub deployment environment. Defaults to Preview/Production based on the mode.'
26
+ required: false
27
+ type: string
28
+ default: ''
29
+ secrets:
30
+ CLOUDFLARE_API_TOKEN:
31
+ required: true
32
+ CLOUDFLARE_ACCOUNT_ID:
33
+ required: true
34
+
35
+ permissions:
36
+ contents: read
37
+ deployments: write
38
+
39
+ jobs:
40
+ deploy:
41
+ name: Cloudflare Deploy / ${{ inputs.mode }}
42
+ if: vars.CI_BILLING_PAUSED != 'true'
43
+ runs-on: ${{ inputs.runner }}
44
+ timeout-minutes: 20
45
+ steps:
46
+ - name: Checkout
47
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
48
+ with:
49
+ persist-credentials: false
50
+
51
+ - name: Deploy to Cloudflare
52
+ id: deploy
53
+ working-directory: ${{ inputs.working-directory }}
54
+ env:
55
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
56
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
57
+ WRANGLER_VERSION: ${{ inputs.wrangler-version }}
58
+ MODE: ${{ inputs.mode }}
59
+ run: |
60
+ set -euo pipefail
61
+ npx -y wrangler@"$WRANGLER_VERSION" --version
62
+ if [ "$MODE" = "production" ]; then
63
+ npx -y wrangler@"$WRANGLER_VERSION" deploy 2>&1 | tee deploy-output.txt
64
+ elif [ "$MODE" = "preview" ]; then
65
+ npx -y wrangler@"$WRANGLER_VERSION" versions upload --message "preview ${GITHUB_SHA::7}" 2>&1 | tee deploy-output.txt
66
+ else
67
+ echo "Unsupported mode: $MODE (use preview or production)" >&2
68
+ exit 1
69
+ fi
70
+ # Prefer version preview URLs; fall back to the first workers.dev URL.
71
+ url=$(grep -Eo 'https://[a-zA-Z0-9][a-zA-Z0-9.-]*\.workers\.dev' deploy-output.txt | tail -1 || true)
72
+ echo "url=$url" >> "$GITHUB_OUTPUT"
73
+
74
+ - name: Record GitHub deployment
75
+ env:
76
+ GH_TOKEN: ${{ github.token }}
77
+ MODE: ${{ inputs.mode }}
78
+ ENVIRONMENT_OVERRIDE: ${{ inputs.environment }}
79
+ DEPLOY_URL: ${{ steps.deploy.outputs.url }}
80
+ run: |
81
+ set -euo pipefail
82
+ if [ "$MODE" != "production" ] && [ "$MODE" != "preview" ]; then
83
+ echo "Unsupported mode: $MODE" >&2
84
+ exit 1
85
+ fi
86
+ environment="${ENVIRONMENT_OVERRIDE:-$([ "$MODE" = "production" ] && echo Production || echo Preview)}"
87
+ body=$(jq -n \
88
+ --arg ref "$GITHUB_SHA" \
89
+ --arg environment "$environment" \
90
+ --argjson production "$([ "$MODE" = "production" ] && echo true || echo false)" \
91
+ '{ref: $ref, environment: $environment, auto_merge: false, production_environment: $production, required_contexts: []}')
92
+ deployment=$(gh api "repos/$GITHUB_REPOSITORY/deployments" --input - <<< "$body" --jq .id)
93
+ status_args=(-f state=success -f description="Cloudflare $MODE deploy (${GITHUB_SHA::7})")
94
+ if [ -n "$DEPLOY_URL" ]; then
95
+ status_args+=(-f environment_url="$DEPLOY_URL" -f log_url="$DEPLOY_URL")
96
+ fi
97
+ gh api -X POST "repos/$GITHUB_REPOSITORY/deployments/$deployment/statuses" "${status_args[@]}" --jq .state
98
+ echo "Recorded $environment deployment for ${GITHUB_SHA::7}"
@@ -285,6 +285,10 @@ jobs:
285
285
  uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
286
286
  with:
287
287
  category: /language:rust/${{ steps.scope.outputs.scope_id }}
288
+ # Keep uploads attached to the PR head rather than the mutable
289
+ # merge ref used by GitHub's pull_request event.
290
+ ref: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
291
+ sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
288
292
  upload-database: false
289
293
  wait-for-processing: false
290
294
  - name: Not applicable
@@ -89,17 +89,21 @@ jobs:
89
89
  let releaseType = config.release_type || 'auto'
90
90
  let npmPublish = config.npm_publish || 'false'
91
91
  // The merge audit topology requires rebase for Release Please version
92
- // pull requests into main, matching the linear-history ruleset.
93
- // Release automation never defaults to a merge method: an unset or
94
- // non-rebase release_merge_strategy fails the release job before any
95
- // pull request is created or merged.
92
+ // pull requests into main, matching the linear-history ruleset. The
93
+ // direct topology may also opt into squash: a single-commit release
94
+ // PR squashes to the identical tree and release-please recommends
95
+ // squash. Release automation never defaults to a merge method: an
96
+ // unset or unsupported release_merge_strategy fails the release job
97
+ // before any pull request is created or merged.
98
+ const gitWorkflow = String(config.git_workflow || 'direct').trim().toLowerCase()
96
99
  const releaseMergeStrategy = String(config.release_merge_strategy || '').trim().toLowerCase()
97
100
  if (releaseType === 'auto') {
98
101
  releaseType = fs.existsSync('package.json') ? 'node' : fs.existsSync('pyproject.toml') ? 'python' : fs.existsSync('Cargo.toml') ? 'rust' : fs.existsSync('version.txt') ? 'simple' : 'none'
99
102
  }
100
103
  if (!['node', 'python', 'rust', 'simple', 'none'].includes(releaseType)) throw new Error(`Unsupported release_type: ${releaseType}`)
101
- if (releaseMergeStrategy !== 'rebase') {
102
- throw new Error(`release_merge_strategy must be "rebase" for automated release merges; got ${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}`)
104
+ const allowedStrategies = gitWorkflow === 'staging-release' ? ['rebase'] : ['rebase', 'squash']
105
+ if (!allowedStrategies.includes(releaseMergeStrategy)) {
106
+ throw new Error(`release_merge_strategy must be "rebase"${gitWorkflow === 'staging-release' ? '' : ' or "squash" (direct topology)'} for automated release merges; got ${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}`)
103
107
  }
104
108
  if (releaseType === 'none' || !fs.existsSync('package.json')) npmPublish = 'false'
105
109
  let legacyReleaseType = ''
package/.oxlintrc.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "$schema": "./node_modules/oxlint/configuration_schema.json",
3
- "categories": { "correctness": "error" },
3
+ "categories": { "correctness": "error", "suspicious": "warn" },
4
4
  "ignorePatterns": ["node_modules/**"]
5
5
  }
package/AGENTS.md CHANGED
@@ -77,6 +77,21 @@ This repository uses the `staging-release` workflow: topic branches **squash** i
77
77
 
78
78
  Merge only with the repository's canonical method. Never merge with `--admin`, never default or auto-select a merge method, and never use a method the branch ruleset does not allow. When in doubt, prefer the merge button's configured method and verify the ruleset after merging. Check `.github/CONTRIBUTING.md` for the complete flow and merge table.
79
79
 
80
+ ### Branch and commit policy
81
+
82
+ Branch rulesets enforce deletions, force-pushes, required status checks, pull
83
+ requests, conversation resolution, and linear history where the repository's
84
+ plan supports them. Mirror those rules even where the plan cannot enforce
85
+ them:
86
+
87
+ - Branch from the default branch using
88
+ `feat/*`, `fix/*`, `chore/*`, `refactor/*`, `docs/*`, or `test/*` names.
89
+ - Never push directly to protected branches; open a pull request.
90
+ - Use Conventional Commit subjects (`feat:`, `fix:`, `chore:`, …); Release
91
+ Please depends on them to version releases.
92
+ - Keep pull requests focused; merge with the canonical method only after
93
+ required checks pass.
94
+
80
95
  ## Toolchain and dependencies
81
96
 
82
97
  - Follow `toolchain: auto` in `.github/code-foundry.yml`; use native tools by
@@ -112,7 +127,7 @@ Run focused tests first, then the complete applicable set for release, security,
112
127
 
113
128
  At minimum:
114
129
 
115
- - TypeScript/JavaScript: Oxfmt formatting, Oxlint linting, type-check, build, and Bun's native test runner for unit/integration tests; use the project's native browser runner for E2E tests. Repositories still on Prettier/ESLint keep working through the runtime's fallback detection until they migrate.
130
+ - TypeScript/JavaScript: Oxfmt formatting, Oxlint linting, type-check, build, and Bun's native test runner for unit/integration tests; use the project's native browser runner for E2E tests. Repositories using a different linter or formatter keep full control through their own `lint`/`format` scripts, which the runtime honors.
116
131
  - Do not add Vitest. Preserve specialized native runners such as Matchstick for The Graph and Hardhat for smart contracts.
117
132
  - Rust: default rustfmt, Clippy with warnings treated as errors, check, unit/integration tests, and dependency audit
118
133
  - Python: Ruff formatting and linting, compile or type checks, pytest, coverage, and dependency audit
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.5.0...v1.5.1) (2026-09-08)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * sync-caller-runtime-pin ([#484](https://github.com/0xPlayerOne/code-foundry/issues/484)) ([e1a216a](https://github.com/0xPlayerOne/code-foundry/commit/e1a216a9dcebd040ef0eacdb20791f360a921908))
9
+
10
+ ## [1.5.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.4.1...v1.5.0) (2026-09-07)
11
+
12
+
13
+ ### Features
14
+
15
+ * **release:** squash release merges, Cloudflare deploy workflow, tooling neutrality ([6a847eb](https://github.com/0xPlayerOne/code-foundry/commit/6a847eb0a3f78dbd343b7b189f32543d621b4b8a))
16
+
3
17
  ## [1.4.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.4.0...v1.4.1) (2026-09-07)
4
18
 
5
19
 
package/README.md CHANGED
@@ -74,7 +74,8 @@ The standard workflow triggers are:
74
74
 
75
75
  Jobs are language-aware and skip irrelevant setup while remaining visible as
76
76
  successful required checks. TypeScript uses Oxlint, Oxfmt, and Bun's native
77
- test runner (Prettier/ESLint setups keep working through fallback detection).
77
+ test runner (repositories using another linter or formatter keep full control
78
+ through their own `lint`/`format` scripts, which the runtime honors).
78
79
  Rust uses `rustfmt`, Clippy with warnings as errors, and native
79
80
  Cargo tests. Python uses Ruff, uv/pip-compatible setup, and native Python
80
81
  tests. Solidity projects retain their native toolchain and test runner.
@@ -90,15 +91,16 @@ See [Workflow and CI conventions](docs/WORKFLOWS.md) for triggers, required
90
91
  checks, runners, coverage, caching, and custom workflow extensions.
91
92
 
92
93
  The contribution policy defaults to the `direct` workflow: feature PRs squash
93
- into `main`, and Release Please version PRs rebase into `main`
94
- (`release_merge_strategy: rebase`). Repositories with a preview/staging
95
- environment opt into `git_workflow: staging-release`, where feature PRs squash
96
- into `staging`, the promotion PR rebases into `main` (`merge_strategy:
97
- rebase`), and Release Please version PRs rebase into `main`. Release automation
98
- never defaults to a merge method and never merges with `--admin`;
99
- `code-foundry doctor` and `code-foundry sync` fail closed on any other
100
- strategy. GitHub Stacks is not part of this topology and does not reduce the
101
- required workflow runs.
94
+ into `main`, and Release Please version PRs use the configured
95
+ `release_merge_strategy` (rebase by default, or squash when opted in).
96
+ Repositories with a preview/staging environment opt into
97
+ `git_workflow: staging-release`, where feature PRs squash into `staging`, the
98
+ promotion PR rebases into `main` (`merge_strategy: rebase`), and Release Please
99
+ version PRs rebase into `main`. Release automation never defaults to a merge
100
+ method and never merges with `--admin`; `code-foundry doctor` and
101
+ `code-foundry sync` fail closed on any strategy outside the selected topology.
102
+ GitHub Stacks is not part of this topology and does not reduce the required
103
+ workflow runs.
102
104
 
103
105
  ## Releases and publishing
104
106
 
@@ -50,7 +50,7 @@ repository manifests and source
50
50
  | `license` | `gpl-3.0-or-later`, `agpl-3.0-or-later`, `apache-2.0`, `mit`, `preserve`, `none` | License policy; new repositories default to GPLv3 |
51
51
  | `git_workflow` | `direct` (default), `staging-release` | Branch/release model; `direct` opens feature branches into `main`, `staging-release` promotes `staging` into `main` |
52
52
  | `merge_strategy` | `rebase` | Promotion merge method for `staging` → `main`; only enforced by the `staging-release` topology |
53
- | `release_merge_strategy` | `rebase` | Merge method for Release Please version PRs into `main`; release automation fails closed unless rebase |
53
+ | `release_merge_strategy` | `rebase`, `squash` (direct topology only) | Merge method for Release Please version PRs into `main`; release automation fails closed on anything else |
54
54
  | `runner` fields | GitHub runner names | Per-workflow runner policy |
55
55
 
56
56
  Supported features are `ci`, `codeql`, `security`, `test`, `draft-pr`,
@@ -77,8 +77,9 @@ present.
77
77
  - `direct` (default): feature branches open pull requests directly into
78
78
  `main`. Validation and security scans run on every PR. No `staging` branch
79
79
  exists, no promotion caller is generated, and `merge_strategy` is not
80
- enforced. Dependabot updates target `main`. This is the right choice when a
81
- repository has no preview or staging environment.
80
+ enforced. Release Please version PRs use `release_merge_strategy` (rebase by
81
+ default; squash is also allowed). Dependabot updates target `main`. This is
82
+ the right choice when a repository has no preview or staging environment.
82
83
  - `staging-release` (opt-in): feature branches squash into `staging`, a
83
84
  promotion PR rebases validated changes into `main` (`merge_strategy:
84
85
  rebase`), and Release Please version PRs rebase into `main`
@@ -115,3 +116,14 @@ Each scoped shard must contain tracked Rust source. Code Foundry rejects
115
116
  absolute paths, parent traversal, duplicates, empty scopes, and more than eight
116
117
  shards. Do not split a single crate by arbitrary non-Rust directories: use
117
118
  `["all"]` when complete, non-overlapping source scopes are not available.
119
+
120
+ ## Cloudflare Workers deployments
121
+
122
+ Repositories that deploy to Cloudflare Workers can opt into GitHub-native
123
+ deployments (Preview/Production environments with deployment statuses, like
124
+ Vercel's integration) by adding a small caller for the runtime's reusable
125
+ `cloudflare-deploy.yml` workflow. The workflow runs `wrangler versions upload`
126
+ for pull-request previews and `wrangler deploy` for production, records a
127
+ GitHub deployment plus status with the workers.dev URL, and respects
128
+ `CI_BILLING_PAUSED`. It requires the `CLOUDFLARE_API_TOKEN` and
129
+ `CLOUDFLARE_ACCOUNT_ID` secrets in the consumer repository.
package/docs/RELEASES.md CHANGED
@@ -24,10 +24,11 @@ topology, feature and fix branches land on `staging` with **squash** merges,
24
24
  the `staging` → `main` promotion PR merges with **rebase** (`merge_strategy:
25
25
  rebase`), and Release Please version PRs merge with **rebase**
26
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`.
27
+ branches squash straight into `main` and Release Please version PRs use the
28
+ configured `release_merge_strategy` (**rebase** by default, or **squash** when
29
+ opted in). `merge_strategy` is not enforced. Release automation never defaults
30
+ to a merge method and never merges with `--admin`: `staging-release` accepts
31
+ only rebase for release PRs, while `direct` accepts rebase or squash.
31
32
 
32
33
  The release workflow opens or updates a versioned PR after changes reach
33
34
  `main`. Merging that PR updates the changelog, creates the Git tag and GitHub
@@ -45,21 +46,21 @@ release_type: auto # auto, node, python, rust, simple, or none
45
46
  npm_publish: false # true only for an npm package
46
47
  git_workflow: direct # direct (default) or staging-release
47
48
  merge_strategy: rebase # staging-release only: staging -> main promotion PRs rebase
48
- release_merge_strategy: rebase # required: Release Please version PRs rebase only
49
+ release_merge_strategy: rebase # required; direct also allows squash
49
50
  ```
50
51
 
51
52
  `git_workflow: staging-release` is opt-in; without it, repositories use the
52
53
  `direct` flow and no promotion PR exists. When the staging-release topology is
53
54
  selected, `merge_strategy` applies to promotion PRs (`staging` into `main`)
54
55
  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. The final branch trees are inspected before mutation; validated
61
- main-only changes are inherited by `staging`, while unpromoted staging work is
62
- replayed on top of `main`.
56
+ `staging` use squash merges. `code-foundry doctor`, `code-foundry sync`, and
57
+ the release workflow reject any non-`rebase` `merge_strategy` or release
58
+ strategy in `staging-release`; `direct` accepts `rebase` or `squash` for
59
+ release PRs and never falls back to `merge`. Both keep `main` fully linear,
60
+ which is what makes the post-release reconciliation possible. The final
61
+ branch trees are inspected before mutation; validated main-only changes are
62
+ inherited by `staging`, while unpromoted staging work is replayed on top of
63
+ `main`.
63
64
 
64
65
  Promotion never opens a pull request directly from a divergent `staging`
65
66
  history. The workflow creates or refreshes a deterministic promotion branch
@@ -135,6 +136,6 @@ already passed).
135
136
 
136
137
  1. Merge tested changes into `main` (direct: feature PRs; staging-release: promote `staging` into `main`).
137
138
  2. Review the generated Release Please PR and changelog.
138
- 3. Merge the release PR with the repository's configured `release_merge_strategy` (**rebase**; the release workflow fails closed on any other value).
139
+ 3. Merge the release PR with the repository's configured `release_merge_strategy` (**rebase** by default; **squash** is also valid for direct topology).
139
140
  4. Confirm the GitHub Release and any package publication.
140
141
  5. staging-release only: synchronize `staging` with the new `main` release commit.
package/docs/WORKFLOWS.md CHANGED
@@ -100,15 +100,17 @@ succeeds.
100
100
  ## Merge methods
101
101
 
102
102
  The merge audit pins one merge method per transition. `code-foundry doctor`
103
- and `code-foundry sync` fail closed on any other strategy, and the release
104
- workflow refuses to run unless its strategy is exactly `rebase`.
105
-
106
- | Transition | Merge method | Enforcement |
107
- | ---------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
- | Feature/fix PR into `main` (direct topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
109
- | Feature/fix PR into `staging` (staging-release topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
110
- | `staging` → `main` promotion PR (staging-release topology) | Rebase (`merge_strategy: rebase`) | Code Foundry creates a one-commit head with `main` as its parent and the exact validated `staging` tree; `merge_strategy` must be `rebase`, and merge commits are rejected |
111
- | Release Please version PR into `main` | Rebase (`release_merge_strategy: rebase`) | Release automation fails closed unless `rebase`; never defaults to `merge`, never uses `--admin` |
103
+ and `code-foundry sync` validate release strategy against the repository
104
+ topology. Staging-release release PRs require `rebase`; direct release PRs
105
+ allow `rebase` or `squash`. The release workflow fails closed on any other
106
+ strategy and never falls back to `merge`.
107
+
108
+ | Transition | Merge method | Enforcement |
109
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
110
+ | Feature/fix PR into `main` (direct topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
111
+ | Feature/fix PR into `staging` (staging-release topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
112
+ | `staging` → `main` promotion PR (staging-release topology) | Rebase (`merge_strategy: rebase`) | Code Foundry creates a one-commit head with `main` as its parent and the exact validated `staging` tree; `merge_strategy` must be `rebase`, and merge commits are rejected |
113
+ | Release Please version PR into `main` | Configured (`release_merge_strategy`: rebase default; squash direct only) | Release automation fails closed on unsupported topology/strategy; never defaults to `merge`, never uses `--admin` |
112
114
 
113
115
  The promotion rows above apply only to `staging-release`; `direct`
114
116
  repositories never generate a promotion caller and `merge_strategy` is not
@@ -123,14 +125,18 @@ additional required checks after earlier ones have passed (for example
123
125
  merge policy-blocked. The mergeability poll is bounded and fails closed on
124
126
  conflicts or timeout; releases without an automation token remain manual.
125
127
 
126
- Keeping `main` linear — rebase promotions and rebase release PRs — is what
127
- lets the post-release reconciliation fast-forward or replay `staging` safely
128
- in the `staging-release` topology. `direct` repositories have no reconciliation
129
- step: releases merge straight into `main` with `release_merge_strategy: rebase`.
130
-
131
- Protect `main` with the aggregate `Validation / Gate` and squash-only pull
132
- requests. In the `staging-release` topology, protect `staging` the same way
133
- with a single GitHub Actions integration path. That path uses the
128
+ Keeping `main` linear — rebase promotions and release PRs in the
129
+ `staging-release` topology, or a single-commit rebase/squash release PR in the
130
+ `direct` topology — is what lets the post-release reconciliation fast-forward
131
+ or replay `staging` safely. `direct` repositories have no reconciliation step:
132
+ releases merge straight into `main` with the configured
133
+ `release_merge_strategy`.
134
+
135
+ Protect `main` with the aggregate `Validation / Gate`. Require squash for
136
+ feature/fix pull requests and permit the configured Release Please method:
137
+ rebase in `staging-release`, or rebase/squash in `direct`. In the
138
+ `staging-release` topology, protect `staging` the same way with a single GitHub
139
+ Actions integration path. That path uses the
134
140
  GitHub Actions integration token by default, and optionally an SSH deploy key
135
141
  when `STAGING_DEPLOY_KEY` is configured. The deploy key is required only when
136
142
  a personal-repository ruleset for `staging` enforces a Deploy Key bypass for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
6
6
  "bugs": {
@@ -122,9 +122,14 @@ export function doctor(root, options = {}) {
122
122
  )
123
123
  }
124
124
  const releaseMergeStrategy = config.release_merge_strategy ?? ''
125
- if (includesValue(features, 'release') && releaseMergeStrategy !== 'rebase') {
125
+ const allowedReleaseStrategies =
126
+ workflow === 'staging-release' ? ['rebase'] : ['rebase', 'squash']
127
+ if (
128
+ includesValue(features, 'release') &&
129
+ !allowedReleaseStrategies.includes(releaseMergeStrategy)
130
+ ) {
126
131
  error(
127
- `release_merge_strategy must be "rebase" for automated release merges; got "${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}".`
132
+ `release_merge_strategy must be "rebase"${workflow === 'staging-release' ? '' : ' or "squash" (direct topology)'} for automated release merges; got "${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}".`
128
133
  )
129
134
  }
130
135
  for (const name of ['validation', 'draft-pr', 'release-pr', 'release']) {
@@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } fro
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { spawnSync } from 'node:child_process'
7
- import { syncRepository } from './sync.mjs'
7
+ import { readPackageVersion, syncRepository } from './sync.mjs'
8
8
 
9
9
  /** @typedef {{ path: string, repository: string, runtimeRef: string, dirty: boolean, configured: boolean, gitWorkflow: string }} FleetRepository */
10
10
 
@@ -32,6 +32,13 @@ export function discoverRepositories(root) {
32
32
 
33
33
  /** @param {string} root @param {string} source @param {{ createPr?: boolean, dryRun?: boolean, force?: boolean, version: string, exclude?: string[] }} options */
34
34
  export function upgradeFleet(root, source, options) {
35
+ const sourceVersion = `v${readPackageVersion(source)}`
36
+ const version = options.version ?? sourceVersion
37
+ if (options.version && options.version !== sourceVersion) {
38
+ throw new Error(
39
+ `fleet upgrade target ${options.version} does not match the runtime source checkout (${sourceVersion}); refresh the code-foundry checkout to ${options.version} before upgrading so the rendered callers and the declared runtime agree.`
40
+ )
41
+ }
35
42
  const repositories = discoverRepositories(root)
36
43
  const report = []
37
44
  for (const repository of repositories) {
@@ -80,7 +87,7 @@ export function upgradeFleet(root, source, options) {
80
87
  })
81
88
  continue
82
89
  }
83
- report.push(upgradeRepository(repository, source, options.version))
90
+ report.push(upgradeRepository(repository, source, version))
84
91
  }
85
92
  console.log(JSON.stringify(report, null, 2))
86
93
  return report
@@ -121,7 +128,12 @@ function upgradeRepository(repository, source, version) {
121
128
  status: 'skipped',
122
129
  reason: add.stderr.trim() || 'unable to create isolated worktree',
123
130
  }
124
- const result = syncRepository({ target: temporary, source, force: false })
131
+ const result = syncRepository({
132
+ target: temporary,
133
+ source,
134
+ force: false,
135
+ runtimeRef: version,
136
+ })
125
137
  const configFile = join(temporary, '.github/code-foundry.yml')
126
138
  if (existsSync(configFile)) {
127
139
  const current = readFileSync(configFile, 'utf8')
@@ -96,7 +96,7 @@ const legacyFiles = [
96
96
  '.github/licenses/AGPL-3.0-or-later.txt',
97
97
  ]
98
98
 
99
- /** @typedef {{ target: string, source: string, dryRun?: boolean, force?: boolean, init?: boolean }} SyncOptions */
99
+ /** @typedef {{ target: string, source: string, dryRun?: boolean, force?: boolean, init?: boolean, runtimeRef?: string }} SyncOptions */
100
100
 
101
101
  /** @param {SyncOptions} options */
102
102
  export function syncRepository(options) {
@@ -137,7 +137,11 @@ export function syncRepository(options) {
137
137
  const features = configured(config.features, 'all')
138
138
  const runtimeRepository = configured(config.runtime_repository, '0xPlayerOne/code-foundry')
139
139
  const sourceRuntimeRef = `v${readPackageVersion(source)}`
140
- let runtimeRef = configured(config.runtime_ref, sourceRuntimeRef)
140
+ // An explicit runtime ref (fleet upgrade) is authoritative: the rendered
141
+ // callers and the config pin must agree with it, otherwise an upgrade would
142
+ // declare one runtime while shipping another.
143
+ const targetRuntimeRef = options.runtimeRef ?? sourceRuntimeRef
144
+ let runtimeRef = options.runtimeRef ?? configured(config.runtime_ref, sourceRuntimeRef)
141
145
  const toolchain = configured(config.toolchain, 'auto')
142
146
  const overlays = overlayPolicy(target, config)
143
147
  const rustCodeql = validateRustCodeqlConfig(config)
@@ -161,23 +165,33 @@ export function syncRepository(options) {
161
165
  )
162
166
  }
163
167
  const releaseMergeStrategy = configured(config.release_merge_strategy, '')
164
- if (includesValue(features, 'release') && releaseMergeStrategy !== 'rebase') {
165
- throw new Error(
166
- `Unsupported release_merge_strategy: ${releaseMergeStrategy || '(unset)'}; release automation requires rebase for Release Please version pull requests and never defaults to merge.`
167
- )
168
+ if (includesValue(features, 'release')) {
169
+ // Staging-release reconciliations depend on rebase promotions and rebase
170
+ // release commits; the direct topology has no reconciliation step, so it
171
+ // may also squash Release Please version PRs (a single-commit release PR
172
+ // squashes to the identical tree, and release-please recommends squash).
173
+ const allowedReleaseStrategies =
174
+ workflow === 'staging-release' ? ['rebase'] : ['rebase', 'squash']
175
+ if (!allowedReleaseStrategies.includes(releaseMergeStrategy)) {
176
+ throw new Error(
177
+ `Unsupported release_merge_strategy: ${releaseMergeStrategy || '(unset)'}; release automation requires rebase (or squash in the direct topology) for Release Please version pull requests and never defaults to merge.`
178
+ )
179
+ }
168
180
  }
169
181
  const changed = []
170
182
 
171
183
  // Keep normal semver pins current during sync while preserving intentional
172
- // refs such as `main`, `staging`, or a custom immutable SHA.
184
+ // refs such as `main`, `staging`, or a custom immutable SHA. An explicit
185
+ // runtime ref (fleet upgrade) is authoritative and overrides even those so
186
+ // the rendered callers and the config pin land on the same runtime.
173
187
  if (
174
188
  existingConfig.runtime_ref &&
175
- /^v\d+\.\d+\.\d+$/.test(existingConfig.runtime_ref) &&
176
- existingConfig.runtime_ref !== sourceRuntimeRef
189
+ existingConfig.runtime_ref !== targetRuntimeRef &&
190
+ (options.runtimeRef !== undefined || /^v\d+\.\d+\.\d+$/.test(existingConfig.runtime_ref))
177
191
  ) {
178
- runtimeRef = sourceRuntimeRef
192
+ runtimeRef = targetRuntimeRef
179
193
  const current = readFileSync(configPath, 'utf8')
180
- const updated = current.replace(/^runtime_ref:\s*.*$/m, `runtime_ref: ${sourceRuntimeRef}`)
194
+ const updated = current.replace(/^runtime_ref:\s*.*$/m, `runtime_ref: ${targetRuntimeRef}`)
181
195
  if (updated !== current) {
182
196
  changed.push('.github/code-foundry.yml')
183
197
  writeOrReport(configPath, updated, dryRun)
@@ -643,7 +657,7 @@ const DIRECT_DOC_REPLACEMENTS = {
643
657
  ],
644
658
  [
645
659
  'This repository uses the `staging-release` workflow: 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`). Feature PRs land on `staging` with squash merges; promotion and release PRs land on `main` with rebase merges. Re-align `staging` with `main` after a release when needed.',
646
- 'This repository uses the `direct` workflow: topic branches **squash** directly into `main`, and the Release Please version PR **rebases** into `main` (`release_merge_strategy: rebase`). Feature PRs land on `main` with squash merges; release PRs land on `main` with rebase merges. No integration branch exists; all pull requests target `main`.',
660
+ 'This repository uses the `direct` workflow: topic branches **squash** directly into `main`, and the Release Please version PR lands on `main` with the configured `release_merge_strategy` (rebase by default, or squash when opted in). Feature PRs land on `main` with squash merges. No integration branch exists; all pull requests target `main`.',
647
661
  ],
648
662
  ],
649
663
  '.github/CONTRIBUTING.md': [
@@ -661,7 +675,7 @@ const DIRECT_DOC_REPLACEMENTS = {
661
675
  ],
662
676
  [
663
677
  '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.',
664
- '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.',
678
+ 'The Git workflow is `direct`: topic branches **squash** directly into `main`, and the Release Please version PR lands on `main` with the configured `release_merge_strategy` (rebase by default, or squash when opted in). 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.',
665
679
  ],
666
680
  [
667
681
  'git switch staging\ngit pull --ff-only origin staging',
@@ -687,7 +701,7 @@ const DIRECT_DOC_REPLACEMENTS = {
687
701
  ],
688
702
  [
689
703
  '| 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',
690
- '| 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 |',
704
+ '| Change | Target | Merge method | Merge gate |\n|----------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| Working branch | `main` | Squash | All applicable required checks pass |\n| Release Please version PR | `main` | Configured `release_merge_strategy` (rebase default, or squash in the direct topology) | Validation gate and release policy pass |',
691
705
  ],
692
706
  ['1. Create a focused branch from `staging`.', '1. Create a focused branch from `main`.'],
693
707
  ],
@@ -821,6 +835,103 @@ function mergeGitignore(baseline, existing) {
821
835
  : baseline
822
836
  }
823
837
 
838
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
839
+ function isJsonObject(value) {
840
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
841
+ }
842
+
843
+ /** @param {string} source @returns {string} */
844
+ function stripJsonComments(source) {
845
+ const output = []
846
+ let inString = false
847
+ for (let index = 0; index < source.length; index += 1) {
848
+ const char = source[index]
849
+ const next = source[index + 1]
850
+ if (inString) {
851
+ output.push(char)
852
+ if (char === '\\' && next !== undefined) {
853
+ output.push(next)
854
+ index += 1
855
+ } else if (char === '"') {
856
+ inString = false
857
+ }
858
+ continue
859
+ }
860
+ if (char === '"') {
861
+ inString = true
862
+ output.push(char)
863
+ continue
864
+ }
865
+ if (char === '/' && next === '/') {
866
+ output.push(' ', ' ')
867
+ index += 2
868
+ while (index < source.length && source[index] !== '\n' && source[index] !== '\r') {
869
+ output.push(' ')
870
+ index += 1
871
+ }
872
+ index -= 1
873
+ continue
874
+ }
875
+ if (char === '/' && next === '*') {
876
+ output.push(' ', ' ')
877
+ index += 2
878
+ while (index < source.length) {
879
+ const commentChar = source[index]
880
+ const commentNext = source[index + 1]
881
+ if (commentChar === '*' && commentNext === '/') {
882
+ output.push(' ', ' ')
883
+ index += 1
884
+ break
885
+ }
886
+ output.push(commentChar === '\n' || commentChar === '\r' ? commentChar : ' ')
887
+ index += 1
888
+ }
889
+ continue
890
+ }
891
+ output.push(char)
892
+ }
893
+ return output.join('')
894
+ }
895
+
896
+ /** @param {string} source @returns {string} */
897
+ function stripJsonTrailingCommas(source) {
898
+ const output = []
899
+ let inString = false
900
+ for (let index = 0; index < source.length; index += 1) {
901
+ const char = source[index]
902
+ if (inString) {
903
+ output.push(char)
904
+ if (char === '\\') {
905
+ const escaped = source[index + 1]
906
+ if (escaped !== undefined) {
907
+ output.push(escaped)
908
+ index += 1
909
+ }
910
+ } else if (char === '"') {
911
+ inString = false
912
+ }
913
+ continue
914
+ }
915
+ if (char === '"') {
916
+ inString = true
917
+ output.push(char)
918
+ continue
919
+ }
920
+ if (char === ',') {
921
+ let nextIndex = index + 1
922
+ while (/\s/.test(source[nextIndex] ?? '')) nextIndex += 1
923
+ if (source[nextIndex] === '}' || source[nextIndex] === ']') continue
924
+ }
925
+ output.push(char)
926
+ }
927
+ return output.join('')
928
+ }
929
+
930
+ /** @param {string} source @returns {unknown} */
931
+ function parseJsonc(source) {
932
+ return JSON.parse(stripJsonTrailingCommas(stripJsonComments(source)))
933
+ }
934
+
824
935
  /**
825
936
  * Merge a baseline Oxc config (.oxfmtrc.json / .oxlintrc.json) with the
826
937
  * consumer's current config. The baseline owns every key it defines, except
@@ -828,7 +939,9 @@ function mergeGitignore(baseline, existing) {
828
939
  * migrations or by the repository) are preserved so repeated syncs never
829
940
  * churn them. Keys the baseline does not define (e.g. a repository-owned
830
941
  * `overrides` block) belong to the consumer and are preserved as well, so
831
- * a sync never silently drops repository-owned configuration.
942
+ * a sync never silently drops repository-owned configuration. Consumer
943
+ * category values are merged last so explicit category overrides survive the
944
+ * baseline's default category levels.
832
945
  *
833
946
  * When the merged semantics already match the consumer file, the exact
834
947
  * existing bytes are returned untouched: rewriting canonical JSON here
@@ -840,23 +953,28 @@ function mergeIgnorePatternsConfig(baseline, existing) {
840
953
  /** @type {Record<string, any>} */
841
954
  let consumer = {}
842
955
  try {
843
- consumer = JSON.parse(existing)
844
- if (consumer === null || typeof consumer !== 'object' || Array.isArray(consumer))
845
- return baseline
956
+ const parsed = parseJsonc(existing)
957
+ if (!isJsonObject(parsed)) return baseline
958
+ consumer = parsed
846
959
  } catch {
847
960
  return baseline
848
961
  }
849
962
  /** @type {Record<string, any>} */
850
963
  let config = {}
851
964
  try {
852
- config = JSON.parse(baseline)
853
- if (config === null || typeof config !== 'object' || Array.isArray(config)) return baseline
965
+ const parsed = parseJsonc(baseline)
966
+ if (!isJsonObject(parsed)) return baseline
967
+ config = parsed
854
968
  } catch {
855
969
  return baseline
856
970
  }
857
971
  /** @type {Record<string, any>} */
858
972
  const merged = { ...config }
859
973
  for (const [key, value] of Object.entries(consumer)) {
974
+ if (key === 'categories' && isJsonObject(merged.categories) && isJsonObject(value)) {
975
+ merged.categories = { ...merged.categories, ...value }
976
+ continue
977
+ }
860
978
  if (!(key in merged)) merged[key] = value
861
979
  }
862
980
  const extra = Array.isArray(consumer.ignorePatterns) ? consumer.ignorePatterns : []
@@ -1006,7 +1124,7 @@ function renderConfigLine(key, value) {
1006
1124
  return needsQuotes ? `${key}: '${value.replace(/'/g, "''")}'` : `${key}: ${value}`
1007
1125
  }
1008
1126
  /** @param {string} root */
1009
- function readPackageVersion(root) {
1127
+ export function readPackageVersion(root) {
1010
1128
  try {
1011
1129
  return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version ?? '0.0.0'
1012
1130
  } catch {
package/src/runtime.mjs CHANGED
@@ -327,34 +327,11 @@ function hasDependencyManifest(ecosystem) {
327
327
  return false
328
328
  }
329
329
 
330
- /**
331
- * Detect repository-owned ESLint setup: an eslint dependency (or script
332
- * reference), or a tracked flat/legacy config file. Repositories without any
333
- * of these have nothing for the fallback runner to enforce, so lint skips
334
- * instead of forcing a network fetch of ESLint in CI.
335
- * @returns {boolean}
336
- */
337
- function hasEslintSetup() {
338
- const pkg = readPackage() ?? {}
339
- const scripts = pkg.scripts ?? {}
340
- const dependencies = {
341
- ...pkg.dependencies,
342
- ...pkg.devDependencies,
343
- ...pkg.optionalDependencies,
344
- ...pkg.peerDependencies,
345
- }
346
- if (dependencies.eslint) return true
347
- if (Object.values(scripts).some((value) => /\beslint\b/.test(String(value)))) return true
348
- return repositoryTestFiles().some((file) =>
349
- /(^|\/)(\.eslintrc[^/]*|eslint\.config\.[^/]*)$/.test(file)
350
- )
351
- }
352
-
353
330
  /**
354
331
  * Detect repository-owned Oxlint setup: an oxlint dependency (or script
355
332
  * reference) or a tracked `.oxlintrc.json` / `oxlint.config.*` file. Oxlint
356
- * is the baseline's preferred JavaScript linter; the ESLint fallback only
357
- * runs when no Oxlint setup exists.
333
+ * is the baseline's preferred JavaScript linter; repositories without one
334
+ * fall back to their own `lint` script if defined.
358
335
  * @returns {boolean}
359
336
  */
360
337
  function hasOxlintSetup() {
@@ -376,8 +353,8 @@ function hasOxlintSetup() {
376
353
  /**
377
354
  * Detect repository-owned Oxfmt setup: an oxfmt dependency (or script
378
355
  * reference) or a tracked `.oxfmtrc.json` / `oxfmt.config.*` file. Oxfmt is
379
- * the baseline's preferred JavaScript formatter; the Prettier fallback only
380
- * runs when no Oxfmt setup exists.
356
+ * the baseline's formatter; repositories using a different formatter opt out
357
+ * through their own `format`/`fmt` scripts, which the script fallback honors.
381
358
  * @returns {boolean}
382
359
  */
383
360
  function hasOxfmtSetup() {
@@ -415,12 +392,13 @@ function ci(task) {
415
392
  if (
416
393
  !scripted &&
417
394
  (hasLanguage('typescript') || hasLanguage('javascript')) &&
418
- hasRootJavascriptProject()
395
+ hasRootJavascriptProject() &&
396
+ hasOxfmtSetup()
419
397
  ) {
420
- // Oxfmt is the preferred formatter; Prettier remains the fallback for
421
- // repositories that have not migrated.
422
- if (hasOxfmtSetup()) runTool('oxfmt', ['--check', '.'])
423
- else runTool('prettier', ['--check', '.'])
398
+ // Oxfmt is the baseline's formatter. Repositories that use a different
399
+ // formatter keep full control through their own `format`/`fmt` scripts,
400
+ // which the runScript fallback above already honors.
401
+ runTool('oxfmt', ['--check', '.'])
424
402
  }
425
403
  if (hasLanguage('python') && hasRootPythonProject()) runTool('ruff', ['format', '--check', '.'])
426
404
  if (hasLanguage('rust') && hasRootRustProject()) run('cargo', ['fmt', '--check'])
@@ -431,12 +409,13 @@ function ci(task) {
431
409
  if (
432
410
  !scripted &&
433
411
  (hasLanguage('typescript') || hasLanguage('javascript')) &&
434
- hasRootJavascriptProject()
412
+ hasRootJavascriptProject() &&
413
+ hasOxlintSetup()
435
414
  ) {
436
- // Oxlint is the preferred linter; ESLint remains the fallback for
437
- // repositories that have not migrated.
438
- if (hasOxlintSetup()) runTool('oxlint', [])
439
- else if (hasEslintSetup()) runTool('eslint', ['.'])
415
+ // Oxlint is the baseline's linter. Repositories that use a different
416
+ // linter keep full control through their own `lint` script, which the
417
+ // runScript fallback above already honors.
418
+ runTool('oxlint', [])
440
419
  }
441
420
  if (hasLanguage('python') && hasRootPythonProject()) runTool('ruff', ['check', '.'])
442
421
  if (hasLanguage('rust') && hasRootRustProject())