code-foundry 0.33.0 → 0.34.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.
- package/.github/workflows/release.yml +52 -2
- package/CHANGELOG.md +20 -0
- package/docs/RELEASES.md +9 -1
- package/docs/WORKFLOWS.md +13 -2
- package/package.json +1 -1
- package/src/commands/release.mjs +76 -6
|
@@ -218,6 +218,56 @@ jobs:
|
|
|
218
218
|
--watch \
|
|
219
219
|
--fail-fast \
|
|
220
220
|
--interval 10
|
|
221
|
+
# gh pr checks --watch only waits for checks registered when the
|
|
222
|
+
# watch starts; a ruleset may register additional required checks
|
|
223
|
+
# afterwards (observed: "Validation / Gate" appearing after
|
|
224
|
+
# "Tauri 2 / Linux" had passed), which leaves the PR blocked by
|
|
225
|
+
# branch policy at merge time. Poll mergeStateStatus and accept
|
|
226
|
+
# only states GitHub reports as mergeable: CLEAN, or UNSTABLE with
|
|
227
|
+
# mergeable MERGEABLE. UNSTABLE is still mergeable when only
|
|
228
|
+
# non-required checks are failing or still running (observed: Kilo
|
|
229
|
+
# Code Review running after every required check passed); keep
|
|
230
|
+
# waiting for BLOCKED/BEHIND/UNKNOWN, fail fast on
|
|
231
|
+
# DIRTY/CONFLICTING, and fail closed on a bounded timeout so the
|
|
232
|
+
# workflow never sleeps indefinitely.
|
|
233
|
+
echo "Waiting for release PR #$pr to become mergeable under branch policy"
|
|
234
|
+
merge_state=UNKNOWN
|
|
235
|
+
mergeable=UNKNOWN
|
|
236
|
+
for attempt in $(seq 1 30); do
|
|
237
|
+
: "$attempt"
|
|
238
|
+
if read -r merge_state mergeable < <(
|
|
239
|
+
gh pr view "$pr" \
|
|
240
|
+
--repo "$GITHUB_REPOSITORY" \
|
|
241
|
+
--json mergeStateStatus,mergeable \
|
|
242
|
+
--jq -r '[.mergeStateStatus, .mergeable] | @tsv' 2>/dev/null
|
|
243
|
+
); then
|
|
244
|
+
case "$merge_state" in
|
|
245
|
+
CLEAN|UNSTABLE)
|
|
246
|
+
if [ "$mergeable" = MERGEABLE ]; then
|
|
247
|
+
break
|
|
248
|
+
fi
|
|
249
|
+
;;
|
|
250
|
+
DIRTY)
|
|
251
|
+
echo "Release PR #$pr is not mergeable (mergeStateStatus=$merge_state); resolve conflicts before retrying the release." >&2
|
|
252
|
+
exit 1
|
|
253
|
+
;;
|
|
254
|
+
esac
|
|
255
|
+
if [ "$mergeable" = CONFLICTING ]; then
|
|
256
|
+
echo "Release PR #$pr is not mergeable (mergeable=$mergeable); resolve conflicts before retrying the release." >&2
|
|
257
|
+
exit 1
|
|
258
|
+
fi
|
|
259
|
+
else
|
|
260
|
+
# gh API failure: read leaves the variables empty on EOF;
|
|
261
|
+
# treat the state as unknown and keep polling.
|
|
262
|
+
merge_state=UNKNOWN
|
|
263
|
+
mergeable=UNKNOWN
|
|
264
|
+
fi
|
|
265
|
+
sleep 10
|
|
266
|
+
done
|
|
267
|
+
if { [ "$merge_state" != CLEAN ] && [ "$merge_state" != UNSTABLE ]; } || [ "$mergeable" != MERGEABLE ]; then
|
|
268
|
+
echo "Release PR #$pr did not become mergeable within the mergeability window (last mergeStateStatus=$merge_state, mergeable=$mergeable). Required checks may still be pending or branch policy blocks the merge; review the pull request before retrying." >&2
|
|
269
|
+
exit 1
|
|
270
|
+
fi
|
|
221
271
|
release_head=$(gh pr view "$pr" \
|
|
222
272
|
--repo "$GITHUB_REPOSITORY" \
|
|
223
273
|
--json headRefOid \
|
|
@@ -300,8 +350,8 @@ jobs:
|
|
|
300
350
|
if (!lines) process.exit(1)
|
|
301
351
|
process.stdout.write(`${lines}\n`)
|
|
302
352
|
NODE
|
|
303
|
-
git remote set-url origin "git@github.com:${GITHUB_REPOSITORY}.git"
|
|
304
|
-
echo "ssh_command
|
|
353
|
+
git remote set-url --push origin "git@github.com:${GITHUB_REPOSITORY}.git"
|
|
354
|
+
echo "ssh_command=ssh -F /dev/null -i $STAGING_KEY -o IdentitiesOnly=yes -o UserKnownHostsFile=$STAGING_HOSTS -o StrictHostKeyChecking=yes" >> "$GITHUB_OUTPUT"
|
|
305
355
|
- name: Configure git for trusted reconcile
|
|
306
356
|
if: steps.staging.outputs.exists == 'true' && env.STAGING_DEPLOY_KEY_PRESENT != 'true'
|
|
307
357
|
run: gh auth setup-git
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.34.1](https://github.com/0xPlayerOne/code-foundry/compare/v0.34.0...v0.34.1) (2026-08-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **release:** wait for PR mergeability before guarded auto-merge ([#311](https://github.com/0xPlayerOne/code-foundry/issues/311)) ([e9be192](https://github.com/0xPlayerOne/code-foundry/commit/e9be192e6b42020f327d42d928f2c6a835358e59))
|
|
9
|
+
|
|
10
|
+
## [0.34.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.33.0...v0.34.0) (2026-08-01)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **release:** classify reconcile push failures and log sanitized details ([#307](https://github.com/0xPlayerOne/code-foundry/issues/307)) ([0651fca](https://github.com/0xPlayerOne/code-foundry/commit/0651fca3eb4f11eeb21b91462397eef865eee7cc))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **release:** include ssh executable in reconcile transport ([#308](https://github.com/0xPlayerOne/code-foundry/issues/308)) ([49713ed](https://github.com/0xPlayerOne/code-foundry/commit/49713edeaf5262a0ed872aa6d3fdf55927b6e1c5))
|
|
21
|
+
* **release:** keep reconcile fetch over HTTPS ([#305](https://github.com/0xPlayerOne/code-foundry/issues/305)) ([022afad](https://github.com/0xPlayerOne/code-foundry/commit/022afade430a00b9656dcfa50af3056bb27a54e3))
|
|
22
|
+
|
|
3
23
|
## [0.33.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.32.4...v0.33.0) (2026-08-01)
|
|
4
24
|
|
|
5
25
|
|
package/docs/RELEASES.md
CHANGED
|
@@ -80,7 +80,15 @@ Configure a narrowly scoped `RELEASE_PLEASE_TOKEN` repository or organization
|
|
|
80
80
|
secret to enable guarded automatic merging and downstream workflows triggered
|
|
81
81
|
by the resulting release. The token needs `contents`, `issues`, and
|
|
82
82
|
`pull-requests` write permissions. Code Foundry validates every changed path in
|
|
83
|
-
the generated version pull request before using the token to merge it
|
|
83
|
+
the generated version pull request before using the token to merge it, waits
|
|
84
|
+
for the required checks to finish, and then polls the pull request's
|
|
85
|
+
`mergeStateStatus` until it is `CLEAN`, or `UNSTABLE` with `mergeable`
|
|
86
|
+
`MERGEABLE`, before merging. Non-required checks that branch policy does not
|
|
87
|
+
require (for example external code review still running after every required
|
|
88
|
+
check passed) do not block the merge; conflicts fail immediately and a bounded
|
|
89
|
+
polling window fails closed if branch or ruleset policy still blocks the merge
|
|
90
|
+
(a ruleset may register additional required checks after earlier ones have
|
|
91
|
+
already passed).
|
|
84
92
|
|
|
85
93
|
## Operational checklist
|
|
86
94
|
|
package/docs/WORKFLOWS.md
CHANGED
|
@@ -50,6 +50,15 @@ workflow refuses to run unless its strategy is exactly `squash`.
|
|
|
50
50
|
| `staging` → `main` promotion PR | Rebase (`merge_strategy: rebase`) | `merge_strategy` must be `rebase`; merge commits are rejected |
|
|
51
51
|
| Release Please version PR into `main` | Squash (`release_merge_strategy: squash`) | Release automation fails closed unless `squash`; never defaults to `merge`, never uses `--admin` |
|
|
52
52
|
|
|
53
|
+
Release auto-merge waits for required checks and then polls `mergeStateStatus`
|
|
54
|
+
until it is `CLEAN`, or `UNSTABLE` with `mergeable` `MERGEABLE`, before
|
|
55
|
+
merging. Non-required checks that branch policy does not require (for example
|
|
56
|
+
external code review) never block the merge, so a ruleset that registers
|
|
57
|
+
additional required checks after earlier ones have passed (for example
|
|
58
|
+
`Validation / Gate` appearing after a platform-specific check) cannot leave the
|
|
59
|
+
merge policy-blocked. The mergeability poll is bounded and fails closed on
|
|
60
|
+
conflicts or timeout; releases without an automation token remain manual.
|
|
61
|
+
|
|
53
62
|
Keeping `main` linear — rebase promotions and squash release PRs — is what
|
|
54
63
|
lets the post-release reconciliation fast-forward or replay `staging` safely.
|
|
55
64
|
|
|
@@ -62,8 +71,10 @@ a personal-repository ruleset for `staging` enforces a Deploy Key bypass for
|
|
|
62
71
|
checkout + GitHub API calls.
|
|
63
72
|
|
|
64
73
|
When used, the optional `STAGING_DEPLOY_KEY` is written at runtime only for
|
|
65
|
-
reconcile, used to set `GIT_SSH_COMMAND` and trusted host settings for
|
|
66
|
-
`release reconcile`
|
|
74
|
+
reconcile, then used to set `GIT_SSH_COMMAND` and trusted host settings for
|
|
75
|
+
`release reconcile` over SSH push operations only; regular `git fetch` reads still
|
|
76
|
+
use HTTPS from the checked-out origin configuration. The key material is scrubbed
|
|
77
|
+
after the step.
|
|
67
78
|
|
|
68
79
|
When absent, the job keeps `GH_TOKEN = github.token` and runs `gh auth setup-git`
|
|
69
80
|
so repositories without a Deploy Key ruleset bypass still reconcile successfully.
|
package/package.json
CHANGED
package/src/commands/release.mjs
CHANGED
|
@@ -44,7 +44,8 @@ export function reconcileRelease(root, options) {
|
|
|
44
44
|
const remoteSha = remoteRefSha(target, head)
|
|
45
45
|
if (remoteSha === state.stagingSha) {
|
|
46
46
|
throw new Error(`${head} synchronization was rejected by an exact lease while remote ${head} tip remained ${state.stagingSha}. ` +
|
|
47
|
-
'Update branch protection or remote policy to permit this mutation, then retry.'
|
|
47
|
+
'Update branch protection or remote policy to permit this mutation, then retry. ' +
|
|
48
|
+
`Last failure detail: ${mutation.error}`)
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
throw new Error(`Reconciliation of ${head} was retried but failed while the branch moved concurrently.`)
|
|
@@ -150,15 +151,55 @@ function remoteRefSha(target, branch) {
|
|
|
150
151
|
function executeReconciliationMutation(target, head, state) {
|
|
151
152
|
if (!state.plan.targetSha) return { success: false, retry: false, error: `No target SHA for ${head} reconciliation.` }
|
|
152
153
|
if (state.plan.action === 'rebase-staging') {
|
|
153
|
-
|
|
154
|
+
let replaySha
|
|
155
|
+
try {
|
|
156
|
+
replaySha = replayOntoMain(target, state.mainSha, state.stagingOnlyCommits)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
retry: false,
|
|
161
|
+
error: `Replay conflict while applying staging-only commits before replay: ${String(error instanceof Error ? error.message : error)}`,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
154
165
|
const push = pushWithLease(target, head, state.stagingSha, replaySha)
|
|
155
166
|
if (push.status === 0) return { success: true, result: { synchronization: 'replay', replaySha } }
|
|
156
|
-
|
|
167
|
+
const classification = classifyPushFailure(head, push.message)
|
|
168
|
+
if (classification.category === 'authentication') {
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
retry: false,
|
|
172
|
+
error: `${head} authentication failed during replay push: ${classification.message}`,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
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
|
+
}
|
|
182
|
+
return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
|
|
157
183
|
}
|
|
158
184
|
const targetSha = /** @type {string} */ (state.plan.targetSha)
|
|
159
185
|
const leased = pushWithLease(target, head, state.stagingSha, targetSha)
|
|
160
186
|
if (leased.status === 0) return { success: true, result: { synchronization: state.plan.action === 'aligned' ? 'synced' : 'leased' } }
|
|
161
|
-
|
|
187
|
+
const classification = classifyPushFailure(head, leased.message)
|
|
188
|
+
if (classification.category === 'authentication') {
|
|
189
|
+
return {
|
|
190
|
+
success: false,
|
|
191
|
+
retry: false,
|
|
192
|
+
error: `${head} authentication failed during push: ${classification.message}`,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (classification.category === 'policy') {
|
|
196
|
+
return {
|
|
197
|
+
success: false,
|
|
198
|
+
retry: false,
|
|
199
|
+
error: `${head} reconciliation was blocked by branch policy: ${classification.message}`,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
|
|
162
203
|
}
|
|
163
204
|
|
|
164
205
|
/** @param {string} target @param {string} head @param {string} expectedSha @param {string} tipSha */
|
|
@@ -169,10 +210,38 @@ function pushWithLease(target, head, expectedSha, tipSha) {
|
|
|
169
210
|
`--force-with-lease=refs/heads/${head}:${expectedSha}`,
|
|
170
211
|
`${tipSha}:refs/heads/${head}`,
|
|
171
212
|
], { cwd: target, encoding: 'utf8' })
|
|
213
|
+
const message = `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim() || `push with lease failed for ${head}.`
|
|
172
214
|
return {
|
|
173
215
|
status: result.status,
|
|
174
|
-
message:
|
|
216
|
+
message: sanitizeReconcileOutput(message),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Classify a push failure to avoid leaking sensitive details and provide useful
|
|
222
|
+
* diagnostics while preserving exact-lease behavior.
|
|
223
|
+
* @param {string} branch
|
|
224
|
+
* @param {string} raw
|
|
225
|
+
*/
|
|
226
|
+
function classifyPushFailure(branch, raw) {
|
|
227
|
+
const message = sanitizeReconcileOutput(raw)
|
|
228
|
+
if (!message) return { category: 'other', message: `push with lease failed for ${branch}.` }
|
|
229
|
+
const lower = message.toLowerCase()
|
|
230
|
+
if (/permission denied|authentication failed|could not read from remote repository|publickey|not authorized|bad credentials/.test(lower)) {
|
|
231
|
+
return { category: 'authentication', message }
|
|
232
|
+
}
|
|
233
|
+
if (/remote:\s*error|protected branch|required status checks|pre-receive hook|branch policy|ruleset|gh006|gh007|gh008/.test(lower)) {
|
|
234
|
+
return { category: 'policy', message }
|
|
175
235
|
}
|
|
236
|
+
return { category: 'other', message }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @param {string} value */
|
|
240
|
+
function sanitizeReconcileOutput(value) {
|
|
241
|
+
return value
|
|
242
|
+
.replace(/https?:\/\/[^\s@]+:[^\s@]*@/g, 'https://***:***@')
|
|
243
|
+
.replace(/https?:\/\/[^\s@]+@/g, 'https://***@')
|
|
244
|
+
.trim()
|
|
176
245
|
}
|
|
177
246
|
|
|
178
247
|
/**
|
|
@@ -198,7 +267,8 @@ function replayOntoMain(target, mainSha, commits) {
|
|
|
198
267
|
const cherryPick = spawnSync('git', [...identityArgs, 'cherry-pick', sha], { cwd: workspace, encoding: 'utf8' })
|
|
199
268
|
if (cherryPick.status !== 0) {
|
|
200
269
|
spawnSync('git', ['cherry-pick', '--abort'], { cwd: workspace, encoding: 'utf8' })
|
|
201
|
-
|
|
270
|
+
const details = [cherryPick.stdout?.trim(), cherryPick.stderr?.trim()].filter(Boolean).join('\n')
|
|
271
|
+
throw new Error(`Cherry-pick of ${sha} failed while replaying staging commits: ${sanitizeReconcileOutput(details)}`)
|
|
202
272
|
}
|
|
203
273
|
}
|
|
204
274
|
const replayTip = git(workspace, ['rev-parse', 'HEAD'])
|