code-foundry 0.31.18 → 0.32.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.
@@ -48,7 +48,7 @@ docs/* test/* refactor/* │ │
48
48
  | `staging` | Integration branch | Target normal pull requests here. Required checks must pass before merge. |
49
49
  | `feat/*`, `fix/*`, `chore/*`, `refactor/*`, `docs/*`, `test/*` | Focused work | Branch from `staging`; keep changes small and reviewable. |
50
50
 
51
- The default Git workflow is `staging-release`: topic branches merge into `staging`, then a promotion PR moves validated changes into `main`, followed by the versioned release PR. The default merge strategy is `rebase`, which preserves the linear Conventional Commit history. Configure `merge_strategy` as `squash` or `merge` in `.github/code-foundry.yml` when the repository intentionally uses another policy. Re-align `staging` with `main` after a release when needed.
51
+ The default Git workflow is `staging-release`: topic branches merge into `staging` (typically after rebasing), then a promotion PR moves validated changes into `main`, followed by the versioned release PR. The default release merge strategy is `merge`, matching your preference for merge-commit promotions from staging to main. Configure `merge_strategy` as `rebase` or `squash` in `.github/code-foundry.yml` if a repository intentionally differs. Re-align `staging` with `main` after a release when needed.
52
52
 
53
53
  ## Before you start
54
54
 
@@ -163,7 +163,7 @@ Keep pull requests focused and reviewable. Include screenshots or recordings for
163
163
 
164
164
  The workflows use separate concurrency groups keyed by the commit under test. A newer run for the same commit cancels a duplicate event-triggered run, while newer commits cancel older runs and independent CI, Test, Security, and CodeQL workflows continue in parallel.
165
165
 
166
- Required checks are enforced by branch protection. Do not duplicate their checklists in the pull request description; document validation commands and results instead.
166
+ Required checks are enforced by branch protection rulesets/branch protection. Do not duplicate their checklists in the pull request description; document validation commands and results instead.
167
167
 
168
168
  ### Release conventions
169
169
 
@@ -16,7 +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: rebase
19
+ merge_strategy: merge
20
+ release_merge_strategy: squash
20
21
  runner: ubuntu-latest
21
22
  unit_runner: ubuntu-slim
22
23
  cache_packages: auto
@@ -35,7 +35,7 @@ env:
35
35
  REPO_FOUNDRY_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before || '' }}
36
36
 
37
37
  concurrency:
38
- group: code-foundry-ci-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
38
+ group: code-foundry-ci-${{ github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
39
39
  cancel-in-progress: true
40
40
 
41
41
  jobs:
@@ -47,7 +47,7 @@ env:
47
47
  REPO_FOUNDRY_FEATURES: ${{ vars.REPO_FOUNDRY_FEATURES }}
48
48
 
49
49
  concurrency:
50
- group: code-foundry-codeql-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
50
+ group: code-foundry-codeql-${{ github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
51
51
  cancel-in-progress: true
52
52
 
53
53
  jobs:
@@ -36,12 +36,36 @@ jobs:
36
36
  if: steps.check-pr.outputs.existing == '0'
37
37
  run: |
38
38
  BRANCH="${{ github.ref_name }}"
39
+ PREFIX="${BRANCH%%/*}"
40
+ SUFFIX="${BRANCH#*/}"
41
+ PR_TITLE=""
42
+
43
+ case "$PREFIX" in
44
+ feat|fix|chore|refactor|docs|test|style|ci|build)
45
+ if [ "$SUFFIX" != "$BRANCH" ] && [ -n "$SUFFIX" ]; then
46
+ PR_TITLE="${PREFIX}: ${SUFFIX}"
47
+ fi
48
+ ;;
49
+ *)
50
+ ;;
51
+ esac
52
+
53
+ PR_TITLE="${PR_TITLE//[\/_]/ }"
54
+ PR_TITLE="$(printf '%s' "$PR_TITLE" | tr -s ' ' | sed 's/^ *//; s/ *$//')"
55
+
56
+ if [ -z "$PR_TITLE" ]; then
57
+ PR_TITLE=$(git log -1 --pretty=%s 2>/dev/null || true)
58
+ fi
59
+ if [ -z "$PR_TITLE" ]; then
60
+ PR_TITLE="$BRANCH"
61
+ fi
62
+
39
63
  gh pr create \
40
64
  --repo "$GITHUB_REPOSITORY" \
41
65
  --base staging \
42
66
  --head "$BRANCH" \
43
67
  --draft \
44
- --title "[WIP] $BRANCH" \
68
+ --title "$PR_TITLE" \
45
69
  --body "Draft PR created automatically by CI.
46
70
 
47
71
  This PR is a draft and should not be merged until CI passes and a reviewer (or the Daily Review agent) marks it ready.
@@ -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: **rebase**).
160
+ - Merge using the repository's configured \`merge_strategy\` (default: **merge**).
161
161
  - After merge, Release Please opens a separate versioned release PR with the changelog.
162
162
  EOF
163
163
 
@@ -72,10 +72,12 @@ jobs:
72
72
  : {}
73
73
  let releaseType = config.release_type || 'auto'
74
74
  let npmPublish = config.npm_publish || 'false'
75
+ let releaseMergeStrategy = String(config.release_merge_strategy || config.merge_strategy || 'merge').trim().toLowerCase()
75
76
  if (releaseType === 'auto') {
76
77
  releaseType = fs.existsSync('package.json') ? 'node' : fs.existsSync('pyproject.toml') ? 'python' : fs.existsSync('Cargo.toml') ? 'rust' : fs.existsSync('version.txt') ? 'simple' : 'none'
77
78
  }
78
79
  if (!['node', 'python', 'rust', 'simple', 'none'].includes(releaseType)) throw new Error(`Unsupported release_type: ${releaseType}`)
80
+ if (!['rebase', 'squash', 'merge'].includes(releaseMergeStrategy)) throw new Error(`Unsupported merge_strategy: ${releaseMergeStrategy}`)
79
81
  if (releaseType === 'none' || !fs.existsSync('package.json')) npmPublish = 'false'
80
82
  let legacyReleaseType = ''
81
83
  if (releaseType !== 'none') {
@@ -84,11 +86,13 @@ jobs:
84
86
  const releaseConfig = JSON.parse(fs.readFileSync(sourcePath, 'utf8'))
85
87
  if (!releaseConfig.packages && !releaseConfig['release-type']) {
86
88
  legacyReleaseType = releaseType
89
+ } else if (!fs.existsSync('.release-please-manifest.json')) {
90
+ throw new Error('Missing .release-please-manifest.json: run code-foundry sync to bootstrap the release manifest.')
87
91
  }
88
92
  }
89
93
  fs.appendFileSync(
90
94
  process.env.GITHUB_OUTPUT,
91
- `release_type=${releaseType}\nlegacy_release_type=${legacyReleaseType}\nnpm_publish=${npmPublish}\n`,
95
+ `release_type=${releaseType}\nlegacy_release_type=${legacyReleaseType}\nnpm_publish=${npmPublish}\nrelease_merge_strategy=${releaseMergeStrategy}\n`,
92
96
  )
93
97
  NODE
94
98
  - name: Detect release credentials
@@ -161,14 +165,14 @@ jobs:
161
165
  gh pr merge "$pr" \
162
166
  --repo "$GITHUB_REPOSITORY" \
163
167
  --admin \
164
- --rebase \
168
+ --${{ steps.profile.outputs.release_merge_strategy }} \
165
169
  --delete-branch
166
170
  done
167
171
 
168
172
  reconcile:
169
173
  name: Release / Reconcile
170
174
  needs: release
171
- if: needs.release.outputs.release_created == 'true'
175
+ if: needs.release.result == 'success'
172
176
  runs-on: ${{ inputs.runner }}
173
177
  timeout-minutes: 15
174
178
  permissions:
@@ -31,7 +31,7 @@ env:
31
31
  REPO_FOUNDRY_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before || '' }}
32
32
 
33
33
  concurrency:
34
- group: code-foundry-security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
34
+ group: code-foundry-security-${{ github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
35
35
  cancel-in-progress: true
36
36
 
37
37
  jobs:
@@ -40,7 +40,7 @@ env:
40
40
  REPO_FOUNDRY_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before || '' }}
41
41
 
42
42
  concurrency:
43
- group: code-foundry-test-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
43
+ group: code-foundry-test-${{ github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
44
44
  cancel-in-progress: true
45
45
 
46
46
  jobs:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.32.1](https://github.com/0xPlayerOne/code-foundry/compare/v0.32.0...v0.32.1) (2026-07-31)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **ci:** key runtime concurrency by event so promotion PRs do not cancel push checks ([5a8d8fd](https://github.com/0xPlayerOne/code-foundry/commit/5a8d8fd80e62feabc887869e07219a77f2c46c1a))
9
+ * **release:** bootstrap release-please manifest during sync ([98c7e5d](https://github.com/0xPlayerOne/code-foundry/commit/98c7e5d9341eedf22d49611f19825a5ef8d6d596))
10
+ * **release:** satisfy JSDoc type-check for manifest bootstrap ([02ddb45](https://github.com/0xPlayerOne/code-foundry/commit/02ddb459405ee9d54c91afdc3cf5602790a4c2b4))
11
+ * **release:** squash release PRs so staging can fast-forward after releases ([5d9a788](https://github.com/0xPlayerOne/code-foundry/commit/5d9a78843fde4ce764237eacfde9b1720d15696e))
12
+
13
+ ## [0.32.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.31.18...v0.32.0) (2026-07-31)
14
+
15
+
16
+ ### Features
17
+
18
+ * improve draft PR titles from branch conventions ([#290](https://github.com/0xPlayerOne/code-foundry/issues/290)) ([7b215db](https://github.com/0xPlayerOne/code-foundry/commit/7b215db961347dfdb74241fc2c3489d390b34533))
19
+
3
20
  ## [0.31.18](https://github.com/0xPlayerOne/code-foundry/compare/v0.31.17...v0.31.18) (2026-07-30)
4
21
 
5
22
 
@@ -48,7 +48,7 @@ repository manifests and source
48
48
  | `npm_publish` | `true` or `false` | Opt into npm publication |
49
49
  | `license` | `gpl-3.0-or-later`, `agpl-3.0-or-later`, `mit`, `preserve`, `none` | License policy; new repositories default to GPLv3 |
50
50
  | `git_workflow` | `staging-release` | Branch/release model; the standard model promotes `staging` into `main` |
51
- | `merge_strategy` | `rebase`, `squash`, `merge` | Preferred merge method for contribution and release PRs; defaults to `rebase` |
51
+ | `merge_strategy` | `rebase`, `squash`, `merge` | Preferred merge method for contribution and release PRs; defaults to `merge` |
52
52
  | `runner` fields | GitHub runner names | Per-workflow runner policy |
53
53
 
54
54
  Supported features are `ci`, `codeql`, `security`, `test`, `draft-pr`,
package/docs/RELEASES.md CHANGED
@@ -12,9 +12,8 @@ Keep commits Conventional Commit-shaped (`feat:`, `fix:`, `docs:`, `ci:`,
12
12
  `chore:`, and so on). Release Please uses them to select patch/minor/major
13
13
  versions and generate grouped changelog notes.
14
14
 
15
- The default `merge_strategy` is `rebase`, matching the repository's linear
16
- history policy. Set it to `squash` or `merge` in `.github/code-foundry.yml` when
17
- that repository intentionally uses a different merge convention. The current
15
+ The default `merge_strategy` is `merge`, matching promotion PRs from `staging` into `main`.
16
+ Configure it to `squash` or `rebase` in `.github/code-foundry.yml` if a repository intentionally differs. The current
18
17
  supported `git_workflow` is `staging-release`.
19
18
 
20
19
  The release workflow opens or updates a versioned PR after changes reach
@@ -31,12 +30,30 @@ Set these values in `.github/code-foundry.yml`:
31
30
  ```yaml
32
31
  release_type: auto # auto, node, python, rust, simple, or none
33
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
34
35
  ```
35
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.
43
+
36
44
  `auto` selects a supported manifest. Use `simple` with `version.txt` for a
37
45
  repository without a package manifest and `none` for a repository that should
38
46
  not release automatically.
39
47
 
48
+ Release Please runs in manifest mode whenever the release config declares
49
+ `packages` or a top-level `release-type`; `code-foundry sync` bootstraps
50
+ `.release-please-manifest.json` from the current package versions the first
51
+ time it is needed. Release Please owns the manifest after the first release
52
+ and sync never overwrites existing manifest versions. Legacy configs without
53
+ `packages` or `release-type` continue to run in simple mode and need no
54
+ manifest; `code-foundry doctor` fails when a manifest-mode config is missing
55
+ its manifest.
56
+
40
57
  ## Pull request permissions
41
58
 
42
59
  Release Please falls back to the repository's `GITHUB_TOKEN` when a
@@ -54,6 +71,6 @@ the generated version pull request before using the token to merge it.
54
71
 
55
72
  1. Merge tested changes from `staging` into `main`.
56
73
  2. Review the generated Release Please PR and changelog.
57
- 3. Merge the release PR with the repository's normal linear-history policy.
74
+ 3. Merge the release PR with the repository.s configured `release_merge_strategy` (default `merge`, recommended `squash`).
58
75
  4. Confirm the GitHub Release and any package publication.
59
76
  5. Synchronize `staging` with the new `main` release commit.
package/docs/WORKFLOWS.md CHANGED
@@ -61,13 +61,14 @@ checks disabled.
61
61
 
62
62
  ## Branch protection
63
63
 
64
- Use the repository's GitHub settings or the maintainer's branch-protection
65
- automation after reviewing the repository's enabled features:
64
+ Use repository rulesets (or legacy branch protection settings) to mirror the
65
+ required checks for each protected branch. Review the repository's enabled
66
+ features and enforce only checks that actually run:
66
67
 
67
68
  ```bash
68
69
  Apply only checks for enabled workflows.
69
70
  ```
70
71
 
71
- Keep strict status checks, linear history, and conversation resolution enabled.
72
- For a repository with optional features disabled, do not require checks that
73
- will never run.
72
+ Keep strict status checks, linear history, and conversation resolution enabled
73
+ where required. For a repository with optional features disabled, do not require
74
+ checks that will never run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.31.18",
3
+ "version": "0.32.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",
@@ -56,6 +56,18 @@ export function doctor(root, options = {}) {
56
56
  }
57
57
  if (profile.languages.split(',').includes('python') && !commandExists('python') && !existsSync(join(target, '.venv/bin/python'))) error('Python is required for this repository')
58
58
 
59
+ const releaseConfigPath = join(target, 'release-please-config.json')
60
+ if (existsSync(releaseConfigPath)) {
61
+ /** @type {Record<string, any>} */
62
+ let releaseConfig = {}
63
+ try { releaseConfig = JSON.parse(readFileSync(releaseConfigPath, 'utf8')) }
64
+ catch { error('release-please-config.json is not valid JSON') }
65
+ const manifestMode = Boolean(releaseConfig.packages || releaseConfig['release-type'])
66
+ if (manifestMode && !existsSync(join(target, '.release-please-manifest.json'))) {
67
+ error('release-please-config.json requires .release-please-manifest.json; run code-foundry sync to bootstrap it')
68
+ }
69
+ }
70
+
59
71
  for (const workflow of ['ci', 'codeql', 'security', 'test', 'draft-pr', 'release-pr', 'release']) {
60
72
  if (includesValue(config.features ?? 'all', workflow) && !existsSync(join(target, `.github/workflows/${workflow}.yml`))) error(`missing enabled workflow: ${workflow}.yml`)
61
73
  }
@@ -5,7 +5,7 @@ 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
7
  import { configured, includesValue, readConfig } from '../lib/config.mjs'
8
- import { buildReleaseConfig } from '../lib/release-manifest.mjs'
8
+ import { buildReleaseConfig, buildReleaseManifest } from '../lib/release-manifest.mjs'
9
9
  import { customWorkflowFiles, overlayPolicy } from '../lib/overlay.mjs'
10
10
 
11
11
  const standardFiles = [
@@ -118,6 +118,27 @@ export function syncRepository(options) {
118
118
  }
119
119
  }
120
120
 
121
+ const releaseManifest = buildReleaseManifest(target, mergeReleaseConfig(target, sourcePath(source, 'release-please-config.json')))
122
+ if (releaseManifest) {
123
+ const manifestPath = join(target, '.release-please-manifest.json')
124
+ /** @type {Record<string, string>} */
125
+ let existingManifest = {}
126
+ if (existsSync(manifestPath)) {
127
+ try { existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8')) }
128
+ catch { existingManifest = {} }
129
+ }
130
+ /** @type {Record<string, string>} */
131
+ const mergedManifest = {}
132
+ for (const directory of [...new Set([...Object.keys(releaseManifest), ...Object.keys(existingManifest)])].sort()) {
133
+ mergedManifest[directory] = existingManifest[directory] ?? releaseManifest[directory]
134
+ }
135
+ const content = `${JSON.stringify(mergedManifest, null, 2)}\n`
136
+ if (!existsSync(manifestPath) || readFileSync(manifestPath, 'utf8') !== content) {
137
+ changed.push('.release-please-manifest.json')
138
+ writeOrReport(manifestPath, content, dryRun)
139
+ }
140
+ }
141
+
121
142
  if (license !== 'preserve' && license !== 'none') {
122
143
  const licenseFile = license === 'mit' ? 'MIT.txt' : license === 'agpl-3.0-or-later' ? 'AGPL-3.0-or-later.txt' : 'GPL-3.0-or-later.txt'
123
144
  const sourceLicense = join(source, '.github/licenses', licenseFile)
@@ -161,8 +182,8 @@ export function syncRepository(options) {
161
182
  return { changed, config }
162
183
  }
163
184
 
164
- /** @param {string} target @param {string} sourceFile @returns {string} */
165
- function renderReleaseConfig(target, sourceFile) {
185
+ /** @param {string} target @param {string} sourceFile @returns {Record<string, any>} */
186
+ function mergeReleaseConfig(target, sourceFile) {
166
187
  let baseline = {}
167
188
  try { baseline = JSON.parse(readFileSync(sourceFile, 'utf8')) }
168
189
  catch { baseline = {} }
@@ -172,8 +193,12 @@ function renderReleaseConfig(target, sourceFile) {
172
193
  try { existing = JSON.parse(readFileSync(destination, 'utf8')) }
173
194
  catch { existing = baseline }
174
195
  }
175
- const merged = { ...baseline, ...existing }
176
- return `${JSON.stringify(buildReleaseConfig(target, merged), null, 2)}\n`
196
+ return buildReleaseConfig(target, { ...baseline, ...existing })
197
+ }
198
+
199
+ /** @param {string} target @param {string} sourceFile @returns {string} */
200
+ function renderReleaseConfig(target, sourceFile) {
201
+ return `${JSON.stringify(mergeReleaseConfig(target, sourceFile), null, 2)}\n`
177
202
  }
178
203
 
179
204
  /** @param {string} file @param {string} languages @param {string} features @param {Record<string, string>} config */
@@ -339,7 +364,7 @@ function createDefaultConfig(root, source) {
339
364
  post_release: 'false', post_release_workflow: '', post_release_mode: 'auto',
340
365
  opencode_security: 'false',
341
366
  sync_mode: 'overlay', custom_workflows: 'preserve',
342
- license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'staging-release', merge_strategy: 'rebase',
367
+ license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'staging-release', merge_strategy: 'merge', release_merge_strategy: 'squash',
343
368
  }
344
369
  }
345
370
 
@@ -15,23 +15,22 @@ export function doctorGithub(root) {
15
15
  const warnings = []
16
16
  /** @type {Record<string, any>} */
17
17
  const details = { repository }
18
+ const rulesets = hydrateRulesets(repository, ghJson(['api', `repos/${repository}/rulesets`]))
19
+ const mainRulesets = rulesetsForBranch(rulesets, 'main')
18
20
  const protection = ghJson(['api', `repos/${repository}/branches/main/protection`])
19
- if (!protection) warnings.push('main branch protection is not readable or is not configured.')
20
- else {
21
- const required = requiredContexts(protection)
22
- details.requiredChecks = required
23
- const duplicateNames = required.filter((name) => /\b([^/]+) \/ \1\b/i.test(name))
24
- if (duplicateNames.length) errors.push(`required checks contain duplicate workflow prefixes: ${duplicateNames.join(', ')}`)
25
- if (!protection.required_status_checks) warnings.push('main protection has no required status checks.')
26
- }
21
+ const required = mainRulesets.length ? requiredContextsFromRulesets(mainRulesets) : requiredContextsFromProtection(protection)
22
+ details.requiredChecks = required
23
+ if (!protection && !mainRulesets.length) warnings.push('main branch protection or repository rulesets are not readable or are not configured.')
24
+ else if (!required.length) warnings.push(mainRulesets.length ? 'main ruleset has no required status checks.' : 'main protection has no required status checks.')
25
+ const duplicateNames = required.filter((name) => /\b([^/]+) \/ \1\b/i.test(name))
26
+ if (duplicateNames.length) errors.push(`required checks contain duplicate workflow prefixes: ${duplicateNames.join(', ')}`)
27
27
 
28
28
  const sha = String(ghJson(['api', `repos/${repository}/git/ref/heads/main`])?.object?.sha ?? '')
29
29
  const checks = sha ? ghJson(['api', `repos/${repository}/commits/${sha}/check-runs?per_page=100`])?.check_runs ?? [] : []
30
30
  const observed = checks.map(/** @param {any} check */ (check) => check.name).filter(Boolean)
31
31
  details.observedChecks = observed
32
32
  if (!observed.length) warnings.push('no check runs were observed on the current main commit; exact check validation is deferred until CI runs.')
33
- if (protection?.required_status_checks) {
34
- const required = requiredContexts(protection)
33
+ if (required.length) {
35
34
  const missing = required.filter((name) => observed.length && !observed.includes(name))
36
35
  if (missing.length) warnings.push(`required checks not observed on current main: ${missing.join(', ')}`)
37
36
  }
@@ -70,13 +69,62 @@ export function doctorGithub(root) {
70
69
  }
71
70
 
72
71
  /** @param {any} protection @returns {string[]} */
73
- function requiredContexts(protection) {
72
+ function requiredContextsFromProtection(protection) {
74
73
  return [...new Set([
75
- ...(protection.required_status_checks?.contexts ?? []),
76
- ...(protection.required_status_checks?.checks ?? []).map(/** @param {any} check */ (check) => check.context),
74
+ ...(protection?.required_status_checks?.contexts ?? []),
75
+ ...(protection?.required_status_checks?.checks ?? []).map(/** @param {any} check */ (check) => check.context),
77
76
  ].filter(Boolean))]
78
77
  }
79
78
 
79
+ /** @param {any[]} rulesets @returns {string[]} */
80
+ function requiredContextsFromRulesets(rulesets) {
81
+ return [...new Set(
82
+ (Array.isArray(rulesets) ? rulesets : []).flatMap((/** @type {any} */ ruleset) =>
83
+ (ruleset.rules ?? []).flatMap((/** @type {any} */ rule) =>
84
+ rule?.type === 'required_status_checks' ?
85
+ (rule.parameters?.required_status_checks ?? []).map((/** @type {any} */ check) => check?.context).filter(Boolean) :
86
+ []
87
+ ),
88
+ ),
89
+ )]
90
+ }
91
+
92
+ /** @param {string} repository @param {any[] | null} rulesets @returns {any[]} */
93
+ function hydrateRulesets(repository, rulesets) {
94
+ if (!Array.isArray(rulesets)) return []
95
+ return rulesets.map((ruleset) => {
96
+ if (!ruleset?.id) return ruleset
97
+ const details = ghJson(['api', `repos/${repository}/rulesets/${ruleset.id}`])
98
+ return details && typeof details === 'object' ? details : ruleset
99
+ })
100
+ }
101
+
102
+ /** @param {any[] | null} rulesets @param {string} branch @returns {any[]} */
103
+ function rulesetsForBranch(rulesets, branch) {
104
+ if (!Array.isArray(rulesets)) return []
105
+ return rulesets.filter((ruleset) => (
106
+ ruleset?.target === 'branch' &&
107
+ Array.isArray(ruleset?.conditions?.ref_name?.include) &&
108
+ Array.isArray(ruleset?.conditions?.ref_name?.exclude) &&
109
+ includesBranch(ruleset.conditions.ref_name.include, branch) &&
110
+ !includesBranch(ruleset.conditions.ref_name.exclude, branch)
111
+ ))
112
+ }
113
+
114
+ /** @param {(string | null | undefined)[]} patterns @param {string} branch @returns {boolean} */
115
+ function includesBranch(patterns, branch) {
116
+ const target = `refs/heads/${branch}`
117
+ return patterns.some((pattern) => {
118
+ if (!pattern) return false
119
+ if (pattern === '~ALL') return true
120
+ if (pattern === target || pattern === branch) return true
121
+ if (!/[\*?]/.test(pattern)) return false
122
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*').replace(/\\\?/g, '.')
123
+ const regex = new RegExp(`^${escaped}$`)
124
+ return regex.test(target) || regex.test(branch)
125
+ })
126
+ }
127
+
80
128
  /** @param {string} root @returns {{ errors: string[], warnings: string[] }} */
81
129
  function inspectWorkflows(root) {
82
130
  /** @type {string[]} */
@@ -66,6 +66,45 @@ export function buildReleaseConfig(root, existing = {}) {
66
66
  return result
67
67
  }
68
68
 
69
+ /**
70
+ * Build the initial Release Please manifest from current package versions.
71
+ * Returns null when the config does not enable manifest mode (legacy configs
72
+ * without `packages`/`release-type` run in simple mode and need no manifest).
73
+ * @param {string} root
74
+ * @param {Record<string, any>} config
75
+ * @returns {Record<string, string>|null}
76
+ */
77
+ export function buildReleaseManifest(root, config = {}) {
78
+ const packages = config.packages && typeof config.packages === 'object' && !Array.isArray(config.packages)
79
+ ? config.packages
80
+ : config['release-type'] ? { '.': {} } : null
81
+ if (!packages || !Object.keys(packages).length) return null
82
+ /** @type {Record<string, string>} */
83
+ const manifest = {}
84
+ for (const directory of Object.keys(packages)) {
85
+ manifest[directory] = readDirectoryVersion(join(root, directory === '.' ? '' : directory))
86
+ }
87
+ return manifest
88
+ }
89
+
90
+ /** @param {string} directory @returns {string} */
91
+ function readDirectoryVersion(directory) {
92
+ const packagePath = join(directory, 'package.json')
93
+ if (existsSync(packagePath)) {
94
+ try {
95
+ const parsed = JSON.parse(readFileSync(packagePath, 'utf8'))
96
+ if (typeof parsed?.version === 'string' && parsed.version) return parsed.version
97
+ } catch { /* fall through to other manifests */ }
98
+ }
99
+ for (const file of ['pyproject.toml', 'Cargo.toml']) {
100
+ const path = join(directory, file)
101
+ if (!existsSync(path)) continue
102
+ const match = readFileSync(path, 'utf8').match(/^version\s*=\s*["']([^"']+)["']/m)
103
+ if (match?.[1]) return match[1]
104
+ }
105
+ return '0.0.0'
106
+ }
107
+
69
108
  /** @param {string} root @param {Record<string, any>} config @returns {string[]} */
70
109
  export function validateReleaseConfig(root, config) {
71
110
  const detected = detectReleasePackages(root)