code-foundry 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.29.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.28.0...v0.29.0) (2026-07-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **release:** detect mixed-language release manifests ([f2daa61](https://github.com/0xPlayerOne/code-foundry/commit/f2daa616f2d8cfd2883f960823e862768454fbd6))
9
+
3
10
  ## [0.28.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.18...v0.28.0) (2026-07-29)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { detectLanguages, detectPackageManager, detectProfile } from '../lib/profile.mjs'
7
7
  import { configured, includesValue, readConfig } from '../lib/config.mjs'
8
+ import { buildReleaseConfig } from '../lib/release-manifest.mjs'
8
9
 
9
10
  const standardFiles = [
10
11
  '.editorconfig', '.gitattributes', '.gitignore', 'release-please-config.json',
@@ -82,6 +83,9 @@ export function syncRepository(options) {
82
83
  if ((file === 'LICENSE' || file === 'NOTICE') && license === 'none') continue
83
84
  if (file === '.github/CODEOWNERS' && existsSync(destination)) continue
84
85
  let content = readFileSync(sourceFile)
86
+ if (file === 'release-please-config.json') {
87
+ content = Buffer.from(renderReleaseConfig(target, sourceFile))
88
+ }
85
89
  if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
86
90
  content = Buffer.from(renderWorkflow(content.toString('utf8'), config, runtimeRepository, runtimeRef))
87
91
  }
@@ -133,6 +137,21 @@ export function syncRepository(options) {
133
137
  return { changed, config }
134
138
  }
135
139
 
140
+ /** @param {string} target @param {string} sourceFile @returns {string} */
141
+ function renderReleaseConfig(target, sourceFile) {
142
+ let baseline = {}
143
+ try { baseline = JSON.parse(readFileSync(sourceFile, 'utf8')) }
144
+ catch { baseline = {} }
145
+ const destination = join(target, 'release-please-config.json')
146
+ let existing = baseline
147
+ if (existsSync(destination)) {
148
+ try { existing = JSON.parse(readFileSync(destination, 'utf8')) }
149
+ catch { existing = baseline }
150
+ }
151
+ const merged = { ...baseline, ...existing }
152
+ return `${JSON.stringify(buildReleaseConfig(target, merged), null, 2)}\n`
153
+ }
154
+
136
155
  /** @param {string} file @param {string} languages @param {string} features */
137
156
  function shouldInclude(file, languages, features) {
138
157
  if (file === 'ruff.toml') return includesValue(languages, 'python')
@@ -0,0 +1,121 @@
1
+ // @ts-check
2
+
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ const ignored = new Set(['.git', '.code-foundry', '.venv', 'node_modules', 'target', 'vendor', 'dist', 'build'])
7
+
8
+ /** @typedef {{ directory: string, manifest: string, releaseType: 'node'|'python'|'rust', packageName?: string, extraFiles: string[] }} ReleasePackage */
9
+
10
+ /** @param {string} root @returns {ReleasePackage[]} */
11
+ export function detectReleasePackages(root) {
12
+ /** @type {ReleasePackage[]} */
13
+ const packages = []
14
+ walk(root, (file) => {
15
+ const name = file.split('/').pop()
16
+ const directory = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '.'
17
+ if (name === 'package.json') {
18
+ const parsed = readJson(join(root, file))
19
+ packages.push({ directory, manifest: file, releaseType: 'node', packageName: typeof parsed?.name === 'string' ? parsed.name : undefined, extraFiles: detectExtraFiles(root, directory) })
20
+ } else if (name === 'Cargo.toml') {
21
+ packages.push({ directory, manifest: file, releaseType: 'rust', packageName: tomlPackageName(join(root, file)), extraFiles: detectExtraFiles(root, directory) })
22
+ } else if (name === 'pyproject.toml') {
23
+ packages.push({ directory, manifest: file, releaseType: 'python', packageName: pyprojectName(join(root, file)), extraFiles: detectExtraFiles(root, directory) })
24
+ }
25
+ })
26
+ return packages.sort((a, b) => a.directory.localeCompare(b.directory))
27
+ }
28
+
29
+ /**
30
+ * Merge automatic package and extra-file detection into a Release Please
31
+ * configuration without discarding repository-owned settings.
32
+ * @param {string} root
33
+ * @param {Record<string, any>} existing
34
+ */
35
+ export function buildReleaseConfig(root, existing = {}) {
36
+ const packages = detectReleasePackages(root)
37
+ const result = { ...existing }
38
+ if (!packages.length) return result
39
+ if (packages.length === 1 && packages[0].directory === '.') {
40
+ result['release-type'] ??= packages[0].releaseType
41
+ /** @type {any[]} */
42
+ const extra = Array.isArray(result['extra-files']) ? result['extra-files'] : []
43
+ result['extra-files'] = extra
44
+ for (const file of packages[0].extraFiles) {
45
+ if (!extra.some((entry) => (typeof entry === 'string' ? entry : entry?.path) === file)) extra.push(file)
46
+ }
47
+ return result
48
+ }
49
+ /** @type {Record<string, any>} */
50
+ const configuredPackages = { ...(result.packages ?? {}) }
51
+ for (const entry of packages) {
52
+ const current = { ...(configuredPackages[entry.directory] ?? {}) }
53
+ current['release-type'] ??= entry.releaseType
54
+ if (entry.packageName) current['package-name'] ??= entry.packageName
55
+ /** @type {any[]} */
56
+ const extra = Array.isArray(current['extra-files']) ? [...current['extra-files']] : []
57
+ for (const file of entry.extraFiles) if (!extra.some((item) => (typeof item === 'string' ? item : item?.path) === file)) extra.push(file)
58
+ if (extra.length) current['extra-files'] = extra
59
+ configuredPackages[entry.directory] = current
60
+ }
61
+ result.packages = configuredPackages
62
+ delete result['release-type']
63
+ return result
64
+ }
65
+
66
+ /** @param {string} root @param {Record<string, any>} config @returns {string[]} */
67
+ export function validateReleaseConfig(root, config) {
68
+ const detected = detectReleasePackages(root)
69
+ if (!detected.length) return []
70
+ const configured = config.packages && typeof config.packages === 'object' ? config.packages : null
71
+ if (detected.length > 1 && !configured) return ['mixed-language or multi-package repositories require release-please packages configuration']
72
+ if (!configured) return []
73
+ const errors = []
74
+ for (const entry of detected) {
75
+ const item = configured[entry.directory]
76
+ if (!item) errors.push(`release-please packages is missing ${entry.directory}`)
77
+ else if (item['release-type'] !== entry.releaseType) errors.push(`${entry.directory} release-type should be ${entry.releaseType}`)
78
+ }
79
+ return errors
80
+ }
81
+
82
+ /** @param {string} root @param {(file: string) => void} visit */
83
+ function walk(root, visit) {
84
+ /** @param {string} directory */
85
+ function descend(directory) {
86
+ for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) {
87
+ if (entry.isDirectory() && ignored.has(entry.name)) continue
88
+ const path = directory ? `${directory}/${entry.name}` : entry.name
89
+ if (entry.isDirectory()) descend(path)
90
+ else visit(path)
91
+ }
92
+ }
93
+ descend('')
94
+ }
95
+
96
+ /** @param {string} root @param {string} directory @returns {string[]} */
97
+ function detectExtraFiles(root, directory) {
98
+ const candidates = ['version.txt', 'VERSION', '.version', 'version.json', 'src/version.ts', 'src/version.js', 'src/version.py', 'src/version.rs']
99
+ return candidates
100
+ .map((file) => directory === '.' ? file : `${directory}/${file}`)
101
+ .filter((file) => existsSync(join(root, file)))
102
+ .map((file) => directory === '.' ? file : file.slice(directory.length + 1))
103
+ }
104
+
105
+ /** @param {string} file @returns {any} */
106
+ function readJson(file) {
107
+ try { return JSON.parse(readFileSync(file, 'utf8')) }
108
+ catch { return {} }
109
+ }
110
+
111
+ /** @param {string} file @returns {string|undefined} */
112
+ function tomlPackageName(file) {
113
+ const match = readFileSync(file, 'utf8').match(/^name\s*=\s*["']([^"']+)["']/m)
114
+ return match?.[1]
115
+ }
116
+
117
+ /** @param {string} file @returns {string|undefined} */
118
+ function pyprojectName(file) {
119
+ const match = readFileSync(file, 'utf8').match(/^(?:name|name\s*)\s*=\s*["']([^"']+)["']/m)
120
+ return match?.[1]
121
+ }