muse-crew 0.14.2 → 0.14.3
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/docs/decisions/AGENTS.md +3 -0
- package/docs/decisions/publish-path.md +189 -0
- package/docs/publish-verification.md +5 -2
- package/docs/reviews/critic-0143-redo.md +94 -0
- package/docs/reviews/critic-0143.md +108 -0
- package/lib/AGENTS.md +4 -3
- package/lib/crew-api.js +8 -6
- package/lib/publish-npm.sh +39 -10
- package/lib/retry-publish.js +5 -2
- package/lib/test-detached-integrate.sh +79 -3
- package/lib/test-publish-preflight.sh +94 -5
- package/lib/verify-publish.js +3 -4
- package/lib/worktree-lifecycle.sh +67 -25
- package/package.json +1 -1
- package/workflows/bugfix.js +27 -26
- package/workflows/chore.js +27 -26
- package/workflows/standard.js +27 -26
package/lib/publish-npm.sh
CHANGED
|
@@ -82,7 +82,42 @@ if [ "$PREFLIGHT_CHECK_CODE" -ne 0 ] \
|
|
|
82
82
|
fi
|
|
83
83
|
# PREFLIGHT-END
|
|
84
84
|
|
|
85
|
-
#
|
|
85
|
+
# PUSH-TARGET-ANCHOR: resolve the push destination before ANY mutation.
|
|
86
|
+
# The destination is the integration target resolved by the lifecycle, not a
|
|
87
|
+
# hardcoded `main` (2026-09-19 REVIEW: the old `git push origin main` was a
|
|
88
|
+
# stale clone of the same hardcoding the integrate fix removed). A detached
|
|
89
|
+
# HEAD pushes as HEAD:<destination>, with the destination resolved explicitly
|
|
90
|
+
# by the lifecycle's push-destination command (2026-09-19 detached-HEAD audit
|
|
91
|
+
# REDO: the old code refused every detached checkout; detached publication
|
|
92
|
+
# is legitimate and must work). Fail here, before the version-bump commit
|
|
93
|
+
# and the registry publish, only when the destination is genuinely
|
|
94
|
+
# unknowable -- never after a side effect you cannot roll back. (The old
|
|
95
|
+
# position of this check -- step 13, after the npm publish -- left a shipped
|
|
96
|
+
# version with a failed phase and a retry loop.)
|
|
97
|
+
# No-origin is checked for BOTH shapes (2026-09-19 critic finding F-A1):
|
|
98
|
+
# step 13 pushes unconditionally, so an attached checkout with no origin
|
|
99
|
+
# would otherwise fail PUBLISH_FAILED=push after the version-bump commit
|
|
100
|
+
# and the registry publish. A version-bump commit with nowhere to record it
|
|
101
|
+
# would ship an npm version with no git provenance, so no-remote fails
|
|
102
|
+
# closed here rather than skipping like the integrate path (F-F6).
|
|
103
|
+
[ -n "${LIFECYCLE:-}" ] || fail "env" "LIFECYCLE is not set -- cannot resolve the push destination before mutation (F-R4)"
|
|
104
|
+
PUSH_TARGET="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" integration-target 2>&1)" \
|
|
105
|
+
|| fail "integration-target" "$PUSH_TARGET"
|
|
106
|
+
if ! git -C "$REPO_PATH" remote get-url origin >/dev/null 2>&1; then
|
|
107
|
+
fail "env" "no origin remote configured: the version-bump commit could not be pushed anywhere -- no version-bump commit was created and nothing was published"
|
|
108
|
+
fi
|
|
109
|
+
PUSH_REFSPEC="$PUSH_TARGET"
|
|
110
|
+
if [ "$PUSH_TARGET" = "HEAD" ]; then
|
|
111
|
+
PUSH_DEST="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" push-destination 2>&1)" \
|
|
112
|
+
|| fail "env" "$PUSH_DEST"
|
|
113
|
+
PUSH_REFSPEC="HEAD:$PUSH_DEST"
|
|
114
|
+
fi
|
|
115
|
+
# PUSH-TARGET-END
|
|
116
|
+
|
|
117
|
+
# 3. Dry run: markers only, no lock, no deploy, no mutation. The push
|
|
118
|
+
# preflight above already ran, so even a dry run fails closed on a
|
|
119
|
+
# genuinely unknowable push destination (see case 7 in
|
|
120
|
+
# lib/test-publish-preflight.sh -- F-R9).
|
|
86
121
|
if [ "${DRY_RUN:-0}" = "1" ]; then
|
|
87
122
|
echo "PUBLISH_DRY_RUN=1"
|
|
88
123
|
exit 0
|
|
@@ -281,15 +316,9 @@ LOCK_OUT2="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE
|
|
|
281
316
|
|| fail "lock-refresh" "$LOCK_OUT2"
|
|
282
317
|
|
|
283
318
|
# 13. Push the version-bump commit (idempotent: no-op if already pushed).
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
PUSH_TARGET="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" integration-target 2>&1)" \
|
|
288
|
-
|| fail "integration-target" "$PUSH_TARGET"
|
|
289
|
-
if [ "$PUSH_TARGET" = "HEAD" ]; then
|
|
290
|
-
fail "push" "detached HEAD: publish-npm cannot push a version-bump commit from a detached checkout"
|
|
291
|
-
fi
|
|
292
|
-
PUSH_OUT="$(git -C "$REPO_PATH" push origin "$PUSH_TARGET" 2>&1)" \
|
|
319
|
+
# PUSH_REFSPEC was resolved in the preflight above -- a branch name, or
|
|
320
|
+
# HEAD:<destination> on a detached checkout (never a bare HEAD).
|
|
321
|
+
PUSH_OUT="$(git -C "$REPO_PATH" push origin "$PUSH_REFSPEC" 2>&1)" \
|
|
293
322
|
|| fail "push" "$PUSH_OUT"
|
|
294
323
|
echo "PUBLISH_PUSHED=1"
|
|
295
324
|
|
package/lib/retry-publish.js
CHANGED
|
@@ -180,8 +180,11 @@ if (phase === "prepare") {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
|
|
183
|
-
// Find the trigger entry for the manifest baseline
|
|
184
|
-
|
|
183
|
+
// Find the trigger entry for the manifest baseline: the OLDEST matching
|
|
184
|
+
// "submitted" (the trigger issuance), matching verify-publish.js's anchor.
|
|
185
|
+
// agent_id is null on the issuance entry (no receipt observed at trigger
|
|
186
|
+
// time) — the baseline comes from manifest_before, never agent_id.
|
|
187
|
+
const trigger = entries.find((e) =>
|
|
185
188
|
e.outcome === "submitted" && e.commit === commit && (attempt == null || e.attempt === attempt));
|
|
186
189
|
const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
|
|
187
190
|
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
# 6. NOT move the main branch (it still points at A)
|
|
23
23
|
# 7. write integration_target=HEAD in the merge record
|
|
24
24
|
# 8. verify-merge → VERIFIED
|
|
25
|
-
# 9. push-target →
|
|
25
|
+
# 9. push-target → NO_REMOTE_PUSH (fixture A has no origin; a detached
|
|
26
|
+
# line with no remote has nowhere to push -- loud, not silent)
|
|
26
27
|
#
|
|
27
28
|
# Fixture B (branch — the old behavior is preserved):
|
|
28
29
|
# repo on main with a local bare origin. Integrate merges, reconciles,
|
|
@@ -49,6 +50,14 @@
|
|
|
49
50
|
# crash): re-running integrate takes the lock and pushes instead of
|
|
50
51
|
# parking on MERGED_EMPTY.
|
|
51
52
|
#
|
|
53
|
+
# Fixture H (detached HEAD with an origin — 2026-09-19 detached-HEAD audit
|
|
54
|
+
# REDO): H1 proves push-destination fails closed when origin/HEAD is unset
|
|
55
|
+
# (genuinely unknowable destination); H2-H5 prove the detached line pushes
|
|
56
|
+
# explicitly as HEAD:main — inline push reports
|
|
57
|
+
# `PUSHED: origin/main (refspec HEAD:main)`, the remote ref advances to the
|
|
58
|
+
# merge commit, local main stays at the reviewed base, HEAD stays detached,
|
|
59
|
+
# verify-merge VERIFIEDs, and push-target re-pushes idempotently.
|
|
60
|
+
#
|
|
52
61
|
# Hermetic: scratch dirs under /tmp only, removed at start (and end).
|
|
53
62
|
set -u
|
|
54
63
|
|
|
@@ -126,9 +135,9 @@ echo "$rec" | grep -q '^integration_target=HEAD$' || fail "merge record missing
|
|
|
126
135
|
out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge exited non-zero: $out"
|
|
127
136
|
echo "$out" | grep -q '^VERIFIED:' || fail "verify-merge: expected VERIFIED, got: $out"
|
|
128
137
|
|
|
129
|
-
# 9. push-target
|
|
138
|
+
# 9. push-target on a detached HEAD with no remote: loud NO_REMOTE_PUSH
|
|
130
139
|
out=$(bash "$LIFECYCLE" push-target "$tid") || fail "push-target exited non-zero: $out"
|
|
131
|
-
echo "$out" | grep -q '^
|
|
140
|
+
echo "$out" | grep -q '^NO_REMOTE_PUSH: no origin remote configured' || fail "push-target: expected NO_REMOTE_PUSH, got: $out"
|
|
132
141
|
|
|
133
142
|
echo "fixture A (detached HEAD): all checks passed"
|
|
134
143
|
|
|
@@ -336,5 +345,72 @@ echo "$out" | grep -qi 'lock lost' && fail "G: misleading lock-lost error: $out"
|
|
|
336
345
|
|
|
337
346
|
echo "fixture G (push-target after MERGED_EMPTY): all checks passed"
|
|
338
347
|
|
|
348
|
+
# ---------------- Fixture H: detached HEAD with an origin ----------------
|
|
349
|
+
# (2026-09-19 detached-HEAD audit REDO: a detached line with a remote is
|
|
350
|
+
# pushed explicitly as HEAD:<destination> -- the remote's default branch --
|
|
351
|
+
# never silently skipped, never refused. The local branch the line
|
|
352
|
+
# descends from is NOT moved; only the remote ref advances.)
|
|
353
|
+
H_DIR="$T/fixture-h"
|
|
354
|
+
H_REMOTE="$T/remote-h.git"
|
|
355
|
+
git init -q --bare "$H_REMOTE"
|
|
356
|
+
mkdir -p "$H_DIR"
|
|
357
|
+
cd "$H_DIR"
|
|
358
|
+
git init -q -b main .
|
|
359
|
+
git config user.email test@test.t
|
|
360
|
+
git config user.name test
|
|
361
|
+
echo reviewed > f.txt
|
|
362
|
+
git add -A
|
|
363
|
+
git commit -qm "reviewed base"
|
|
364
|
+
H_BASE=$(git rev-parse HEAD)
|
|
365
|
+
git remote add origin "$H_REMOTE"
|
|
366
|
+
git push -q origin main
|
|
367
|
+
git fetch -q origin
|
|
368
|
+
# Detach at the reviewed base, crew-init stamp on top -- the detached line
|
|
369
|
+
# the REDO must push explicitly (set origin/HEAD only after H1).
|
|
370
|
+
git checkout -q --detach HEAD
|
|
371
|
+
echo crew-init >> f.txt
|
|
372
|
+
git commit -qam "crew-init: stamp provenance"
|
|
373
|
+
H_LINE=$(git rev-parse HEAD)
|
|
374
|
+
git symbolic-ref -q HEAD >/dev/null && fail "fixture H: HEAD should be detached"
|
|
375
|
+
export CREW_REPO="$H_DIR"
|
|
376
|
+
export CREW_HOME="$H_DIR/home"
|
|
377
|
+
export CREW_LIB="$REPO_DIR/lib"
|
|
378
|
+
# H1: origin/HEAD not set -- the destination is genuinely unknowable --
|
|
379
|
+
# push-destination fails closed (never a silent skip, never a bare-HEAD push)
|
|
380
|
+
out=$(bash "$LIFECYCLE" push-destination 2>&1) && fail "H1: push-destination should fail without origin/HEAD: $out"
|
|
381
|
+
echo "$out" | grep -q 'genuinely unknowable' || fail "H1: expected unknowable error, got: $out"
|
|
382
|
+
echo "fixture H1 (unknowable destination fails closed): passed"
|
|
383
|
+
# Now set the remote default branch explicitly (hermetic: no `remote set-head -a` network round-trip)
|
|
384
|
+
git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
|
|
385
|
+
tid="detachedorigin1"
|
|
386
|
+
|
|
387
|
+
# H2: push-destination resolves the remote default branch
|
|
388
|
+
out=$(bash "$LIFECYCLE" push-destination) || fail "H2 push-destination exited non-zero: $out"
|
|
389
|
+
[ "$out" = "main" ] || fail "H2 push-destination: expected main, got: $out"
|
|
390
|
+
|
|
391
|
+
# H3: prepare + task work, then integrate pushes inline as HEAD:main
|
|
392
|
+
out=$(bash "$LIFECYCLE" prepare "$tid") || fail "H3 prepare exited non-zero: $out"
|
|
393
|
+
echo task-work >> "$H_DIR/.worktrees/$tid/f.txt"
|
|
394
|
+
(cd "$H_DIR/.worktrees/$tid" && git commit -qam "task work")
|
|
395
|
+
out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "H3 integrate exited non-zero: $out"
|
|
396
|
+
echo "$out" | grep -q '^MERGED:' || fail "H3 integrate: expected MERGED, got: $out"
|
|
397
|
+
echo "$out" | grep -q '^PUSHED: origin/main (refspec HEAD:main)' || fail "H3 integrate: expected explicit detached PUSHED, got: $out"
|
|
398
|
+
|
|
399
|
+
# H4: HEAD still detached; the detached line survived; the remote advanced
|
|
400
|
+
git symbolic-ref -q HEAD >/dev/null && fail "H4: HEAD should still be detached"
|
|
401
|
+
H_MERGE=$(git rev-parse HEAD)
|
|
402
|
+
git merge-base --is-ancestor "$H_BASE" HEAD || fail "H4: reviewed base abandoned"
|
|
403
|
+
git merge-base --is-ancestor "$H_LINE" HEAD || fail "H4: detached line abandoned"
|
|
404
|
+
[ "$(git rev-parse main)" = "$H_BASE" ] || fail "H4: local main moved (must stay at the reviewed base)"
|
|
405
|
+
[ "$(git --git-dir="$H_REMOTE" rev-parse refs/heads/main)" = "$H_MERGE" ] || fail "H4: origin/main not updated to the merge commit"
|
|
406
|
+
|
|
407
|
+
# H5: verify-merge VERIFIEDs; push-target re-pushes idempotently as HEAD:main
|
|
408
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "H5 verify-merge exited non-zero: $out"
|
|
409
|
+
echo "$out" | grep -q '^VERIFIED:' || fail "H5: expected VERIFIED, got: $out"
|
|
410
|
+
out=$(bash "$LIFECYCLE" push-target "$tid") || fail "H5 push-target exited non-zero: $out"
|
|
411
|
+
echo "$out" | grep -q '^PUSHED: origin/main (refspec HEAD:main)' || fail "H5 push-target: expected idempotent PUSHED, got: $out"
|
|
412
|
+
|
|
413
|
+
echo "fixture H (detached HEAD + origin): all checks passed"
|
|
414
|
+
|
|
339
415
|
rm -rf "$T"
|
|
340
416
|
echo "PASS: detached-HEAD integrate fixtures"
|
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
# * old helper that does not know --check (usage to stderr, exit 1,
|
|
22
22
|
# no CREDENTIAL markers) with DRY_RUN=1 -> no skip (fall-through
|
|
23
23
|
# preserved: unknown is not absent)
|
|
24
|
+
# - detached fixtures (detached-HEAD audit REDO, 2026-09-19): a detached
|
|
25
|
+
# checkout with a local origin. Case 6 (origin/HEAD set, DRY_RUN=1) ->
|
|
26
|
+
# preflight passes through to PUBLISH_DRY_RUN=1 (no refusal — detached
|
|
27
|
+
# publication is legitimate). Case 7 (origin/HEAD deleted, DRY_RUN=1) ->
|
|
28
|
+
# PUBLISH_FAILED=env before any mutation, repo untouched (the genuinely
|
|
29
|
+
# unknowable destination fails in the preflight, ahead of the DRY_RUN
|
|
30
|
+
# block).
|
|
31
|
+
# - Case 8: attached checkout with no origin (DRY_RUN=1) -> PUBLISH_FAILED=env
|
|
32
|
+
# pre-mutation, repo untouched (2026-09-19 critic F-A1: the anchor used to
|
|
33
|
+
# check no-origin only on the detached branch, so attached+no-origin
|
|
34
|
+
# failed post-mutation after the version-bump commit and registry publish).
|
|
24
35
|
# - --check probe unit test against the real helper: on the author
|
|
25
36
|
# machine this expects exit 0 and exactly CREDENTIAL_OK=custom.npm.
|
|
26
37
|
# NOTE: this case depends on the `custom.npm` credential existing in
|
|
@@ -52,6 +63,13 @@ git -C "$SCRATCH" config user.email "test@crew"
|
|
|
52
63
|
git -C "$SCRATCH" config user.name "crew-test"
|
|
53
64
|
echo x > "$SCRATCH/f.txt"
|
|
54
65
|
git -C "$SCRATCH" add -A && git -C "$SCRATCH" commit -qm init
|
|
66
|
+
# The scratch repo needs an origin for the DRY_RUN pass-through cases
|
|
67
|
+
# (2026-09-19 critic F-A1: the push-destination anchor fails closed
|
|
68
|
+
# pre-mutation when origin is absent, attached or detached).
|
|
69
|
+
git init -q --bare "$TMPBASE/origin.git"
|
|
70
|
+
git -C "$SCRATCH" remote add origin "$TMPBASE/origin.git"
|
|
71
|
+
git -C "$SCRATCH" push -q origin main
|
|
72
|
+
git -C "$SCRATCH" remote set-head origin -a
|
|
55
73
|
|
|
56
74
|
# --- Fake helpers ---
|
|
57
75
|
cat > "$TMPBASE/helper-ok.py" <<'EOF'
|
|
@@ -78,6 +96,32 @@ print("Usage: helper-old.py <tarball-path>", file=sys.stderr)
|
|
|
78
96
|
sys.exit(1)
|
|
79
97
|
EOF
|
|
80
98
|
|
|
99
|
+
# --- Detached fixtures (detached-HEAD audit REDO, 2026-09-19): a detached
|
|
100
|
+
# checkout with a local origin. Case 6 runs with origin/HEAD set; Case 7
|
|
101
|
+
# deletes origin/HEAD first, exercising push-destination's genuinely-
|
|
102
|
+
# unknowable branch. ---
|
|
103
|
+
ORIGIN="$TMPBASE/origin-det.git"
|
|
104
|
+
git init -q --bare "$ORIGIN"
|
|
105
|
+
SCRATCH_DET="$TMPBASE/repo-detached"
|
|
106
|
+
git init -q -b main "$SCRATCH_DET"
|
|
107
|
+
git -C "$SCRATCH_DET" config user.email "test@crew"
|
|
108
|
+
git -C "$SCRATCH_DET" config user.name "crew-test"
|
|
109
|
+
echo x > "$SCRATCH_DET/f.txt"
|
|
110
|
+
git -C "$SCRATCH_DET" add -A && git -C "$SCRATCH_DET" commit -qm init
|
|
111
|
+
git -C "$SCRATCH_DET" remote add origin "$ORIGIN"
|
|
112
|
+
git -C "$SCRATCH_DET" push -q origin main
|
|
113
|
+
git -C "$SCRATCH_DET" remote set-head origin -a
|
|
114
|
+
git -C "$SCRATCH_DET" checkout -q --detach HEAD
|
|
115
|
+
|
|
116
|
+
# --- Attached no-origin fixture (F-A1): an attached checkout with no origin
|
|
117
|
+
# fails closed in the anchor, attached or detached. ---
|
|
118
|
+
SCRATCH_NOORIGIN="$TMPBASE/repo-no-origin"
|
|
119
|
+
git init -q -b main "$SCRATCH_NOORIGIN"
|
|
120
|
+
git -C "$SCRATCH_NOORIGIN" config user.email "test@crew"
|
|
121
|
+
git -C "$SCRATCH_NOORIGIN" config user.name "crew-test"
|
|
122
|
+
echo x > "$SCRATCH_NOORIGIN/f.txt"
|
|
123
|
+
git -C "$SCRATCH_NOORIGIN" add -A && git -C "$SCRATCH_NOORIGIN" commit -qm init
|
|
124
|
+
|
|
81
125
|
ACTIONABLE="npm publish not configured on this machine — see docs/guide.md#npm-releases; code changes are merged, nothing was versioned or published"
|
|
82
126
|
|
|
83
127
|
# --- Structural: extract the shipped preflight block by anchor ---
|
|
@@ -123,13 +167,23 @@ fi
|
|
|
123
167
|
|
|
124
168
|
# Runner: full publish-npm.sh against the scratch repo. The preflight exits
|
|
125
169
|
# before any mutation, so the repo needs no package.json.
|
|
170
|
+
# LIFECYCLE + CREW_HOME are exported because the preflight now resolves the
|
|
171
|
+
# push destination up front (2026-09-19: the push-destination resolution moved
|
|
172
|
+
# from step 13 to the preflight — fail before mutation, never after the
|
|
173
|
+
# registry publish; 2026-09-19 detached-HEAD audit REDO: a detached checkout
|
|
174
|
+
# resolves to HEAD:<destination>, refused only when genuinely unknowable).
|
|
175
|
+
# The scratch repo sits on branch `main`, so integration-target
|
|
176
|
+
# resolves cleanly and the npm-credential assertions are unaffected.
|
|
126
177
|
run_full() { # run_full <helper_path> [extra_env...]
|
|
127
178
|
local helper="$1"; shift
|
|
128
179
|
local run="$TMPBASE/run-$RANDOM"
|
|
129
180
|
mkdir -p "$run"
|
|
181
|
+
local crew_home="$TMPBASE/crew-home"
|
|
182
|
+
mkdir -p "$crew_home"
|
|
130
183
|
(
|
|
131
184
|
export TASK_ID="preflight-test" REPO_PATH="$SCRATCH" PKG="muse-crew" TARGET_VERSION="9.9.9"
|
|
132
185
|
export NPM_PUBLISH_PY="$helper"
|
|
186
|
+
export LIFECYCLE="$SCRIPT_DIR/worktree-lifecycle.sh" CREW_HOME="$crew_home" CREW_REPO="$SCRATCH"
|
|
133
187
|
# A bare `export` (empty "$@") would dump the environment to stdout and
|
|
134
188
|
# corrupt the captured run path — only export when extras are given.
|
|
135
189
|
if [ $# -gt 0 ]; then export "$@"; fi
|
|
@@ -140,11 +194,11 @@ run_full() { # run_full <helper_path> [extra_env...]
|
|
|
140
194
|
echo "$run"
|
|
141
195
|
}
|
|
142
196
|
|
|
143
|
-
repo_state() { #
|
|
144
|
-
local head status tgz
|
|
145
|
-
head="$(git -C "$
|
|
146
|
-
status="$(git -C "$
|
|
147
|
-
tgz="$(find "$
|
|
197
|
+
repo_state() { # repo_state [repo]: "HEAD=<sha> STATUS=<porcelain-lines> TGZ=<count>"
|
|
198
|
+
local head status tgz repo="${1:-$SCRATCH}"
|
|
199
|
+
head="$(git -C "$repo" rev-parse HEAD)"
|
|
200
|
+
status="$(git -C "$repo" status --porcelain | wc -l)"
|
|
201
|
+
tgz="$(find "$repo" -maxdepth 1 -name '*.tgz' | wc -l)"
|
|
148
202
|
echo "HEAD=$head STATUS=$status TGZ=$tgz"
|
|
149
203
|
}
|
|
150
204
|
|
|
@@ -203,6 +257,41 @@ else
|
|
|
203
257
|
no "real helper --check: code=$CHECK_CODE out='$CHECK_OUT' (expected CREDENTIAL_OK=custom.npm — this case is credential-dependent; see header)"
|
|
204
258
|
fi
|
|
205
259
|
|
|
260
|
+
# --- Case 6: detached + resolvable destination + DRY_RUN=1 -> pass-through, no refusal ---
|
|
261
|
+
RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1 REPO_PATH="$SCRATCH_DET")"
|
|
262
|
+
CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"
|
|
263
|
+
if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_DRY_RUN=1"* ]] \
|
|
264
|
+
&& [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] && [[ "$OUT" != *"PUBLISH_FAILED"* ]]; then
|
|
265
|
+
ok "detached + resolvable destination: preflight passes through to PUBLISH_DRY_RUN=1 (no refusal)"
|
|
266
|
+
else
|
|
267
|
+
no "detached resolvable: code=$CODE out='$OUT'"
|
|
268
|
+
fi
|
|
269
|
+
|
|
270
|
+
# --- Case 7: detached + origin/HEAD unset -> PUBLISH_FAILED=env before any mutation ---
|
|
271
|
+
git -C "$SCRATCH_DET" remote set-head origin -d
|
|
272
|
+
BEFORE="$(repo_state "$SCRATCH_DET")"
|
|
273
|
+
RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1 REPO_PATH="$SCRATCH_DET")"
|
|
274
|
+
CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state "$SCRATCH_DET")"
|
|
275
|
+
if [ "$CODE" -ne 0 ] && [[ "$OUT" == *"PUBLISH_FAILED=env"* ]] \
|
|
276
|
+
&& [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] && [[ "$OUT" != *"PUBLISH_DRY_RUN=1"* ]] \
|
|
277
|
+
&& [ "$BEFORE" = "$AFTER" ]; then
|
|
278
|
+
ok "detached + unknowable destination: fails closed pre-mutation, repo untouched"
|
|
279
|
+
else
|
|
280
|
+
no "detached unknowable: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
# --- Case 8: attached + no origin -> PUBLISH_FAILED=env before any mutation ---
|
|
284
|
+
BEFORE="$(repo_state "$SCRATCH_NOORIGIN")"
|
|
285
|
+
RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1 REPO_PATH="$SCRATCH_NOORIGIN")"
|
|
286
|
+
CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state "$SCRATCH_NOORIGIN")"
|
|
287
|
+
if [ "$CODE" -ne 0 ] && [[ "$OUT" == *"PUBLISH_FAILED=env"* ]] \
|
|
288
|
+
&& [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] && [[ "$OUT" != *"PUBLISH_DRY_RUN=1"* ]] \
|
|
289
|
+
&& [ "$BEFORE" = "$AFTER" ]; then
|
|
290
|
+
ok "attached + no origin: fails closed pre-mutation, repo untouched"
|
|
291
|
+
else
|
|
292
|
+
no "attached no-origin: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
|
|
293
|
+
fi
|
|
294
|
+
|
|
206
295
|
bash -n "$PUBLISH" && ok "publish-npm.sh syntax OK" || no "publish-npm.sh syntax"
|
|
207
296
|
|
|
208
297
|
echo ""
|
package/lib/verify-publish.js
CHANGED
|
@@ -298,8 +298,9 @@ if (head !== commit) {
|
|
|
298
298
|
// 2026-09-18, blocker 4: bind by exact (task_id, commit, attempt) when the
|
|
299
299
|
// attempt is known — the original and retry attempts share a commit, and
|
|
300
300
|
// positional selection is luck. Within one attempt, select the OLDEST
|
|
301
|
-
// submitted entry (the trigger issuance;
|
|
302
|
-
//
|
|
301
|
+
// submitted entry (the trigger issuance; a receipt-bearing second "submitted"
|
|
302
|
+
// may follow it — the oldest binds the trigger instant, the latest carries
|
|
303
|
+
// the observed agent_id).
|
|
303
304
|
let triggerTs = null;
|
|
304
305
|
let manifestBefore = null;
|
|
305
306
|
try {
|
|
@@ -313,8 +314,6 @@ try {
|
|
|
313
314
|
(attempt == null || e.attempt === attempt)
|
|
314
315
|
);
|
|
315
316
|
if (candidates.length > 0) {
|
|
316
|
-
// Oldest first: the trigger issuance is the first submitted entry for
|
|
317
|
-
// the attempt (observations are "build-observed" per blocker 2).
|
|
318
317
|
candidates.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
319
318
|
const e = candidates[0];
|
|
320
319
|
triggerTs = e.ts || null;
|
|
@@ -150,6 +150,24 @@ integration_target() {
|
|
|
150
150
|
git rev-parse --abbrev-ref HEAD 2>/dev/null || { echo "ERROR: cannot resolve HEAD"; return 1; }
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
# Resolve the remote branch a detached HEAD pushes to: the remote's default
|
|
154
|
+
# branch (origin/HEAD). Prints the bare branch name (no origin/ prefix).
|
|
155
|
+
# Attached checkouts never need this (their target IS the branch name).
|
|
156
|
+
# Fails when the destination is genuinely unknowable -- the caller fails
|
|
157
|
+
# closed (never a silent skip, never a refusal of a knowable push).
|
|
158
|
+
# (2026-09-19 detached-HEAD audit REDO: the old code treated detached+remote
|
|
159
|
+
# as PUSH_SKIPPED->PASS "by policy", hiding merged work that never reached
|
|
160
|
+
# the remote. The contract is explicit HEAD:<destination> semantics.)
|
|
161
|
+
resolve_push_destination() {
|
|
162
|
+
cd "$REPO"
|
|
163
|
+
local sym
|
|
164
|
+
sym=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) || {
|
|
165
|
+
echo "ERROR: detached HEAD with an origin remote, but origin/HEAD is not set -- the push destination is genuinely unknowable; set it (git remote set-head origin -a) and retry -- not pushing"
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
printf '%s\n' "${sym#origin/}"
|
|
169
|
+
}
|
|
170
|
+
|
|
153
171
|
# (2026-09-19 REVIEW: the old integration_tip() helper is deleted — it had
|
|
154
172
|
# zero call sites and cmd_verify_merge reimplemented tip resolution inline
|
|
155
173
|
# with different semantics, a drift hazard. Tip resolution lives in exactly
|
|
@@ -323,8 +341,15 @@ acquire_merge_lock() {
|
|
|
323
341
|
# refresh needed) and by cmd_push_target for the R5 manual-conflict path
|
|
324
342
|
# (which refreshes the lock first). Never force-pushes.
|
|
325
343
|
#
|
|
326
|
-
# The caller must hold the merge lock. Prints one of PUSHED /
|
|
327
|
-
# NO_REMOTE_PUSH and returns 0, or prints
|
|
344
|
+
# The caller must hold the merge lock. Prints one of PUSHED /
|
|
345
|
+
# NO_REMOTE_PUSH and returns 0, or prints PUSH_FAILED / CONFLICT / ERROR
|
|
346
|
+
# and returns 1 as labeled. (PUSH_SKIPPED belongs to cmd_push_target's
|
|
347
|
+
# nothing-merged path only -- F-R3.) The push
|
|
348
|
+
# refspec is always explicit: a branch pushes as <branch>, a detached HEAD
|
|
349
|
+
# pushes as HEAD:<destination> where the destination is the remote's
|
|
350
|
+
# default branch resolved by resolve_push_destination() -- never a silent
|
|
351
|
+
# skip (2026-09-19 detached-HEAD audit REDO: PUSH_SKIPPED->PASS on
|
|
352
|
+
# detached+remote hid merged work that never reached the remote).
|
|
328
353
|
do_push() {
|
|
329
354
|
local task_id="$1"
|
|
330
355
|
cd "$REPO"
|
|
@@ -332,17 +357,6 @@ do_push() {
|
|
|
332
357
|
local target
|
|
333
358
|
target=$(integration_target) || return 1
|
|
334
359
|
|
|
335
|
-
# Detached HEAD: by design the integration line is local to this checkout
|
|
336
|
-
# (clean-room staging pins the reviewed commit; no remote ref is claimed
|
|
337
|
-
# for the detached line), so there is nothing to push. This is a policy
|
|
338
|
-
# statement, not a git limitation — `git push origin HEAD:<ref>` would
|
|
339
|
-
# work; we choose not to push the detached line. Room evidence is
|
|
340
|
-
# preserved in room state; releases ship through the loop's Publish phase.
|
|
341
|
-
if [ "$target" = "HEAD" ]; then
|
|
342
|
-
echo "PUSH_SKIPPED: detached HEAD -- the integration line is local by design; nothing to push"
|
|
343
|
-
return 0
|
|
344
|
-
fi
|
|
345
|
-
|
|
346
360
|
# No remote configured: fail soft, loudly. (2026-09-19 REVIEW: the old
|
|
347
361
|
# code ran `git push origin` unconditionally, which exits 128 with no
|
|
348
362
|
# origin, got mislabeled PUSH_REJECTED, and failed the phase — while
|
|
@@ -354,8 +368,20 @@ do_push() {
|
|
|
354
368
|
return 0
|
|
355
369
|
fi
|
|
356
370
|
|
|
357
|
-
|
|
358
|
-
|
|
371
|
+
# Attached: branch name; detached: remote default branch via resolve_push_destination() (see header).
|
|
372
|
+
local dest refspec
|
|
373
|
+
if [ "$target" = "HEAD" ]; then
|
|
374
|
+
# Surface the resolver's reason: the caller cannot remediate a swallowed
|
|
375
|
+
# failure, and the retry-promise below must not lie about it (F-R1).
|
|
376
|
+
dest=$(resolve_push_destination) || { echo "$dest"; return 1; }
|
|
377
|
+
refspec="HEAD:$dest"
|
|
378
|
+
else
|
|
379
|
+
dest="$target"
|
|
380
|
+
refspec="$target"
|
|
381
|
+
fi
|
|
382
|
+
|
|
383
|
+
if git push origin "$refspec" 2>&1; then
|
|
384
|
+
echo "PUSHED: origin/$dest (refspec $refspec)"
|
|
359
385
|
return 0
|
|
360
386
|
fi
|
|
361
387
|
|
|
@@ -363,17 +389,17 @@ do_push() {
|
|
|
363
389
|
# "non-fast-forward" — auth errors got the same label). Recover
|
|
364
390
|
# mechanically while the lock is held: fetch, merge, retry once.
|
|
365
391
|
echo "PUSH_FAILED: push did not complete -- reconciling once while the lock is held"
|
|
366
|
-
if ! git fetch origin "$
|
|
367
|
-
echo "ERROR: git fetch origin $
|
|
392
|
+
if ! git fetch origin "$dest" 2>&1; then
|
|
393
|
+
echo "ERROR: git fetch origin $dest failed -- push not completed"
|
|
368
394
|
return 1
|
|
369
395
|
fi
|
|
370
|
-
if ! git merge --no-edit "origin/$
|
|
371
|
-
echo "CONFLICT: origin/$
|
|
396
|
+
if ! git merge --no-edit "origin/$dest" -m "merge: push-time reconcile ($task_id)" 2>&1; then
|
|
397
|
+
echo "CONFLICT: origin/$dest diverged with conflicting changes -- aborting"
|
|
372
398
|
git merge --abort 2>/dev/null || true
|
|
373
399
|
return 1
|
|
374
400
|
fi
|
|
375
|
-
if git push origin "$
|
|
376
|
-
echo "PUSHED: origin/$
|
|
401
|
+
if git push origin "$refspec" 2>&1; then
|
|
402
|
+
echo "PUSHED: origin/$dest (refspec $refspec, after reconcile)"
|
|
377
403
|
return 0
|
|
378
404
|
fi
|
|
379
405
|
echo "ERROR: push failed again after reconcile -- not pushing"
|
|
@@ -473,10 +499,12 @@ cmd_integrate() {
|
|
|
473
499
|
# the Integrate phase fails closed. Fetch and merge the remote branch now
|
|
474
500
|
# so the subsequent push is a fast-forward.
|
|
475
501
|
# Fail-soft when there is no remote; fail closed on a real conflict.
|
|
476
|
-
#
|
|
477
|
-
#
|
|
502
|
+
# Detached: there is no static branch target to reconcile against inline;
|
|
503
|
+
# do_push resolves the destination explicitly and reconciles on push
|
|
504
|
+
# failure, so the inline reconcile is skipped here (F-R2 -- the old
|
|
505
|
+
# "local by design" rationale is the rejected 0.14.2 policy).
|
|
478
506
|
if [ "$target" = "HEAD" ]; then
|
|
479
|
-
echo "NO_REMOTE_RECONCILE: detached HEAD --
|
|
507
|
+
echo "NO_REMOTE_RECONCILE: detached HEAD -- destination resolved explicitly by do_push, skipping inline reconcile"
|
|
480
508
|
elif git rev-parse --verify "origin/$target" >/dev/null 2>&1; then
|
|
481
509
|
if git fetch origin "$target" 2>&1; then
|
|
482
510
|
if ! git merge --no-edit "origin/$target" -m "merge: reconcile with origin/$target before push ($task_id)" 2>&1; then
|
|
@@ -512,7 +540,7 @@ cmd_integrate() {
|
|
|
512
540
|
# agent run added only a lock refresh). R5's manual-conflict path uses the
|
|
513
541
|
# push-target command instead.
|
|
514
542
|
if ! do_push "$task_id"; then
|
|
515
|
-
echo "ERROR: push failed after merge $merged_commit -- merge is recorded;
|
|
543
|
+
echo "ERROR: push failed after merge $merged_commit -- merge is recorded; a re-run of push-target retries transient failures, but an unknowable destination needs 'git remote set-head origin -a' (see the ERROR line above -- F-R1)"
|
|
516
544
|
return 1
|
|
517
545
|
fi
|
|
518
546
|
|
|
@@ -834,6 +862,19 @@ cmd_integration_target() {
|
|
|
834
862
|
integration_target
|
|
835
863
|
}
|
|
836
864
|
|
|
865
|
+
# Agent-side one-liner: prints the remote branch a push updates. Attached:
|
|
866
|
+
# the branch name. Detached: the resolved destination from
|
|
867
|
+
# resolve_push_destination() (fails when genuinely unknowable).
|
|
868
|
+
cmd_push_destination() {
|
|
869
|
+
local target
|
|
870
|
+
target=$(integration_target) || exit 1
|
|
871
|
+
if [ "$target" = "HEAD" ]; then
|
|
872
|
+
resolve_push_destination
|
|
873
|
+
else
|
|
874
|
+
printf '%s\n' "$target"
|
|
875
|
+
fi
|
|
876
|
+
}
|
|
877
|
+
|
|
837
878
|
# Deterministic push of the integration target. Thin wrapper over do_push
|
|
838
879
|
# for the R5 manual-conflict path (re-running `integrate` there misfires on
|
|
839
880
|
# ahead=0 -> MERGED_EMPTY, so the manually-merged line still needs a
|
|
@@ -888,6 +929,7 @@ case "$cmd" in
|
|
|
888
929
|
integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
|
|
889
930
|
verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
|
|
890
931
|
integration-target) cmd_integration_target ;;
|
|
932
|
+
push-destination) cmd_push_destination ;;
|
|
891
933
|
push-target) cmd_push_target "${1:?task_id required}" ;;
|
|
892
934
|
post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
|
|
893
935
|
terminal-cleanup) cmd_terminal_cleanup "${1:?task_id required}" ;;
|
package/package.json
CHANGED