code-foundry 0.34.3 → 0.34.5
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/code-foundry.yml +1 -1
- package/.github/workflows/opencode-security_self-ci.yml +3 -1
- package/.github/workflows/release.yml +4 -4
- package/CHANGELOG.md +14 -0
- package/docs/WORKFLOWS.md +10 -0
- package/package.json +4 -3
- package/src/commands/release.mjs +202 -22
- package/src/lib/release-policy.mjs +86 -0
- package/src/lib/test-discovery.mjs +39 -0
- package/src/runtime.mjs +61 -7
package/.github/code-foundry.yml
CHANGED
|
@@ -41,7 +41,9 @@ jobs:
|
|
|
41
41
|
if: needs.detect.outputs.enabled == 'true' && needs.detect.outputs.token == 'true'
|
|
42
42
|
uses: 0xPlayerOne/opencode-security/.github/workflows/opencode-security.yml@b3dce823322672b285fbe99b870ea984c01826cb # main
|
|
43
43
|
with:
|
|
44
|
-
|
|
44
|
+
# This is the scanner checkout ref, not the consumer PR ref. The
|
|
45
|
+
# reusable workflow already checks out the consumer repository.
|
|
46
|
+
source_ref: b3dce823322672b285fbe99b870ea984c01826cb
|
|
45
47
|
runner: ubuntu-latest
|
|
46
48
|
mode: standard
|
|
47
49
|
fail_on_severity: high
|
|
@@ -44,7 +44,7 @@ env:
|
|
|
44
44
|
|
|
45
45
|
jobs:
|
|
46
46
|
release:
|
|
47
|
-
name:
|
|
47
|
+
name: Version
|
|
48
48
|
runs-on: ${{ inputs.runner }}
|
|
49
49
|
timeout-minutes: 20
|
|
50
50
|
concurrency:
|
|
@@ -284,7 +284,7 @@ jobs:
|
|
|
284
284
|
done
|
|
285
285
|
|
|
286
286
|
reconcile:
|
|
287
|
-
name:
|
|
287
|
+
name: Reconcile
|
|
288
288
|
needs: release
|
|
289
289
|
# A normal promotion push can successfully run Release Please without
|
|
290
290
|
# creating a release. Reconciliation is only meaningful after a release
|
|
@@ -376,7 +376,7 @@ jobs:
|
|
|
376
376
|
run: echo 'No staging branch exists; release reconciliation is not applicable.'
|
|
377
377
|
|
|
378
378
|
post-release:
|
|
379
|
-
name:
|
|
379
|
+
name: Post Hook
|
|
380
380
|
needs: release
|
|
381
381
|
if: needs.release.outputs.release_created == 'true'
|
|
382
382
|
runs-on: ${{ inputs.runner }}
|
|
@@ -432,7 +432,7 @@ jobs:
|
|
|
432
432
|
run: echo 'No post-release workflow configured; artifact delivery is intentionally skipped.'
|
|
433
433
|
|
|
434
434
|
npm:
|
|
435
|
-
name:
|
|
435
|
+
name: Publish npm
|
|
436
436
|
needs: release
|
|
437
437
|
if: needs.release.outputs.release_created == 'true' && needs.release.outputs.npm_publish == 'true'
|
|
438
438
|
runs-on: ${{ inputs.runner }}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.34.5](https://github.com/0xPlayerOne/code-foundry/compare/v0.34.4...v0.34.5) (2026-08-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **release:** reconcile protected staging branches ([fa18bfb](https://github.com/0xPlayerOne/code-foundry/commit/fa18bfbb90aaed5c78ff70dbb3dc17d5633e7e5f))
|
|
9
|
+
|
|
10
|
+
## [0.34.4](https://github.com/0xPlayerOne/code-foundry/compare/v0.34.3...v0.34.4) (2026-08-02)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* split native test suites by category ([93c9ede](https://github.com/0xPlayerOne/code-foundry/commit/93c9ede6e202a790befe1ee0e57633e3d01dc428))
|
|
16
|
+
|
|
3
17
|
## [0.34.3](https://github.com/0xPlayerOne/code-foundry/compare/v0.34.2...v0.34.3) (2026-08-02)
|
|
4
18
|
|
|
5
19
|
|
package/docs/WORKFLOWS.md
CHANGED
|
@@ -79,6 +79,16 @@ after the step.
|
|
|
79
79
|
When absent, the job keeps `GH_TOKEN = github.token` and runs `gh auth setup-git`
|
|
80
80
|
so repositories without a Deploy Key ruleset bypass still reconcile successfully.
|
|
81
81
|
|
|
82
|
+
When the protected `staging` branch rejects the exact-lease reconcile push with
|
|
83
|
+
a branch-policy/ruleset/required-PR error, the reconcile job does not fail:
|
|
84
|
+
it opens (or reuses) a generated `code-foundry/reconcile/main-to-staging`
|
|
85
|
+
pull request whose head contains the exact target tip (the `main` tip for a
|
|
86
|
+
fast-forward, the replay tip when staging-only commits are replayed) and whose
|
|
87
|
+
body documents the reconciliation. Reruns reuse the open pull request instead
|
|
88
|
+
of duplicating it; unexpected code divergence, replay conflicts, authentication
|
|
89
|
+
errors, and ambiguous or stale pull request state still fail the job closed,
|
|
90
|
+
and exact-lease protection stays in place for every direct push.
|
|
91
|
+
|
|
82
92
|
For this reconciliation path, maintainer PATs and administrator roles are not
|
|
83
93
|
authorized bypasses; the job deliberately authenticates with `github.token`,
|
|
84
94
|
not `CODE_FOUNDRY_TOKEN` or `RELEASE_PLEASE_TOKEN`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-foundry",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.5",
|
|
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",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"format:check": "node --check src/cli.mjs",
|
|
45
45
|
"lint": "node --check src/cli.mjs",
|
|
46
46
|
"test": "node --test",
|
|
47
|
+
"test:unit": "node --test",
|
|
47
48
|
"type-check": "tsc --project jsconfig.json",
|
|
48
49
|
"pack:check": "npm pack --dry-run",
|
|
49
50
|
"prepublishOnly": "npm test && npm run pack:check"
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
"access": "public"
|
|
53
54
|
},
|
|
54
55
|
"devDependencies": {
|
|
55
|
-
"@types/node": "^
|
|
56
|
-
"typescript": "^
|
|
56
|
+
"@types/node": "^26.0.0",
|
|
57
|
+
"typescript": "^7.0.2"
|
|
57
58
|
}
|
|
58
59
|
}
|
package/src/commands/release.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join, resolve } from 'node:path'
|
|
6
6
|
import { spawnSync } from 'node:child_process'
|
|
7
|
-
import { approvedReleaseFiles, buildReleaseRecoveryPlan, classifyReconciliation, readReleaseConfig, selectGeneratedReleasePrs, validateReleasePullRequests } from '../lib/release-policy.mjs'
|
|
7
|
+
import { approvedReleaseFiles, buildReleaseRecoveryPlan, buildReconciliationPullRequestBody, classifyReconciliation, readReleaseConfig, reconciliationPullRequestBranch, reconciliationPullRequestTitle, selectGeneratedReleasePrs, selectReconciliationPullRequest, validateReleasePullRequests } from '../lib/release-policy.mjs'
|
|
8
8
|
import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib/release-hook.mjs'
|
|
9
9
|
|
|
10
10
|
/** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
|
|
@@ -14,7 +14,10 @@ import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib
|
|
|
14
14
|
*
|
|
15
15
|
* Local mode is deterministic and suitable for CI; --github uses strict
|
|
16
16
|
* lease-based mirror retries with fresh refetch/reclassification and fails
|
|
17
|
-
* closed if classification or remote mutation fails.
|
|
17
|
+
* closed if classification or remote mutation fails. When the protected
|
|
18
|
+
* staging branch rejects the exact lease push with a branch-policy/ruleset/
|
|
19
|
+
* required-PR error, the mutation is delivered through an idempotent
|
|
20
|
+
* automated synchronization pull request instead of failing the job.
|
|
18
21
|
* @param {string} root
|
|
19
22
|
* @param {ReleaseOptions} options
|
|
20
23
|
*/
|
|
@@ -38,14 +41,14 @@ export function reconcileRelease(root, options) {
|
|
|
38
41
|
state = resolveReconciliationState(target, base, head, allowed, true)
|
|
39
42
|
if (state.plan.action === 'fail') throw new Error(formatReconciliationFailure(state.plan))
|
|
40
43
|
if (state.plan.action === 'aligned' && state.mainSha === state.stagingSha) return state.plan
|
|
41
|
-
const mutation = executeReconciliationMutation(target, head, state)
|
|
42
|
-
if (mutation.success) return { ...state.plan, ...mutation.result }
|
|
43
|
-
if (
|
|
44
|
+
const mutation = executeReconciliationMutation(target, base, head, state)
|
|
45
|
+
if (mutation.success === true) return { ...state.plan, ...(mutation.result ?? {}) }
|
|
46
|
+
if (mutation.retry !== true) throw new Error(mutation.error ?? `${head} reconciliation failed.`)
|
|
44
47
|
const remoteSha = remoteRefSha(target, head)
|
|
45
48
|
if (remoteSha === state.stagingSha) {
|
|
46
49
|
throw new Error(`${head} synchronization was rejected by an exact lease while remote ${head} tip remained ${state.stagingSha}. ` +
|
|
47
50
|
'Update branch protection or remote policy to permit this mutation, then retry. ' +
|
|
48
|
-
`Last failure detail: ${mutation.error}`)
|
|
51
|
+
`Last failure detail: ${mutation.error ?? 'unknown synchronization failure'}`)
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
54
|
throw new Error(`Reconciliation of ${head} was retried but failed while the branch moved concurrently.`)
|
|
@@ -128,10 +131,10 @@ function refreshRemoteRefs(target, base, head) {
|
|
|
128
131
|
if (result.status !== 0) throw new Error(`Failed to refresh origin/${base} and origin/${head} before reconciliation.`)
|
|
129
132
|
}
|
|
130
133
|
|
|
131
|
-
/** @param {string} target @param {string} branch */
|
|
134
|
+
/** @param {string} target @param {string} branch @returns {string | null} */
|
|
132
135
|
function remoteRefSha(target, branch) {
|
|
133
136
|
const result = spawnSync('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: target, encoding: 'utf8' })
|
|
134
|
-
if (result.status !== 0) return
|
|
137
|
+
if (result.status !== 0) return null
|
|
135
138
|
const [sha] = result.stdout.trim().split(/\t/, 1)
|
|
136
139
|
return sha || ''
|
|
137
140
|
}
|
|
@@ -139,6 +142,7 @@ function remoteRefSha(target, branch) {
|
|
|
139
142
|
/**
|
|
140
143
|
* Reconcile a single attempt from fresh state.
|
|
141
144
|
* @param {string} target
|
|
145
|
+
* @param {string} base
|
|
142
146
|
* @param {string} head
|
|
143
147
|
* @param {{
|
|
144
148
|
* plan: ReturnType<typeof classifyReconciliation>,
|
|
@@ -147,8 +151,9 @@ function remoteRefSha(target, branch) {
|
|
|
147
151
|
* mainOnlyCommits: Array<{ sha: string, changedPaths: string[] }>,
|
|
148
152
|
* stagingOnlyCommits: Array<{ sha: string, changedPaths: string[] }>,
|
|
149
153
|
* }} state
|
|
154
|
+
* @returns {{ success: false, retry: boolean, error: string } | { success: true, result: ReconciliationMutationResult }}
|
|
150
155
|
*/
|
|
151
|
-
function executeReconciliationMutation(target, head, state) {
|
|
156
|
+
function executeReconciliationMutation(target, base, head, state) {
|
|
152
157
|
if (!state.plan.targetSha) return { success: false, retry: false, error: `No target SHA for ${head} reconciliation.` }
|
|
153
158
|
if (state.plan.action === 'rebase-staging') {
|
|
154
159
|
let replaySha
|
|
@@ -173,11 +178,7 @@ function executeReconciliationMutation(target, head, state) {
|
|
|
173
178
|
}
|
|
174
179
|
}
|
|
175
180
|
if (classification.category === 'policy') {
|
|
176
|
-
return {
|
|
177
|
-
success: false,
|
|
178
|
-
retry: false,
|
|
179
|
-
error: `${head} reconciliation was blocked by branch policy: ${classification.message}`,
|
|
180
|
-
}
|
|
181
|
+
return deliverReconciliationPullRequest(target, base, head, state, replaySha, { synchronization: 'replay', replaySha }, classification.message)
|
|
181
182
|
}
|
|
182
183
|
return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
|
|
183
184
|
}
|
|
@@ -193,15 +194,189 @@ function executeReconciliationMutation(target, head, state) {
|
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
if (classification.category === 'policy') {
|
|
196
|
-
return {
|
|
197
|
-
success: false,
|
|
198
|
-
retry: false,
|
|
199
|
-
error: `${head} reconciliation was blocked by branch policy: ${classification.message}`,
|
|
200
|
-
}
|
|
197
|
+
return deliverReconciliationPullRequest(target, base, head, state, targetSha, {}, classification.message)
|
|
201
198
|
}
|
|
202
199
|
return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
|
|
203
200
|
}
|
|
204
201
|
|
|
202
|
+
/** @typedef {{ number: number, url: string, base: string, head: string, branch: string, title: string, body: string }} ReconciliationPullRequest */
|
|
203
|
+
|
|
204
|
+
/** @typedef {{ synchronization: string, replaySha?: string, pullRequest?: ReconciliationPullRequest }} ReconciliationMutationResult */
|
|
205
|
+
|
|
206
|
+
/** @param {string} error @returns {{ success: false, retry: false, error: string }} */
|
|
207
|
+
function failResult(error) {
|
|
208
|
+
return { success: false, retry: false, error }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Deliver reconciliation through an automated synchronization pull request
|
|
213
|
+
* when staging branch policy rejects the exact lease push. The head branch is
|
|
214
|
+
* deterministic and namespaced, and reuse is keyed on the exact branch, base,
|
|
215
|
+
* and title so stale or ambiguous state fails closed.
|
|
216
|
+
* @param {string} target
|
|
217
|
+
* @param {string} base
|
|
218
|
+
* @param {string} head
|
|
219
|
+
* @param {{
|
|
220
|
+
* plan: ReturnType<typeof classifyReconciliation>,
|
|
221
|
+
* mainSha: string,
|
|
222
|
+
* stagingSha: string,
|
|
223
|
+
* }} state
|
|
224
|
+
* @param {string} targetSha
|
|
225
|
+
* @param {Record<string, unknown>} extra
|
|
226
|
+
* @param {string} pushError
|
|
227
|
+
* @returns {{ success: false, retry: false, error: string } | { success: true, result: ReconciliationMutationResult }}
|
|
228
|
+
*/
|
|
229
|
+
function deliverReconciliationPullRequest(target, base, head, state, targetSha, extra, pushError) {
|
|
230
|
+
const repository = process.env.GITHUB_REPOSITORY
|
|
231
|
+
if (!repository) return failResult('GITHUB_REPOSITORY is required to open a reconciliation pull request.')
|
|
232
|
+
// base (main) is only the reconciliation source; the generated pull
|
|
233
|
+
// request must target the protected head branch (staging), so the PR base
|
|
234
|
+
// is always the reconcile head. Keep one named source of truth so the gh
|
|
235
|
+
// create call, the reuse selector, and the result metadata cannot drift.
|
|
236
|
+
const prBase = head
|
|
237
|
+
const branch = reconciliationPullRequestBranch(base, head)
|
|
238
|
+
const title = reconciliationPullRequestTitle({ targetHead: head, sourceBase: base })
|
|
239
|
+
const body = buildReconciliationPullRequestBody({
|
|
240
|
+
sourceBase: base,
|
|
241
|
+
targetHead: head,
|
|
242
|
+
mainSha: state.mainSha,
|
|
243
|
+
stagingSha: state.stagingSha,
|
|
244
|
+
targetSha,
|
|
245
|
+
action: state.plan.action,
|
|
246
|
+
pushError,
|
|
247
|
+
})
|
|
248
|
+
const expected = { targetBase: prBase, branch, title }
|
|
249
|
+
let prs = listOpenHeadPullRequests(target, repository, branch)
|
|
250
|
+
if (!prs) return failResult(`Failed to list open pull requests for reconciliation branch ${branch}.`)
|
|
251
|
+
let selection = selectReconciliationPullRequest(prs, expected)
|
|
252
|
+
if (selection.error) return failResult(selection.error)
|
|
253
|
+
if (!selection.create) {
|
|
254
|
+
if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch}.`)
|
|
255
|
+
return reuseReconciliationPullRequest(target, repository, selection.reuse, targetSha, body)
|
|
256
|
+
}
|
|
257
|
+
const pushed = pushReconciliationHead(target, branch, targetSha)
|
|
258
|
+
if (pushed.status !== 0) {
|
|
259
|
+
const classification = classifyPushFailure(branch, pushed.message)
|
|
260
|
+
if (classification.category === 'authentication') {
|
|
261
|
+
return failResult(`${branch} authentication failed while pushing the reconciliation head: ${classification.message}`)
|
|
262
|
+
}
|
|
263
|
+
return failResult(`Failed to push the reconciliation head ${branch}: ${classification.message}`)
|
|
264
|
+
}
|
|
265
|
+
prs = listOpenHeadPullRequests(target, repository, branch)
|
|
266
|
+
if (!prs) return failResult(`Failed to re-list open pull requests for reconciliation branch ${branch} after pushing its head.`)
|
|
267
|
+
selection = selectReconciliationPullRequest(prs, expected)
|
|
268
|
+
if (selection.error) return failResult(selection.error)
|
|
269
|
+
if (!selection.create) {
|
|
270
|
+
if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch} after pushing its head.`)
|
|
271
|
+
return reuseReconciliationPullRequest(target, repository, selection.reuse, targetSha, body)
|
|
272
|
+
}
|
|
273
|
+
const created = ghSpawn(target, ['pr', 'create', '--repo', repository, '--base', prBase, '--head', branch, '--title', title, '--body', body])
|
|
274
|
+
if (created.status !== 0) {
|
|
275
|
+
// A concurrent run may have created the pull request between our list
|
|
276
|
+
// and create; reuse it when it is exactly ours, otherwise fail closed.
|
|
277
|
+
prs = listOpenHeadPullRequests(target, repository, branch)
|
|
278
|
+
if (!prs) return failResult(`Failed to create the reconciliation pull request and to re-list open pull requests for ${branch}.`)
|
|
279
|
+
selection = selectReconciliationPullRequest(prs, expected)
|
|
280
|
+
if (selection.error) return failResult(selection.error)
|
|
281
|
+
if (selection.create) {
|
|
282
|
+
return failResult(`gh pr create failed for ${branch}: ${sanitizeReconcileOutput(created.stderr) || 'unknown error'}`)
|
|
283
|
+
}
|
|
284
|
+
if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch} after gh pr create failed.`)
|
|
285
|
+
return reuseReconciliationPullRequest(target, repository, selection.reuse, targetSha, body)
|
|
286
|
+
}
|
|
287
|
+
const number = parsePullRequestNumber(created.stdout)
|
|
288
|
+
if (!number) return failResult(`Created the reconciliation pull request for ${branch} but could not parse its number.`)
|
|
289
|
+
const url = created.stdout.trim()
|
|
290
|
+
return {
|
|
291
|
+
success: true,
|
|
292
|
+
result: {
|
|
293
|
+
...extra,
|
|
294
|
+
synchronization: 'pull-request',
|
|
295
|
+
pullRequest: { number, url, base: prBase, head: branch, branch, title, body },
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Refresh an existing reconciliation pull request to the exact target tip and
|
|
302
|
+
* body. The head branch is pushed under an exact lease when its tip is stale,
|
|
303
|
+
* so a concurrent move fails closed instead of being clobbered.
|
|
304
|
+
* @param {string} target
|
|
305
|
+
* @param {string} repository
|
|
306
|
+
* @param {{number?: number, title?: string, headRefName?: string, headRefOid?: string, baseRefName?: string, url?: string}} pr
|
|
307
|
+
* @param {string} targetSha
|
|
308
|
+
* @param {string} body
|
|
309
|
+
* @returns {{ success: false, retry: false, error: string } | { success: true, result: ReconciliationMutationResult }}
|
|
310
|
+
*/
|
|
311
|
+
function reuseReconciliationPullRequest(target, repository, pr, targetSha, body) {
|
|
312
|
+
const number = Number(pr.number)
|
|
313
|
+
if (String(pr.headRefOid ?? '') !== targetSha) {
|
|
314
|
+
const pushed = pushReconciliationHead(target, /** @type {string} */ (pr.headRefName), targetSha)
|
|
315
|
+
if (pushed.status !== 0) {
|
|
316
|
+
return failResult(`Failed to refresh the reconciliation branch ${pr.headRefName} to the target tip: ${sanitizeReconcileOutput(pushed.message)}`)
|
|
317
|
+
}
|
|
318
|
+
const edited = ghSpawn(target, ['pr', 'edit', String(number), '--repo', repository, '--body', body])
|
|
319
|
+
if (edited.status !== 0) {
|
|
320
|
+
return failResult(`Failed to update reconciliation pull request #${number}: ${sanitizeReconcileOutput(edited.stderr) || 'unknown error'}`)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
success: true,
|
|
325
|
+
result: {
|
|
326
|
+
synchronization: 'pull-request',
|
|
327
|
+
pullRequest: {
|
|
328
|
+
number,
|
|
329
|
+
url: pr.url ?? '',
|
|
330
|
+
base: pr.baseRefName ?? '',
|
|
331
|
+
head: pr.headRefName ?? '',
|
|
332
|
+
branch: pr.headRefName ?? '',
|
|
333
|
+
title: pr.title ?? '',
|
|
334
|
+
body,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Push the exact target tip to the deterministic reconciliation head branch,
|
|
342
|
+
* creating it when absent and refreshing it under an exact lease otherwise.
|
|
343
|
+
* @param {string} target @param {string} branch @param {string} targetSha
|
|
344
|
+
*/
|
|
345
|
+
function pushReconciliationHead(target, branch, targetSha) {
|
|
346
|
+
const existingTip = remoteRefSha(target, branch)
|
|
347
|
+
if (existingTip === null) return { status: 1, message: `could not resolve the remote reconciliation branch ${branch} before pushing.` }
|
|
348
|
+
if (existingTip === targetSha) return { status: 0, message: '' }
|
|
349
|
+
if (!existingTip) {
|
|
350
|
+
const result = spawnSync('git', ['push', 'origin', `${targetSha}:refs/heads/${branch}`], { cwd: target, encoding: 'utf8' })
|
|
351
|
+
const message = `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim() || `failed to create reconciliation branch ${branch}.`
|
|
352
|
+
return { status: result.status, message: sanitizeReconcileOutput(message) }
|
|
353
|
+
}
|
|
354
|
+
return pushWithLease(target, branch, existingTip, targetSha)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param {string} target
|
|
359
|
+
* @param {string} repository
|
|
360
|
+
* @param {string} branch
|
|
361
|
+
* @returns {Array<{number?: number, title?: string, headRefName?: string, headRefOid?: string, baseRefName?: string, url?: string}> | null}
|
|
362
|
+
*/
|
|
363
|
+
function listOpenHeadPullRequests(target, repository, branch) {
|
|
364
|
+
const result = ghSpawn(target, ['pr', 'list', '--repo', repository, '--state', 'open', '--head', branch, '--json', 'number,title,headRefName,headRefOid,baseRefName,url'])
|
|
365
|
+
if (result.status !== 0) return null
|
|
366
|
+
try {
|
|
367
|
+
const prs = JSON.parse(result.stdout)
|
|
368
|
+
return Array.isArray(prs) ? prs : null
|
|
369
|
+
} catch {
|
|
370
|
+
return null
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** @param {string} stdout @returns {number | null} */
|
|
375
|
+
function parsePullRequestNumber(stdout) {
|
|
376
|
+
const match = stdout.trim().match(/\/pull\/(\d+)\/?$/)
|
|
377
|
+
return match ? Number(match[1]) : null
|
|
378
|
+
}
|
|
379
|
+
|
|
205
380
|
/** @param {string} target @param {string} head @param {string} expectedSha @param {string} tipSha */
|
|
206
381
|
function pushWithLease(target, head, expectedSha, tipSha) {
|
|
207
382
|
const result = spawnSync('git', [
|
|
@@ -230,7 +405,7 @@ function classifyPushFailure(branch, raw) {
|
|
|
230
405
|
if (/permission denied|authentication failed|could not read from remote repository|publickey|not authorized|bad credentials/.test(lower)) {
|
|
231
406
|
return { category: 'authentication', message }
|
|
232
407
|
}
|
|
233
|
-
if (/
|
|
408
|
+
if (/changes must be made through a pull request|protected branch|protected branch hook declined|required status checks|pre-receive hook|branch policy|repository rule|ruleset|push declined due to repository rule|gh006|gh007|gh008|gh013/.test(lower)) {
|
|
234
409
|
return { category: 'policy', message }
|
|
235
410
|
}
|
|
236
411
|
return { category: 'other', message }
|
|
@@ -395,10 +570,15 @@ function commitChangedPaths(root, commitSha) {
|
|
|
395
570
|
return result.stdout.split(/\r?\n/).filter(Boolean)
|
|
396
571
|
}
|
|
397
572
|
|
|
573
|
+
/** @param {string} root @param {string[]} args @returns {{ status: number | null, stdout: string, stderr: string }} */
|
|
574
|
+
function ghSpawn(root, args) {
|
|
575
|
+
const token = process.env.RELEASE_PLEASE_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
|
576
|
+
return spawnSync('gh', args, { cwd: resolve(root), encoding: 'utf8', env: { ...process.env, ...(token ? { GH_TOKEN: token } : {}) } })
|
|
577
|
+
}
|
|
578
|
+
|
|
398
579
|
/** @param {string} root @param {string[]} args @returns {unknown} */
|
|
399
580
|
function ghJson(root, args) {
|
|
400
|
-
const
|
|
401
|
-
const result = spawnSync('gh', args, { cwd: resolve(root), encoding: 'utf8', env: { ...process.env, ...(token ? { GH_TOKEN: token } : {}) } })
|
|
581
|
+
const result = ghSpawn(root, args)
|
|
402
582
|
if (result.status !== 0) return []
|
|
403
583
|
try { return JSON.parse(result.stdout) }
|
|
404
584
|
catch { return [] }
|
|
@@ -273,3 +273,89 @@ export function classifyReconciliation(input) {
|
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
export { DEFAULT_RELEASE_FILES }
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Deterministic head branch for the automated synchronization pull request
|
|
279
|
+
* used when branch policy rejects the direct reconcile push. The branch is
|
|
280
|
+
* namespaced under code-foundry/ so it never collides with release-please or
|
|
281
|
+
* user branches, and the name is a pure function of the reconciled refs so
|
|
282
|
+
* reruns converge on the same branch and PR.
|
|
283
|
+
* @param {string} sourceBase
|
|
284
|
+
* @param {string} targetHead
|
|
285
|
+
*/
|
|
286
|
+
export function reconciliationPullRequestBranch(sourceBase, targetHead) {
|
|
287
|
+
return `code-foundry/reconcile/${sourceBase}-to-${targetHead}`
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @param {{ targetHead: string, sourceBase: string }} input */
|
|
291
|
+
export function reconciliationPullRequestTitle({ targetHead, sourceBase }) {
|
|
292
|
+
return `chore(${targetHead}): reconcile release metadata from ${sourceBase}`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Deterministic pull request body for the automated synchronization PR. The
|
|
297
|
+
* body explains that this is generated reconciliation and preserves the
|
|
298
|
+
* fail-closed contract: unexpected divergence, conflicts, authentication
|
|
299
|
+
* errors, and ambiguous state are never pushed around branch protection.
|
|
300
|
+
* @param {{
|
|
301
|
+
* sourceBase: string,
|
|
302
|
+
* targetHead: string,
|
|
303
|
+
* mainSha: string,
|
|
304
|
+
* stagingSha: string,
|
|
305
|
+
* targetSha: string,
|
|
306
|
+
* action: string,
|
|
307
|
+
* pushError?: string,
|
|
308
|
+
* }} input
|
|
309
|
+
*/
|
|
310
|
+
export function buildReconciliationPullRequestBody(input) {
|
|
311
|
+
const { sourceBase, targetHead, mainSha, stagingSha, targetSha, action, pushError = '' } = input
|
|
312
|
+
const lines = [
|
|
313
|
+
'## Automated release reconciliation',
|
|
314
|
+
'',
|
|
315
|
+
`This pull request was generated by Code Foundry release reconciliation. The protected \`${targetHead}\` branch rejected the exact-lease synchronization push that carries release metadata published on \`${sourceBase}\` (branch policy, ruleset, or required pull request review), so this pull request delivers the exact target tip through the normal review flow instead.`,
|
|
316
|
+
'',
|
|
317
|
+
`- Base \`${targetHead}\`: \`${stagingSha}\``,
|
|
318
|
+
`- \`${sourceBase}\` tip: \`${mainSha}\``,
|
|
319
|
+
`- Target tip in this branch: \`${targetSha}\``,
|
|
320
|
+
`- Synchronization: \`${action}\``,
|
|
321
|
+
]
|
|
322
|
+
const reason = pushError.replace(/\s+/g, ' ').trim()
|
|
323
|
+
if (reason) lines.push(`- Direct push rejection: \`${reason}\``)
|
|
324
|
+
lines.push(
|
|
325
|
+
'',
|
|
326
|
+
`Merging this pull request synchronizes \`${targetHead}\` with the exact target tip above; no other commits are introduced.`,
|
|
327
|
+
'',
|
|
328
|
+
'Code Foundry reconciles only release metadata. Unexpected code divergence, replay conflicts, authentication failures, and ambiguous or stale state fail the release job closed and are never pushed around branch protection. Later releases retry the direct push first; when branch policy still rejects it, this pull request is updated instead of duplicated.',
|
|
329
|
+
)
|
|
330
|
+
return lines.join('\n')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Select the single reusable automated synchronization pull request from the
|
|
335
|
+
* open pull requests that use the deterministic reconciliation branch. Reuse
|
|
336
|
+
* requires the exact generated target base and title; foreign or ambiguous
|
|
337
|
+
* state fails closed so a generated PR is never reused or overwritten by
|
|
338
|
+
* accident. targetBase is the PR base branch (the protected branch being
|
|
339
|
+
* synchronized, e.g. staging), branch is the generated head branch, and
|
|
340
|
+
* title is the exact generated title.
|
|
341
|
+
* @param {Array<{number?: number, title?: string, headRefName?: string, baseRefName?: string}>} prs
|
|
342
|
+
* @param {{ targetBase: string, branch: string, title: string }} input
|
|
343
|
+
* @returns {{ create: boolean, reuse?: {number?: number, title?: string, headRefName?: string, headRefOid?: string, baseRefName?: string, url?: string}, error?: string }}
|
|
344
|
+
*/
|
|
345
|
+
export function selectReconciliationPullRequest(prs, input) {
|
|
346
|
+
const { targetBase, branch, title } = input
|
|
347
|
+
const owned = prs.filter((pr) => String(pr.headRefName ?? '') === branch)
|
|
348
|
+
if (!owned.length) return { create: true }
|
|
349
|
+
const reusable = owned.filter((pr) => String(pr.baseRefName ?? '') === targetBase && String(pr.title ?? '') === title)
|
|
350
|
+
if (reusable.length === 1) return { create: false, reuse: reusable[0] }
|
|
351
|
+
if (reusable.length > 1) {
|
|
352
|
+
return { create: false, error: `Multiple open pull requests use the reconciliation branch ${branch}; refusing to pick one.` }
|
|
353
|
+
}
|
|
354
|
+
const foreign = owned
|
|
355
|
+
.map((pr) => `#${pr.number ?? '?'} (base ${pr.baseRefName ?? '?'}, title ${JSON.stringify(pr.title ?? '')})`)
|
|
356
|
+
.join(', ')
|
|
357
|
+
return {
|
|
358
|
+
create: false,
|
|
359
|
+
error: `Open pull request ${foreign} uses the reconciliation branch ${branch} with an unexpected base or title; refusing to reuse or overwrite it.`,
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const TEST_FILE_PATTERN = /(?:\.(?:test|spec|unit|integration|smoke|e2e)\.(?:[cm]?[jt]sx?|py)|(?:^|\/)test_[^/]+\.py$|(?:^|\/)tests\/.*\.rs$)/i
|
|
4
|
+
|
|
5
|
+
/** @param {string} file */
|
|
6
|
+
export function isTestFile(file) {
|
|
7
|
+
return TEST_FILE_PATTERN.test(file.replaceAll('\\', '/'))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @param {string} file */
|
|
11
|
+
export function testCategory(file) {
|
|
12
|
+
const normalized = file.replaceAll('\\', '/').toLowerCase()
|
|
13
|
+
const base = normalized.split('/').at(-1) ?? normalized
|
|
14
|
+
|
|
15
|
+
if (/(^|\/)(e2e|end-to-end|acceptance)(\/|$)|(?:^|[._-])(e2e|acceptance)(?:[._-]|$)/.test(normalized)) {
|
|
16
|
+
return 'e2e'
|
|
17
|
+
}
|
|
18
|
+
if (/(^|\/)(integration)(\/|$)|(?:^|[._-])integration(?:[._-]|$)/.test(normalized)) {
|
|
19
|
+
return 'integration'
|
|
20
|
+
}
|
|
21
|
+
if (/(^|\/)(smoke|live)(\/|$)|(?:^|[._-])(smoke|live)(?:[._-]|$)/.test(normalized)) {
|
|
22
|
+
return 'smoke'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (normalized.endsWith('.rs') && /(^|\/)tests\//.test(normalized)) {
|
|
26
|
+
return 'integration'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Rust files under tests/ compile as integration-test binaries. An explicit
|
|
30
|
+
// suffix still lets smoke and integration names win when they are present.
|
|
31
|
+
if (base.endsWith('_smoke.rs')) return 'smoke'
|
|
32
|
+
if (base.endsWith('_integration.rs')) return 'integration'
|
|
33
|
+
return 'unit'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {string[]} files @param {'unit'|'integration'|'e2e'|'smoke'} task */
|
|
37
|
+
export function classifyTestFiles(files, task) {
|
|
38
|
+
return files.filter((file) => isTestFile(file) && testCategory(file) === task)
|
|
39
|
+
}
|
package/src/runtime.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { resolve } from 'node:path'
|
|
|
6
6
|
import { spawnSync } from 'node:child_process'
|
|
7
7
|
import { detectPackageManager, resolveProfile } from './lib/profile.mjs'
|
|
8
8
|
import { configured, readConfig } from './lib/config.mjs'
|
|
9
|
+
import { classifyTestFiles } from './lib/test-discovery.mjs'
|
|
9
10
|
import { classifyValidationMode, evaluateValidationGate } from './lib/validation-policy.mjs'
|
|
10
11
|
import { readReleaseConfig, validateGeneratedReleaseDiff } from './lib/release-policy.mjs'
|
|
11
12
|
|
|
@@ -126,6 +127,19 @@ function capture(command, args = []) {
|
|
|
126
127
|
return result.status === 0 ? result.stdout.trim() : ''
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
/** @returns {string[]} */
|
|
131
|
+
function repositoryTestFiles() {
|
|
132
|
+
return capture('git', ['ls-files'])
|
|
133
|
+
.split(/\r?\n/)
|
|
134
|
+
.map((file) => file.trim())
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {'unit'|'integration'|'e2e'|'smoke'} task */
|
|
139
|
+
function taskTestFiles(task) {
|
|
140
|
+
return classifyTestFiles(repositoryTestFiles(), task)
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
/** @param {string[]} args @returns {[string|null, string[]]} */
|
|
130
144
|
function packageCommand(args) {
|
|
131
145
|
switch (packageManager) {
|
|
@@ -212,8 +226,16 @@ function relevant(task) {
|
|
|
212
226
|
return (js && hasRootJavascriptProject()) || (python && hasRootPythonProject()) || (rust && hasRootRustProject())
|
|
213
227
|
}
|
|
214
228
|
if (['unit', 'integration', 'e2e', 'smoke'].includes(task)) {
|
|
215
|
-
|
|
216
|
-
|
|
229
|
+
if (taskTestFiles(/** @type {'unit'|'integration'|'e2e'|'smoke'} */ (task)).length > 0) return true
|
|
230
|
+
// A project with inline Rust/Python/JavaScript unit tests is still a unit
|
|
231
|
+
// test target even when it has no conventional test file path. Other
|
|
232
|
+
// categories must have an explicit script or discoverable test files so
|
|
233
|
+
// their workflow jobs become skipped instead of successful no-ops.
|
|
234
|
+
if (task === 'unit') {
|
|
235
|
+
return (js && hasRootJavascriptProject()) || (python && hasRootPythonProject()) ||
|
|
236
|
+
(rust && hasRootRustProject()) || (hasLanguage('solidity') && hasRootJavascriptProject())
|
|
237
|
+
}
|
|
238
|
+
return false
|
|
217
239
|
}
|
|
218
240
|
return true
|
|
219
241
|
}
|
|
@@ -274,17 +296,49 @@ function ci(task) {
|
|
|
274
296
|
smoke: ['test:smoke', 'smoke'],
|
|
275
297
|
}
|
|
276
298
|
const scripts = scriptsByTask[task]
|
|
277
|
-
|
|
278
|
-
|
|
299
|
+
const scripted = scripts ? runScript(scripts) : false
|
|
300
|
+
const testFiles = taskTestFiles(/** @type {'unit'|'integration'|'e2e'|'smoke'} */ (task))
|
|
301
|
+
const javascriptTests = testFiles.filter((file) => /\.(?:[cm]?[jt]sx?)$/i.test(file))
|
|
302
|
+
const pythonTests = testFiles.filter((file) => /\.py$/i.test(file))
|
|
303
|
+
const rustTests = testFiles.filter((file) => /\.rs$/i.test(file))
|
|
304
|
+
|
|
305
|
+
// A repository script is authoritative for that package. When it is absent,
|
|
306
|
+
// use Bun's native runner with only the discovered files for the requested
|
|
307
|
+
// category; this prevents smoke/integration files from being re-run as unit
|
|
308
|
+
// tests and avoids no-op category jobs.
|
|
309
|
+
if (!scripted && (hasLanguage('typescript') || hasLanguage('javascript') || hasLanguage('solidity')) && hasRootJavascriptProject()) {
|
|
310
|
+
if (javascriptTests.length > 0 || task === 'unit') run('bun', ['test', ...javascriptTests])
|
|
311
|
+
}
|
|
312
|
+
|
|
279
313
|
if (hasLanguage('python') && hasRootPythonProject()) {
|
|
280
314
|
const python = existsSync(resolve(root, '.venv/bin/python')) ? resolve(root, '.venv/bin/python') : 'python'
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
315
|
+
if (pythonTests.length > 0 || task === 'unit') run(python, ['-m', 'pytest', ...pythonTests])
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (hasLanguage('rust') && hasRootRustProject()) {
|
|
319
|
+
if (task === 'unit') {
|
|
320
|
+
if (existsSync(resolve(root, 'src/lib.rs'))) run('cargo', ['test', '--lib'])
|
|
321
|
+
if (existsSync(resolve(root, 'src/main.rs'))) run('cargo', ['test', '--bin', packageName()])
|
|
322
|
+
if (!existsSync(resolve(root, 'src/lib.rs')) && !existsSync(resolve(root, 'src/main.rs'))) run('cargo', ['test'])
|
|
323
|
+
} else {
|
|
324
|
+
for (const file of rustTests) {
|
|
325
|
+
const match = file.match(/^tests\/(.+)\.rs$/)
|
|
326
|
+
if (match && !match[1].includes('/')) run('cargo', ['test', '--test', match[1]])
|
|
327
|
+
}
|
|
284
328
|
}
|
|
285
329
|
}
|
|
286
330
|
}
|
|
287
331
|
|
|
332
|
+
function packageName() {
|
|
333
|
+
const cargo = readFileSafe(resolve(root, 'Cargo.toml'))
|
|
334
|
+
return cargo?.match(/^name\s*=\s*"([^"]+)"/m)?.[1] ?? 'app'
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** @param {string} file */
|
|
338
|
+
function readFileSafe(file) {
|
|
339
|
+
try { return readFileSync(file, 'utf8') } catch { return null }
|
|
340
|
+
}
|
|
341
|
+
|
|
288
342
|
/** @param {string} task @param {string} ecosystem */
|
|
289
343
|
function security(task, ecosystem) {
|
|
290
344
|
if (task === 'profile') {
|