code-foundry 0.27.18 → 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/.github/workflows/release.yml +51 -0
- package/.github/workflows/release_self-ci.yml +2 -0
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/cli.mjs +21 -2
- package/src/commands/release.mjs +64 -0
- package/src/commands/sync.mjs +19 -0
- package/src/lib/release-manifest.mjs +121 -0
- package/src/lib/release-policy.mjs +108 -0
|
@@ -8,6 +8,16 @@ on:
|
|
|
8
8
|
required: false
|
|
9
9
|
type: string
|
|
10
10
|
default: ubuntu-slim
|
|
11
|
+
runtime-repository:
|
|
12
|
+
description: Repository containing the Code Foundry runtime.
|
|
13
|
+
required: false
|
|
14
|
+
type: string
|
|
15
|
+
default: 0xPlayerOne/code-foundry
|
|
16
|
+
runtime-ref:
|
|
17
|
+
description: Code Foundry runtime tag or ref.
|
|
18
|
+
required: false
|
|
19
|
+
type: string
|
|
20
|
+
default: v0.27.18
|
|
11
21
|
|
|
12
22
|
permissions:
|
|
13
23
|
contents: write
|
|
@@ -168,6 +178,47 @@ jobs:
|
|
|
168
178
|
--delete-branch
|
|
169
179
|
done
|
|
170
180
|
|
|
181
|
+
reconcile:
|
|
182
|
+
name: Release / Reconcile
|
|
183
|
+
needs: release
|
|
184
|
+
if: needs.release.outputs.release_created == 'true'
|
|
185
|
+
runs-on: ${{ inputs.runner }}
|
|
186
|
+
timeout-minutes: 15
|
|
187
|
+
permissions:
|
|
188
|
+
contents: write
|
|
189
|
+
pull-requests: write
|
|
190
|
+
env:
|
|
191
|
+
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }}
|
|
192
|
+
steps:
|
|
193
|
+
- name: Checkout repository
|
|
194
|
+
uses: actions/checkout@v7
|
|
195
|
+
with:
|
|
196
|
+
fetch-depth: 0
|
|
197
|
+
- name: Checkout runtime
|
|
198
|
+
uses: actions/checkout@v7
|
|
199
|
+
with:
|
|
200
|
+
repository: ${{ inputs.runtime-repository }}
|
|
201
|
+
ref: ${{ inputs.runtime-ref }}
|
|
202
|
+
path: .code-foundry
|
|
203
|
+
sparse-checkout: |
|
|
204
|
+
src
|
|
205
|
+
release-please-config.json
|
|
206
|
+
- name: Check staging branch
|
|
207
|
+
id: staging
|
|
208
|
+
run: |
|
|
209
|
+
if git ls-remote --exit-code origin refs/heads/staging >/dev/null 2>&1; then
|
|
210
|
+
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
211
|
+
git fetch --no-tags origin main staging
|
|
212
|
+
else
|
|
213
|
+
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
214
|
+
fi
|
|
215
|
+
- name: Reconcile release metadata
|
|
216
|
+
if: steps.staging.outputs.exists == 'true'
|
|
217
|
+
run: node .code-foundry/src/cli.mjs release reconcile --github --base main --head staging
|
|
218
|
+
- name: Skip without staging
|
|
219
|
+
if: steps.staging.outputs.exists != 'true'
|
|
220
|
+
run: echo 'No staging branch exists; release reconciliation is not applicable.'
|
|
221
|
+
|
|
171
222
|
npm:
|
|
172
223
|
name: Release / Publish npm
|
|
173
224
|
needs: release
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.28.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.18...v0.28.0) (2026-07-29)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **release:** reconcile staging after release metadata ([4de1ad6](https://github.com/0xPlayerOne/code-foundry/commit/4de1ad64bd9276344368934ab555e6cb31a90784))
|
|
16
|
+
|
|
3
17
|
## [0.27.18](https://github.com/0xPlayerOne/code-foundry/compare/v0.27.17...v0.27.18) (2026-07-29)
|
|
4
18
|
|
|
5
19
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
import { resolve } from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { doctor } from './commands/doctor.mjs'
|
|
7
|
+
import { reconcileRelease } from './commands/release.mjs'
|
|
7
8
|
import { syncRepository } from './commands/sync.mjs'
|
|
8
9
|
|
|
9
10
|
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
|
10
11
|
|
|
11
|
-
/** @typedef {{ target: string, dryRun: boolean, force: boolean }} Options */
|
|
12
|
+
/** @typedef {{ target: string, dryRun: boolean, force: boolean, github: boolean, base: string, head: string }} Options */
|
|
12
13
|
/** @typedef {{ command: string, options: Options }} ParsedArgs */
|
|
13
14
|
|
|
14
15
|
const usage = `code-foundry — initialize and maintain agent-ready repositories
|
|
@@ -17,6 +18,7 @@ Usage:
|
|
|
17
18
|
npx code-foundry init [--target PATH]
|
|
18
19
|
npx code-foundry sync [--target PATH]
|
|
19
20
|
npx code-foundry doctor [--target PATH]
|
|
21
|
+
npx code-foundry release reconcile [--github] [--base BRANCH] [--head BRANCH]
|
|
20
22
|
|
|
21
23
|
The repository configuration lives in .github/code-foundry.yml.
|
|
22
24
|
init detects the repository, creates that file, and renders the baseline.
|
|
@@ -26,6 +28,9 @@ Options:
|
|
|
26
28
|
--target PATH Repository directory (default: current directory)
|
|
27
29
|
--dry-run Preview changes without writing files
|
|
28
30
|
--force Replace protected standard documents
|
|
31
|
+
--github Apply a verified fast-forward through GitHub
|
|
32
|
+
--base BRANCH Release source branch (default: main)
|
|
33
|
+
--head BRANCH Branch to reconcile (default: staging)
|
|
29
34
|
-h, --help Show this help
|
|
30
35
|
`
|
|
31
36
|
|
|
@@ -40,7 +45,12 @@ function parseArgs(argv) {
|
|
|
40
45
|
const first = argv[0]
|
|
41
46
|
const hasCommand = Boolean(first && !first.startsWith('-'))
|
|
42
47
|
const command = hasCommand ? /** @type {string} */ (argv.shift()) : 'init'
|
|
43
|
-
const options = { target: process.cwd(), dryRun: false, force: false }
|
|
48
|
+
const options = { target: process.cwd(), dryRun: false, force: false, github: false, base: 'main', head: 'staging' }
|
|
49
|
+
|
|
50
|
+
if (command === 'release') {
|
|
51
|
+
const subcommand = argv.shift()
|
|
52
|
+
if (subcommand !== 'reconcile') fail(`unknown release command: ${subcommand ?? '(missing)'}; use release reconcile`)
|
|
53
|
+
}
|
|
44
54
|
|
|
45
55
|
while (argv.length) {
|
|
46
56
|
const arg = argv.shift()
|
|
@@ -54,6 +64,9 @@ function parseArgs(argv) {
|
|
|
54
64
|
options.target = value
|
|
55
65
|
} else if (arg === '--dry-run') options.dryRun = true
|
|
56
66
|
else if (arg === '--force') options.force = true
|
|
67
|
+
else if (arg === '--github') options.github = true
|
|
68
|
+
else if (arg === '--base') options.base = argv.shift() ?? fail('--base requires a branch')
|
|
69
|
+
else if (arg === '--head') options.head = argv.shift() ?? fail('--head requires a branch')
|
|
57
70
|
else fail(`unknown option: ${arg}; run --help for the supported options`)
|
|
58
71
|
}
|
|
59
72
|
|
|
@@ -83,6 +96,12 @@ function main() {
|
|
|
83
96
|
console.error(`code-foundry: ${error instanceof Error ? error.message : String(error)}`)
|
|
84
97
|
process.exitCode = 1
|
|
85
98
|
}
|
|
99
|
+
} else if (command === 'release') {
|
|
100
|
+
try {
|
|
101
|
+
reconcileRelease(target, options)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
fail(error instanceof Error ? error.message : String(error))
|
|
104
|
+
}
|
|
86
105
|
}
|
|
87
106
|
else fail(`unknown command: ${command}`)
|
|
88
107
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { approvedReleaseFiles, classifyReconciliation, readReleaseConfig } from '../lib/release-policy.mjs'
|
|
7
|
+
|
|
8
|
+
/** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reconcile staging after Release Please updates main. The local mode is
|
|
12
|
+
* deterministic and suitable for CI; --github applies only a verified
|
|
13
|
+
* fast-forward through the GitHub API and otherwise fails closed.
|
|
14
|
+
* @param {string} root
|
|
15
|
+
* @param {ReleaseOptions} options
|
|
16
|
+
*/
|
|
17
|
+
export function reconcileRelease(root, options) {
|
|
18
|
+
const target = resolve(root)
|
|
19
|
+
const base = options.base || 'main'
|
|
20
|
+
const head = options.head || 'staging'
|
|
21
|
+
const mainSha = git(target, ['rev-parse', `origin/${base}`]) || git(target, ['rev-parse', base])
|
|
22
|
+
const stagingSha = git(target, ['rev-parse', `origin/${head}`]) || git(target, ['rev-parse', head])
|
|
23
|
+
const mergeBaseSha = git(target, ['merge-base', mainSha, stagingSha])
|
|
24
|
+
const mainChangedPaths = diffNames(target, mergeBaseSha, mainSha)
|
|
25
|
+
const stagingChangedPaths = diffNames(target, mergeBaseSha, stagingSha)
|
|
26
|
+
const plan = classifyReconciliation({
|
|
27
|
+
mainSha,
|
|
28
|
+
stagingSha,
|
|
29
|
+
mergeBaseSha,
|
|
30
|
+
mainChangedPaths,
|
|
31
|
+
stagingChangedPaths,
|
|
32
|
+
allowed: approvedReleaseFiles(readReleaseConfig(target)),
|
|
33
|
+
})
|
|
34
|
+
console.log(JSON.stringify({ base, head, ...plan }, null, 2))
|
|
35
|
+
if (plan.action === 'fail') throw new Error(plan.reason + (plan.unexpected?.length ? ` Unexpected paths: ${plan.unexpected.join(', ')}` : ''))
|
|
36
|
+
if (plan.action !== 'fast-forward' || !options.github || options.dryRun) return plan
|
|
37
|
+
if (!process.env.GITHUB_REPOSITORY) throw new Error('GITHUB_REPOSITORY is required for --github reconciliation.')
|
|
38
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
|
39
|
+
if (!token) throw new Error('GH_TOKEN or GITHUB_TOKEN is required for --github reconciliation.')
|
|
40
|
+
const result = spawnSync('gh', [
|
|
41
|
+
'api', '--method', 'PATCH', `repos/${process.env.GITHUB_REPOSITORY}/git/refs/heads/${head}`,
|
|
42
|
+
'-f', `sha=${plan.targetSha}`, '-F', 'force=false',
|
|
43
|
+
], { cwd: target, stdio: 'inherit', env: { ...process.env, GH_TOKEN: token } })
|
|
44
|
+
if (result.status !== 0) throw new Error(`GitHub refused the protected fast-forward of ${head}.`)
|
|
45
|
+
return plan
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {string} root @param {string[]} args @returns {string} */
|
|
49
|
+
function git(root, args) {
|
|
50
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' })
|
|
51
|
+
return result.status === 0 ? result.stdout.trim() : ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {string} root @param {string} from @param {string} to @returns {string[]} */
|
|
55
|
+
function diffNames(root, from, to) {
|
|
56
|
+
if (!from || !to || from === to) return []
|
|
57
|
+
const result = spawnSync('git', ['diff', '--name-only', from, to], { cwd: root, encoding: 'utf8' })
|
|
58
|
+
return result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string} root */
|
|
62
|
+
export function releaseConfigExists(root) {
|
|
63
|
+
return existsSync(join(resolve(root), 'release-please-config.json'))
|
|
64
|
+
}
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RELEASE_FILES = new Set([
|
|
7
|
+
'.release-please-manifest.json',
|
|
8
|
+
'CHANGELOG.md',
|
|
9
|
+
'Cargo.lock',
|
|
10
|
+
'Cargo.toml',
|
|
11
|
+
'bun.lock',
|
|
12
|
+
'bun.lockb',
|
|
13
|
+
'package-lock.json',
|
|
14
|
+
'package.json',
|
|
15
|
+
'pnpm-lock.yaml',
|
|
16
|
+
'pyproject.toml',
|
|
17
|
+
'uv.lock',
|
|
18
|
+
'version.txt',
|
|
19
|
+
'yarn.lock',
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
/** @param {string} root @returns {Record<string, unknown>} */
|
|
23
|
+
export function readReleaseConfig(root) {
|
|
24
|
+
const file = join(root, 'release-please-config.json')
|
|
25
|
+
if (!existsSync(file)) return {}
|
|
26
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) }
|
|
27
|
+
catch { return {} }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Return the release files allowed to change during an automated reconciliation.
|
|
32
|
+
* Package manifests are scoped beneath their Release Please package directory.
|
|
33
|
+
* @param {Record<string, unknown>} config
|
|
34
|
+
* @returns {Set<string>}
|
|
35
|
+
*/
|
|
36
|
+
export function approvedReleaseFiles(config = {}) {
|
|
37
|
+
const allowed = new Set(DEFAULT_RELEASE_FILES)
|
|
38
|
+
addExtraFiles(allowed, config['extra-files'])
|
|
39
|
+
const packages = config.packages
|
|
40
|
+
if (packages && typeof packages === 'object' && !Array.isArray(packages)) {
|
|
41
|
+
for (const [directory, value] of Object.entries(packages)) {
|
|
42
|
+
const prefix = directory === '.' ? '' : directory.replace(/\/$/, '')
|
|
43
|
+
for (const file of DEFAULT_RELEASE_FILES) addPath(allowed, prefix, file)
|
|
44
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
45
|
+
addExtraFiles(allowed, value['extra-files'], prefix)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return allowed
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {Set<string>} allowed @param {unknown} entries @param {string} [prefix] */
|
|
53
|
+
function addExtraFiles(allowed, entries, prefix = '') {
|
|
54
|
+
if (!Array.isArray(entries)) return
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const path = typeof entry === 'string' ? entry : entry && typeof entry === 'object' && 'path' in entry ? entry.path : ''
|
|
57
|
+
if (typeof path === 'string' && path) addPath(allowed, prefix, path)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {Set<string>} allowed @param {string} prefix @param {string} path */
|
|
62
|
+
function addPath(allowed, prefix, path) {
|
|
63
|
+
const clean = path.replace(/^\.\//, '').replace(/\\/g, '/')
|
|
64
|
+
allowed.add(prefix ? `${prefix}/${clean}` : clean)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {string[]} paths @param {Set<string>} allowed @returns {string[]} */
|
|
68
|
+
export function unexpectedReleasePaths(paths, allowed) {
|
|
69
|
+
return [...new Set(paths.map((path) => path.trim()).filter(Boolean))]
|
|
70
|
+
.filter((path) => !allowed.has(path))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Classify the relationship between main and staging. A fast-forward is safe
|
|
75
|
+
* only when main is the ancestor of staging or staging is the ancestor of main
|
|
76
|
+
* and the intervening main files are release metadata.
|
|
77
|
+
* @param {{ mainSha: string, stagingSha: string, mergeBaseSha?: string, mainChangedPaths?: string[], stagingChangedPaths?: string[], allowed?: Set<string> }} input
|
|
78
|
+
*/
|
|
79
|
+
export function classifyReconciliation(input) {
|
|
80
|
+
const {
|
|
81
|
+
mainSha,
|
|
82
|
+
stagingSha,
|
|
83
|
+
mergeBaseSha = '',
|
|
84
|
+
mainChangedPaths = [],
|
|
85
|
+
stagingChangedPaths = [],
|
|
86
|
+
allowed = approvedReleaseFiles(),
|
|
87
|
+
} = input
|
|
88
|
+
if (!mainSha || !stagingSha) return { action: 'fail', reason: 'Missing branch SHA.' }
|
|
89
|
+
if (mainSha === stagingSha) return { action: 'aligned', reason: 'Branches already point at the same commit.' }
|
|
90
|
+
if (mainSha === mergeBaseSha) {
|
|
91
|
+
return { action: 'none', reason: 'staging contains commits that are not on main; no release-only reconciliation is needed.' }
|
|
92
|
+
}
|
|
93
|
+
const unexpectedMain = unexpectedReleasePaths(mainChangedPaths, allowed)
|
|
94
|
+
const unexpectedStaging = unexpectedReleasePaths(stagingChangedPaths, allowed)
|
|
95
|
+
if (unexpectedMain.length || unexpectedStaging.length) {
|
|
96
|
+
return {
|
|
97
|
+
action: 'fail',
|
|
98
|
+
reason: 'Branch divergence includes repository code or configuration.',
|
|
99
|
+
unexpected: [...new Set([...unexpectedMain, ...unexpectedStaging])],
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (stagingSha === mergeBaseSha) {
|
|
103
|
+
return { action: 'fast-forward', targetSha: mainSha, reason: 'main only added approved release metadata.' }
|
|
104
|
+
}
|
|
105
|
+
return { action: 'pull-request', targetSha: mainSha, reason: 'Branches diverged, but only approved release metadata changed.' }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { DEFAULT_RELEASE_FILES }
|