muse-crew 0.7.20 → 0.8.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/docs/guide.md +179 -7
- package/lib/AGENTS.md +2 -1
- package/lib/build-registry.js +3 -2
- package/lib/publish-npm.sh +57 -6
- package/lib/test-publish-preflight.sh +210 -0
- package/lib/test-worktree-backend.sh +51 -1
- package/lib/update-watch.js +633 -0
- package/lib/worktree-lifecycle.sh +68 -10
- package/package.json +1 -1
- package/seed/AGENTS.md +1 -0
- package/seed/cron-body-update-watch.md +13 -0
- package/seed/crons.json +14 -1
- package/seed/workflows/upgrade.md +21 -0
- package/workflows/AGENTS.md +1 -1
- package/workflows/bugfix.js +239 -14
- package/workflows/chore.js +44 -12
- package/workflows/crew-dispatch.js +43 -6
- package/workflows/crew-init.js +173 -10
- package/workflows/standard.js +44 -12
- package/workflows/upgrade.js +794 -0
package/docs/guide.md
CHANGED
|
@@ -17,7 +17,7 @@ This is the full setup and operations reference. If you're new, start with the [
|
|
|
17
17
|
- A **release** — the first immutable snapshot of the crew's runtime code.
|
|
18
18
|
- An **`.orchestration/` directory** in the crew home with identities, personas, workflow docs, and feedback conventions.
|
|
19
19
|
- A **project registration** — the task service registered as its own first project.
|
|
20
|
-
- **Cron jobs** — the scheduler state declared in `seed/crons.json`: a polling loop (every 15 minutes via Muse's scheduling, even when nobody's in the conversation).
|
|
20
|
+
- **Cron jobs** — the scheduler state declared in `seed/crons.json`: a polling loop (every 15 minutes via Muse's scheduling, even when nobody's in the conversation). The scheduler identity is dashboard-independent — owner `cli:<instance-id>`, chosen once at first init — so deleting the dashboard artifact does not stop the crew. Removing a crew entirely is `crew-uninstall`'s job.
|
|
21
21
|
|
|
22
22
|
The agent running in the main chat receives the dispatcher's claims and launches each task workflow. Workflows can't launch workflows, so this handoff is structural.
|
|
23
23
|
|
|
@@ -57,17 +57,21 @@ Dashboard arguments (optional as a pair — provide both or neither):
|
|
|
57
57
|
- `dashboardSlug` — slug of the task service artifact (must already exist).
|
|
58
58
|
- `dashboardRepoPath` — path to the dashboard's git repository (your local clone from the Prerequisites). Must be a real git repo inside the workspace. Init registers the dashboard project against this path and the crew's worktree lifecycle works from it — it is never the crew home, and init will never create it for you.
|
|
59
59
|
|
|
60
|
-
Omit both dashboard arguments for a CLI-only install: init skips project registration (create projects later with `crew-api.js create-project`)
|
|
60
|
+
Omit both dashboard arguments for a CLI-only install: init skips project registration (create projects later with `crew-api.js create-project`). The scheduler identity is the same in both modes — the crew never requires a dashboard, and attaching or removing one later never renames the crew's cron jobs.
|
|
61
61
|
|
|
62
62
|
Optional arguments:
|
|
63
63
|
- `dashboardName` — display name for the project registration (default: `"Muse Crew"`).
|
|
64
64
|
- `crewName` — the human's chosen name for the crew. Stored as plain text at `$CREW_HOME/crew-name` and returned in the init summary. The setup conversation should always ask for one; init won't fail without it.
|
|
65
65
|
- `cronIds` — manifest id → live id map for parallel instances on one account (default: `{}`). The manifest's ids are used as-is unless overridden, e.g. `{"crew-poll": "crew-poll-canary"}`.
|
|
66
|
-
- `cronOwner` — explicit owner for the created cron jobs. Defaults to `
|
|
66
|
+
- `cronOwner` — explicit owner for the created cron jobs. Defaults to `cli:<instance-id>`: the instance id is chosen at first init (the crew-home basename) and kept from `.cron-registry.json` on re-runs, so re-init never renames the crew's jobs.
|
|
67
|
+
- `autoUpdateCrew` — automatic crew upgrades on/off (default: `true`). Persists as the `auto_update_crew` config key; `false` turns the update watcher's crew check off.
|
|
68
|
+
- `autoUpdateDashboard` — automatic dashboard upgrades on/off (default: `true`). Persists as the `auto_update_dashboard` config key.
|
|
69
|
+
- `updateChannel` — `latest` (default) or `patch`: narrows crew upgrades to patch releases. Persists as the `update_channel` config key; any other value fails the init.
|
|
70
|
+
- Re-init semantics for all four policy values: an explicit input wins; otherwise the existing config value is kept; the default applies only on first init — a re-init that passes no inputs never silently resurrects a deliberate opt-out.
|
|
67
71
|
|
|
68
72
|
### What init creates
|
|
69
73
|
|
|
70
|
-
Init runs
|
|
74
|
+
Init runs five phases, each idempotent — re-running converges anything that drifted and skips what already matches:
|
|
71
75
|
|
|
72
76
|
1. **Release** — copies `crew-release.sh` to `crewHome`, runs `init` to create the first release from the source directory. From a Git checkout, it extracts `workflows/` and `lib/` via `git archive` and uses the commit hash as the release identity. From an npm install, it copies the directories directly and uses the package version (e.g., `pkg-0.1.0`). Creates a release directory, sets the `current` symlink.
|
|
73
77
|
|
|
@@ -75,7 +79,9 @@ Init runs four phases, each idempotent — re-running converges anything that dr
|
|
|
75
79
|
|
|
76
80
|
3. **Project registration** — registers the task service as a project in its own database via `createproject`, with `repo_path` set to the validated `dashboardRepoPath`. The dashboard becomes its own first project, so the crew can work on the dashboard itself. Re-running init is self-healing: a project already registered with the correct path is left alone, while one with a different path (e.g. from an older init) is repaired via `update-project`. Skipped entirely in CLI-only mode (no `dashboardSlug`) — create projects with `crew-api.js create-project` instead.
|
|
77
81
|
|
|
78
|
-
4. **Crons** — reads the manifest at `seed/crons.json` and makes the scheduler match it: missing jobs are created from the entry (title, enabled, mode, schedule, owner, timeout_secs, and the body built from the entry's template with `crewHome`
|
|
82
|
+
4. **Crons** — reads the manifest at `seed/crons.json` and makes the scheduler match it: missing jobs are created from the entry (title, enabled, mode, schedule, owner, timeout_secs, and the body built from the entry's template with `crewHome` substituted in); existing jobs are viewed and converged — drifted fields are updated, unchanged jobs are left alone. The scheduler identity (`instanceId`, `cli:<instanceId>` owner) is chosen once at first init and never renamed by re-runs: the instance id comes from the existing `.cron-registry.json` when present, otherwise the crew-home basename. One exception: init never touches `enabled` on an existing job. `enabled` is a creation-time default only — a disabled job is a deliberate human decision, and re-init must not silently resurrect it. Jobs removed from the manifest are left alone; deletion is a human decision.
|
|
83
|
+
|
|
84
|
+
5. **Update policy** — persists the automatic-update policy (`auto_update_crew`, `auto_update_dashboard`, `update_channel`) via the Crew API's `update-config`, with the re-init semantics above: explicit inputs win, existing values survive, defaults apply only on first init. Seeds `$CREW_HOME/.update-watch.json` as `{}` only when it does not exist (the watcher's idempotency record is never overwritten).
|
|
79
85
|
|
|
80
86
|
### Verification
|
|
81
87
|
|
|
@@ -93,7 +99,9 @@ After init completes, it returns a summary:
|
|
|
93
99
|
"project": "registered",
|
|
94
100
|
"crons": [
|
|
95
101
|
{ "id": "crew-poll", "action": "created", "updated_fields": [] }
|
|
96
|
-
]
|
|
102
|
+
],
|
|
103
|
+
"autoUpdate": { "crew": true, "dashboard": true, "channel": "latest" },
|
|
104
|
+
"announcement": "Your crew and dashboard will update themselves automatically; say the word if you'd rather approve each one."
|
|
97
105
|
}
|
|
98
106
|
```
|
|
99
107
|
|
|
@@ -188,7 +196,7 @@ Create a task using the `createtask` action (see the [API reference](../API.md))
|
|
|
188
196
|
Required: `title`. Everything else has defaults.
|
|
189
197
|
|
|
190
198
|
- `project` — which registered project this task belongs to.
|
|
191
|
-
- `workflow` — `"standard"`, `"bugfix"`, `"chore"`, or `"
|
|
199
|
+
- `workflow` — `"standard"`, `"bugfix"`, `"chore"`, `"docs"`, or `"upgrade"` (default: assigned at triage).
|
|
192
200
|
- `state` — `"todo"` (ready for dispatch), `"parked"` (held back), `"in_progress"`, or `"done"`.
|
|
193
201
|
- `priority` — `"high"`, `"normal"`, or `"low"`.
|
|
194
202
|
- `deps` — array of task IDs that must complete first (blocked until all deps are done).
|
|
@@ -228,6 +236,8 @@ Every 15 minutes, the `crew-poll` cron fires:
|
|
|
228
236
|
|
|
229
237
|
7. **Task completes** — when all phases finish, the dispatcher marks the task `done` on the next tick.
|
|
230
238
|
|
|
239
|
+
Every tick also records its own release identity: right after acknowledging the poll, the dispatcher appends one line — `{"seq": <n>, "release": "<active release id>"}` — to `$CREW_HOME/.tick-releases.jsonl`. `release` is the active release (the basename of the crew home's `current` symlink, the same resolution the Crew API uses), and `seq` is the file's non-empty line count plus one; the file is append-only and never rewritten. Ordering is the proof, not timestamps — deliberately, since workflow scripts may not read the clock. For the self-upgrade proof, a tick whose line names the new release is demonstrably running the newly built code. A failed write never fails the tick: the writer swallows its own errors, carries no schema, and is wrapped in try/catch.
|
|
240
|
+
|
|
231
241
|
### Failure handling and retry
|
|
232
242
|
|
|
233
243
|
A phase that fails doesn't fail the task — it fails the *session*, and the dispatcher retries the phase on the next tick. The retry state is explicit and dashboard-owned, never inferred from prose:
|
|
@@ -295,11 +305,40 @@ Configuration (per project, via the existing `getconfig` / `updateconfig` action
|
|
|
295
305
|
| bugfix | Triage → Capture → Reproduce → Map → Build → Review → Integrate → Publish → QA |
|
|
296
306
|
| chore | Triage → Capture → Map → Build → Review → Integrate → Publish |
|
|
297
307
|
| docs | Triage → Write → Review |
|
|
308
|
+
| upgrade | Triage → Deploy → Verify |
|
|
309
|
+
|
|
310
|
+
### Upgrading the crew
|
|
311
|
+
|
|
312
|
+
The crew upgrades itself through the normal dispatch loop. An upgrade task carries a `source:` line in its description naming where the new release comes from:
|
|
313
|
+
|
|
314
|
+
- `source: repo` (default when absent) — deploys the task project's repo at its current HEAD. This is how you ship repo changes to your running crew: change the code, merge to main, then file an upgrade task pointing at that repo.
|
|
315
|
+
- `source: npm@x.y.z` — installs the published `muse-crew` package at that exact version from the public npm registry. Use this to move the crew onto a known-good release — including one that predates the upgrade workflow itself.
|
|
316
|
+
|
|
317
|
+
Both paths deploy through the stable `crew-release.sh` on persistent disk (never the release under upgrade), then verify mechanically: the live release equals the target, the release registry parses, and `crew-api get-state` works. Handover is automatic — the next polling tick launches the dispatcher from the new release through the `current` symlink. In-flight runs finish on the old release via the per-task lib pin, so an upgrade mid-flight does not break running work.
|
|
318
|
+
|
|
319
|
+
Four ways to start an upgrade:
|
|
320
|
+
|
|
321
|
+
1. **Dashboard** — file an upgrade task against your crew's project.
|
|
322
|
+
2. **Manual crew** — create a task with `workflow: "upgrade"` in any project, with or without a `source:` line.
|
|
323
|
+
3. **Self** — the crew can file its own upgrade task: any workflow's work agent can create one once it knows a newer release exists (release discovery is a future capability — `lib/update-watch.js`).
|
|
324
|
+
4. **External npm** — file an upgrade task with `source: npm@x.y.z` to install a published version from the registry.
|
|
325
|
+
|
|
326
|
+
Fail-closed rules: an upgrade never mutates the crew repo (repo source always means the repo's current HEAD, never a checkout or pull), never publishes to npm, never touches the scheduler or retries, and never rolls back automatically. A failed deploy parks the task with the exact deploy output; `crew-release.sh rollback` is the human recovery path. If the crew is already on the target release, the task completes as an idempotent no-op.
|
|
298
327
|
|
|
299
328
|
### The visual verdict
|
|
300
329
|
|
|
301
330
|
For tasks that change anything rendered and visible in the project's artifact ("experiential" tasks), QA owns the visual verdict. Capture runs right after Triage to collect pre-change baseline evidence (parent-driven — see `docs/visual-verdict.md`); Map cannot be written without it; final QA covers mechanical checks only and the task does not complete until a `visual_verdict: PASS` is recorded. A FAIL reworks at Build within the shared budget; a `rendering impossible:` FAIL parks for human attention. Hazel judges against the artist-eye rubric (alignment, spacing, hierarchy, composition, balance, finish, taste) in `identities/hazel.md`, from rendered evidence only — never from prose descriptions.
|
|
302
331
|
|
|
332
|
+
### Reproduce at the bug's layer
|
|
333
|
+
|
|
334
|
+
Bugfix tasks reproduce at the layer where the bug lives — not always in the browser. During Triage, Sage classifies the bug's layer with a machine-read marker line (`layer: artifact`, `layer: engine`, or `layer: docs`):
|
|
335
|
+
|
|
336
|
+
- **artifact** — user-facing behavior of the rendered artifact (something a user sees or clicks). Reproduced with the experiential see-act browser loop, as today.
|
|
337
|
+
- **engine** — the crew's own machinery: workflows, lib scripts, shell scripts, tests, scheduler. Reproduced deterministically with shell commands in the repo checkout — run the failing script, re-run the failing test suite (`bash tests/run.sh`), query crew state via the API.
|
|
338
|
+
- **docs** — a documentation gap or error. Reproduced by reading the file and confirming the gap is actually there.
|
|
339
|
+
|
|
340
|
+
The workflow dispatches the Reproduce strategy mechanically on the classification. A garbled or missing marker degrades to `artifact` (the old behavior) — a task is never parked on it. The experiential loop is forbidden for engine/docs tasks: if the reproducer drives the browser anyway, the closeout fails the phase loudly for retry, so wrong-layer verdicts never reach Map.
|
|
341
|
+
|
|
303
342
|
### Publish content verification
|
|
304
343
|
|
|
305
344
|
The artifact builder's `applied` report is derived from the diff the workflow carries to it, so comparing the report to the diff is circular — canary run 8 (2026-09-11) stamped provenance on a hollow build and every phase went green. The workflow therefore never stamps provenance itself: after the build lands it parks with `publish: verification-requested <commit> (build <agent_id|agent_id unobserved>)`. "Landed" requires positive evidence (canary 2026-09-15, task `1d692d91`): the build poll must have positively observed our build — a running build with the receipt `agent_id`, or a completed-build record matching it. Absence of a running build is not evidence our build ran; an unobserved "done" is an unknown outcome, parked fail-closed with an append-only `unknown` ledger entry — never parked as verification-requested. The parent protocol owns the independent content confirmation (parent-driven — see `docs/publish-verification.md`); the park message records the observed builder build identifier as `(build <agent_id|agent_id unobserved>)`, and the parent correlates the read-back's live build agent_id against it — a mismatch logs `publish: build-mismatch <commit> …`, stays parked, and is never stamped (parent-driven — see `docs/publish-verification.md` step 4b). The independent read-back step is currently unavailable: `artifact_inspect` was removed by the platform (2026-09-14) and no agent-callable replacement exists (`artifact.inspect` is malfunction diagnosis, not a read-back tool), so the parent cannot confirm content independently and tasks stay parked at verification-requested until a read-back path exists. QA's provenance check then enforces the stamp mechanically, so an unverified publish fails loudly in QA instead of passing silently.
|
|
@@ -418,6 +457,139 @@ Each release is identified by its source: a full Git commit hash for Git checkou
|
|
|
418
457
|
crew-release.sh current
|
|
419
458
|
```
|
|
420
459
|
|
|
460
|
+
### Automatic updates
|
|
461
|
+
|
|
462
|
+
A daily deterministic script (`lib/update-watch.js`, run by the
|
|
463
|
+
`crew-update-watch` cron) watches for new releases and files upgrade tasks
|
|
464
|
+
through the normal loop — it never deploys, never touches the artifact,
|
|
465
|
+
never mutates config. The loop applies.
|
|
466
|
+
|
|
467
|
+
- **Crew:** the public npm registry (`npm view muse-crew version`, pinned to
|
|
468
|
+
`https://registry.npmjs.org/`) vs the live release (`crew-release.sh
|
|
469
|
+
current`). If a newer version is published and the policy/channel allow,
|
|
470
|
+
the watcher files a `workflow: "upgrade"` task with `source: npm@<version>`.
|
|
471
|
+
Non-npm releases (git-hash dev builds) are skipped — they cannot be
|
|
472
|
+
compared to registry versions.
|
|
473
|
+
- **Dashboard:** `git ls-remote origin HEAD` on the first
|
|
474
|
+
`deploy_type=artifact` project vs the recorded sha in
|
|
475
|
+
`$CREW_HOME/.update-watch.json`. If the origin has a new ref, the watcher
|
|
476
|
+
files a `workflow: "chore"` task carrying the mechanical dashboard-upgrade
|
|
477
|
+
journey (fetch, ancestor check, review, let Publish carry the artifact update).
|
|
478
|
+
|
|
479
|
+
#### Dashboard compatibility anchor (.crew-version)
|
|
480
|
+
|
|
481
|
+
A dashboard release declares the crew version it needs in a `.crew-version`
|
|
482
|
+
file at the repo root — one line, an exact version (`0.7.21`, strict semver,
|
|
483
|
+
no ranges). Bump it in the same commit as any change that needs a new crew.
|
|
484
|
+
The file is added on the dashboard-repo side at release time; the watcher
|
|
485
|
+
only honors it.
|
|
486
|
+
|
|
487
|
+
The watcher reads the declaration at the filed ref (`git fetch origin`, then
|
|
488
|
+
`git show <sha>:.crew-version` — never the local working tree, which still
|
|
489
|
+
sits at the recorded sha while a newer ref is being evaluated) and treats
|
|
490
|
+
(dashboard ref, crew version) as one unit, ordered dashboard-led:
|
|
491
|
+
|
|
492
|
+
- Declared crew newer than the running release: the crew upgrade task
|
|
493
|
+
(`workflow: "upgrade"`, `source: npm@<declared>`, `filed_by:
|
|
494
|
+
update-watch`) is filed first, and the dashboard task notes that it follows
|
|
495
|
+
the crew upgrade (old→new dashboard commits plus the required crew
|
|
496
|
+
version). The declaration is a requirement, not a preference: it bypasses
|
|
497
|
+
`update_channel`, but not the `auto_update_crew=false` opt-out — with the
|
|
498
|
+
opt-out, only the dashboard task is filed, noting the required version for
|
|
499
|
+
a human.
|
|
500
|
+
If the crew upgrade task cannot be filed (no `muse-crew` project
|
|
501
|
+
registered, or `create-task` fails): the whole dashboard leg is held —
|
|
502
|
+
nothing is filed, the old dashboard sha stays in the state file, and the
|
|
503
|
+
ref is re-evaluated on the next run, so the (dashboard ref, crew version)
|
|
504
|
+
unit is never ordered half-way.
|
|
505
|
+
- Declared crew equal to the running release: the dashboard task is filed as
|
|
506
|
+
today, no crew task.
|
|
507
|
+
- Declared crew older than the running release: the dashboard leg is skipped
|
|
508
|
+
and logged — a downgrade is a human decision, never automatic. State is
|
|
509
|
+
left untouched.
|
|
510
|
+
- Missing, unreadable, or invalid file: silent with a log line, and the
|
|
511
|
+
dashboard leg proceeds as today. Updates are never blocked on a missing
|
|
512
|
+
declaration.
|
|
513
|
+
|
|
514
|
+
**Policy** (crew config, set at setup, changeable later via the Crew API's
|
|
515
|
+
`update-config`):
|
|
516
|
+
|
|
517
|
+
- `auto_update_crew` — default `true`. `false` turns the crew check off.
|
|
518
|
+
- `auto_update_dashboard` — default `true`. `false` turns the dashboard check off.
|
|
519
|
+
- `update_channel` — `latest` (default) or `patch` (crew upgrades only for
|
|
520
|
+
patch releases). Any other value is logged and treated as `latest`.
|
|
521
|
+
|
|
522
|
+
Setup announces the default with a one-word opt-out: "Your crew and
|
|
523
|
+
dashboard will update themselves automatically; say the word if you'd
|
|
524
|
+
rather approve each one." Opting out is a re-run with
|
|
525
|
+
`autoUpdateCrew: false` / `autoUpdateDashboard: false`; a re-init that
|
|
526
|
+
passes no inputs never resurrects a deliberate opt-out.
|
|
527
|
+
|
|
528
|
+
**Trust anchors:**
|
|
529
|
+
|
|
530
|
+
- Publisher + registry trust = install trust. The watcher adds no new
|
|
531
|
+
trust: it watches the same public registry the install came from.
|
|
532
|
+
- The upgrade workflow's Verify phase is the real gate — nothing deploys
|
|
533
|
+
without it.
|
|
534
|
+
- Fail-closed + retained rollback: a bad upgrade parks; `crew-release.sh
|
|
535
|
+
rollback` is the human recovery path.
|
|
536
|
+
- Capability split: the watcher reaches the network but can never deploy —
|
|
537
|
+
it only files tasks. The deployer never reaches the network beyond the
|
|
538
|
+
package install the task explicitly names.
|
|
539
|
+
|
|
540
|
+
**Idempotency:** `$CREW_HOME/.update-watch.json` records the last filed crew
|
|
541
|
+
version and per-project dashboard sha (recorded at file time, so a parked
|
|
542
|
+
upgrade stays visible in the loop instead of being silently re-filed).
|
|
543
|
+
Check failures (npm unreachable, `ls-remote` auth friction) are silent with
|
|
544
|
+
a log line in `$CREW_HOME/update-watch.log` — the script always exits 0 so
|
|
545
|
+
a failed check never breaks the loop.
|
|
546
|
+
|
|
547
|
+
## npm releases
|
|
548
|
+
|
|
549
|
+
The npm Publish phase (`lib/publish-npm.sh`) releases the crew to the
|
|
550
|
+
registry as `muse-crew@<target-version>`: it writes the workflow-computed
|
|
551
|
+
target version into `package.json`, commits it, packs a tarball, publishes
|
|
552
|
+
via the publish helper, verifies against the registry, pushes, and
|
|
553
|
+
finalizes.
|
|
554
|
+
|
|
555
|
+
### What the publish path needs
|
|
556
|
+
|
|
557
|
+
Two things, both machine-local:
|
|
558
|
+
|
|
559
|
+
1. **The publish helper** — `NPM_PUBLISH_PY`, defaulting to
|
|
560
|
+
`$HOME/workspace/skills/npm/bin/npm-publish.py`. The script invokes it
|
|
561
|
+
as `python3 <helper> <tarball>`; it only needs to be a readable file
|
|
562
|
+
(the executable bit is irrelevant).
|
|
563
|
+
2. **The `custom.npm` credential** — the npm publish token in the secure
|
|
564
|
+
vault, under exactly that name. The helper probes it with
|
|
565
|
+
`python3 <helper> --check`, which reports presence/absence only and
|
|
566
|
+
never prints credential material.
|
|
567
|
+
|
|
568
|
+
### How to configure it
|
|
569
|
+
|
|
570
|
+
- Point `NPM_PUBLISH_PY` at your own helper (any script that takes a
|
|
571
|
+
tarball path, and optionally `--check` for the credential probe).
|
|
572
|
+
- Store the publish token as `custom.npm` in the secure vault.
|
|
573
|
+
|
|
574
|
+
A helper that predates `--check` still works: the probe's failure is
|
|
575
|
+
treated as "unknown", not "absent", and the publish runs with today's
|
|
576
|
+
fail-closed behavior.
|
|
577
|
+
|
|
578
|
+
### What the skip means
|
|
579
|
+
|
|
580
|
+
When the helper or the credential is absent, the preflight (which runs
|
|
581
|
+
before any mutation — before the version write, the commit, the tarball)
|
|
582
|
+
skips gracefully and the Publish agent reports:
|
|
583
|
+
|
|
584
|
+
```
|
|
585
|
+
PUBLISH_SKIPPED=no-npm-publish
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
This is a designed outcome, not a failure. The merge stands, the version
|
|
589
|
+
was not cut, nothing was shipped — and the task completes normally. There
|
|
590
|
+
is no retry and nothing to park: a missing credential is configuration,
|
|
591
|
+
not a transient error, so one probe and one skip is the whole story.
|
|
592
|
+
|
|
421
593
|
## Current limitations
|
|
422
594
|
|
|
423
595
|
1. **No global kill switch.** Only per-project pause/resume exists. Setting global `config.simultaneity` to 0 does not override projects with explicit nonzero values. A global stop requires pausing each project individually.
|
package/lib/AGENTS.md
CHANGED
|
@@ -7,7 +7,7 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
7
7
|
- `schema.sql` — the crew-owned state schema: projects, tasks, poll_state, config, agent_sessions, events. Vocabularies enforced by CHECK constraints; `rejected` is a valid event type (the 2026-09-11 crash was a stored session whose event was rejected). Column names match the historical dashboard tables for a verbatim migration.
|
|
8
8
|
- `crew-release.sh` — immutable release manager: deploy, rollback, prune
|
|
9
9
|
- `merge-lock.sh` — serialized merge lock for concurrent agents: time-based holder lease (bug 2fc8f52f — an unexpired lease is held regardless of process liveness; only an expired lease may be broken). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset). Lock file is key=value: task_id, opaque holder identity (never a PID), acquired_at epoch, lease_seconds (default 600, override via MERGE_LOCK_LEASE_SECONDS). acquire/refresh/release/status/force-release; holder-only refresh and release; every op appends to $CREW_HOME/.merge-lock.log
|
|
10
|
-
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (`integrate` reconciles `origin/main` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — so the push is a fast-forward; fail-soft with no remote, fail closed on conflict) (`.worktrees/<id>`, branch `task/<id>`).
|
|
10
|
+
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (`integrate` reconciles `origin/main` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — so the push is a fast-forward; fail-soft with no remote, fail closed on conflict) (`.worktrees/<id>`, branch `task/<id>`). Branch resolution order: the crew registry (`<repo>/.worktrees/.registry/<id>`), then the canonical `task/<full-id>` ref, then a `task/<id-prefix>` ref matched by strict prefix enumeration (task 4e1a1bba — a Build agent may create the branch with raw git from an abbreviated id, bypassing prepare; prefix names are tolerated, never created; ambiguous prefixes fail closed). `resolve-branch` prints the resolved branch for agent-side one-liners. Nothing reconstructs the branch name — every command resolves it. Prepare fails closed on dirty `main`; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into main — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
|
|
11
11
|
- `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight, integrate remote-reconcile with fixture sensitivity + no-remote fail-soft) on scratch repos
|
|
12
12
|
- `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \\u2014 escape; extracts the shipped block by anchor)
|
|
13
13
|
- `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
|
|
@@ -25,3 +25,4 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
25
25
|
- `readback-disk.js` — deterministic publish content sensor (2026-09-16): reads the on-disk tree the artifact is built/served from and emits the machine-readable findings block (`FILE:`/`ADDED:`/`REMOVED:`/`END_FILE`) that `verify-publish.js` judges. The primary sensor — the LLM-inspector path (`build-readback-request.js`) is manual-fallback only since `artifact_inspect` was removed by the platform 2026-09-14.
|
|
26
26
|
- `build-readback-request.js` — builds the LLM-inspector read-back `verbatim_request` from the merge commit's diff (2026-09-14): carries the merged diff as the expected change and asks for an independent read of the artifact's actual source. Retained as the manual fallback; the deterministic `readback-disk.js` is the primary sensor.
|
|
27
27
|
- `verify-publish.js` — mechanical publish verification judge (2026-09-14/16): certifies the read-back findings block against `git diff` (strict `FILE:`/`ADDED:`/`REMOVED:`/`END_FILE` parsing, every added line PRESENT / every removed line ABSENT, HEAD==commit supersession check) and only then stamps provenance. Binary files, mode-only changes, and fully-colliding added hunks fail closed as `unverifiable-content` (2026-09-16, critic findings 1/5) — they can never vacuously stamp. Content-mismatch, unreadable-result, superseded, and stamp failures exit 1 with `publish: verification-failed` and no stamp. Collision exemption (2026-09-15): a removed line occurring in the old tree strictly more times than the diff removes is exempt from ABSENT (zero discriminating power); computed from `git show <base>:<path>`, never the working tree.
|
|
28
|
+
- `update-watch.js` — deterministic automatic update watcher (2026-09-16, zero deps): `node update-watch.js --crew-home <path>` (missing arg → usage, exit 2; every other path exits 0). Watches the public npm registry (`npm view muse-crew version` pinned to `https://registry.npmjs.org/`) vs `crew-release.sh current` and files a `workflow: "upgrade"` task with `source: npm@<version>` when policy (`auto_update_crew`, `update_channel`) and channel gating allow; watches `git ls-remote origin HEAD` on the first `deploy_type=artifact` project vs `$CREW_HOME/.update-watch.json` and files a `workflow: "chore"` task carrying the mechanical dashboard-upgrade journey. Reads the `.crew-version` compatibility anchor at the new ref via `git fetch` + `git show <sha>:.crew-version` (never the working tree) and orders dashboard-led: a declared newer crew files the crew upgrade task FIRST and the dashboard task notes it follows the crew upgrade (declaration bypasses `update_channel`, not the `auto_update_crew=false` opt-out); a declared older crew skips the dashboard leg entirely as a human decision; a missing/invalid/unfetchable anchor fails open to the dashboard leg as today. Idempotency via the same state file (records at file time); check failures log to `$CREW_HOME/update-watch.log` and are never thrown. Safety: only files tasks — never deploys, never touches the artifact/config/scheduler. Run by the daily `crew-update-watch` cron through the `current` symlink (latest release); deliberately NOT in the lib-pinning `PIN_BASENAMES`.
|
package/lib/build-registry.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// build-registry.js — deterministic extraction of the workflow step registry.
|
|
3
3
|
//
|
|
4
4
|
// The workflow files (workflows/standard.js, workflows/bugfix.js,
|
|
5
|
-
// workflows/chore.js, workflows/docs.js) are the
|
|
5
|
+
// workflows/chore.js, workflows/docs.js, workflows/upgrade.js) are the
|
|
6
|
+
// single source of truth.
|
|
6
7
|
// This script generates workflows/registry.json at release time (invoked by
|
|
7
8
|
// crew-release.sh cmd_deploy). The JSON is a build artifact and must never be
|
|
8
9
|
// hand-edited — publish a new release instead.
|
|
@@ -14,7 +15,7 @@ const fs = require("fs");
|
|
|
14
15
|
const path = require("path");
|
|
15
16
|
const vm = require("vm");
|
|
16
17
|
|
|
17
|
-
const WORKFLOWS = ["standard", "bugfix", "chore", "docs"];
|
|
18
|
+
const WORKFLOWS = ["standard", "bugfix", "chore", "docs", "upgrade"];
|
|
18
19
|
|
|
19
20
|
function fail(msg) {
|
|
20
21
|
process.stderr.write("build-registry: " + msg + "\n");
|
package/lib/publish-npm.sh
CHANGED
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
# When no merge lock is held (empty-diff Integrate), the publish path is
|
|
28
28
|
# skipped gracefully: PUBLISH_SKIPPED=no-lock-held is printed, no version
|
|
29
29
|
# is written or published, and PUBLISH_COMPLETE is omitted.
|
|
30
|
+
# When npm publish is not configured on this machine (publish helper or
|
|
31
|
+
# credential absent), the preflight skips gracefully before any mutation:
|
|
32
|
+
# PUBLISH_SKIPPED=no-npm-publish is printed, no version is written or
|
|
33
|
+
# published, and PUBLISH_COMPLETE is omitted (see docs/guide.md#npm-releases).
|
|
30
34
|
|
|
31
35
|
set -euo pipefail
|
|
32
36
|
|
|
@@ -49,6 +53,35 @@ echo "PUBLISH_TARGET=$TARGET_VERSION"
|
|
|
49
53
|
git -C "$REPO_PATH" rev-parse --git-dir >/dev/null 2>&1 \
|
|
50
54
|
|| fail "env" "REPO_PATH is not a git checkout: $REPO_PATH"
|
|
51
55
|
|
|
56
|
+
# PREFLIGHT-ANCHOR: publish capability preflight (runs before ANY mutation).
|
|
57
|
+
# publish-npm.sh assumed the author-machine publish helper path and failed
|
|
58
|
+
# cryptically AFTER the version-bump commit when npm publish was not
|
|
59
|
+
# configured, leaving a release commit for a version that never shipped.
|
|
60
|
+
# Probe first: when the helper or the publish credential is absent, skip
|
|
61
|
+
# cleanly with PUBLISH_SKIPPED=no-npm-publish — no commit, no tarball,
|
|
62
|
+
# nothing mutated. Checks -f/-r only: the helper is invoked as
|
|
63
|
+
# `python3 <path>`, so the executable bit is irrelevant (requiring it would
|
|
64
|
+
# false-skip on files that are rw-rw----). An old helper that does not know
|
|
65
|
+
# --check (nonzero exit without a CREDENTIAL_* marker) is "unknown", not
|
|
66
|
+
# "absent": fall through to today's fail-closed behavior at step 11.
|
|
67
|
+
# Placed before DRY_RUN on purpose: a dry run on an unconfigured machine
|
|
68
|
+
# honestly reports "would skip". No wall-clock, no randomness, no new env
|
|
69
|
+
# vars — the determinism scan stays clean. Never prints credential material.
|
|
70
|
+
if ! [ -f "$NPM_PUBLISH_PY" ] || ! [ -r "$NPM_PUBLISH_PY" ]; then
|
|
71
|
+
echo "PUBLISH_SKIPPED=no-npm-publish"
|
|
72
|
+
echo "npm publish not configured on this machine — see docs/guide.md#npm-releases; code changes are merged, nothing was versioned or published"
|
|
73
|
+
exit 0
|
|
74
|
+
fi
|
|
75
|
+
PREFLIGHT_CHECK_CODE=0
|
|
76
|
+
PREFLIGHT_CHECK_OUT="$(python3 "$NPM_PUBLISH_PY" --check 2>&1)" || PREFLIGHT_CHECK_CODE=$?
|
|
77
|
+
if [ "$PREFLIGHT_CHECK_CODE" -ne 0 ] \
|
|
78
|
+
&& printf '%s' "$PREFLIGHT_CHECK_OUT" | grep -q 'CREDENTIAL_MISSING=custom.npm'; then
|
|
79
|
+
echo "PUBLISH_SKIPPED=no-npm-publish"
|
|
80
|
+
echo "npm publish not configured on this machine — see docs/guide.md#npm-releases; code changes are merged, nothing was versioned or published"
|
|
81
|
+
exit 0
|
|
82
|
+
fi
|
|
83
|
+
# PREFLIGHT-END
|
|
84
|
+
|
|
52
85
|
# 3. Dry run: markers only, no lock, no deploy, no mutation.
|
|
53
86
|
if [ "${DRY_RUN:-0}" = "1" ]; then
|
|
54
87
|
echo "PUBLISH_DRY_RUN=1"
|
|
@@ -179,7 +212,13 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
179
212
|
|
|
180
213
|
# 11. Publish. "previously published versions" means a retried Publish
|
|
181
214
|
# already landed this version (the merge lock guarantees no other task
|
|
182
|
-
# picked it) — continue.
|
|
215
|
+
# picked it) — continue. A "staged version" 409 means npm accepted the
|
|
216
|
+
# publish but staged it: the PUT returned 2xx yet the version is
|
|
217
|
+
# invisible in the packument until it finalizes (~7 min observed
|
|
218
|
+
# 2026-09-17). Never re-PUT a staged version — the registry rejects it
|
|
219
|
+
# and the re-PUT proves nothing. Poll the registry at step 12 until it
|
|
220
|
+
# finalizes or the budget exhausts (fail closed). Any other failure is
|
|
221
|
+
# fatal.
|
|
183
222
|
if PUB_OUT="$(python3 "$NPM_PUBLISH_PY" "$TGZ" 2>&1)"; then
|
|
184
223
|
echo "PUBLISHED=$TARGET_VERSION"
|
|
185
224
|
else
|
|
@@ -187,6 +226,10 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
187
226
|
*"previously published versions"*)
|
|
188
227
|
echo "PUBLISHED_ALREADY=$TARGET_VERSION"
|
|
189
228
|
;;
|
|
229
|
+
*"staged version"*)
|
|
230
|
+
echo "PUBLISHED_STAGED=$TARGET_VERSION"
|
|
231
|
+
echo "publish staged by registry — polling for visibility, not re-PUTting"
|
|
232
|
+
;;
|
|
190
233
|
*)
|
|
191
234
|
rm -f "$TGZ"
|
|
192
235
|
fail "publish" "$(printf '%s\n' "$PUB_OUT" | tail -5)"
|
|
@@ -198,12 +241,20 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
198
241
|
# 12. Verify: ground truth is the registry, not any agent's summary.
|
|
199
242
|
# The registry is eventually consistent: a publish followed by an
|
|
200
243
|
# immediate read can observe the pre-publish version (canary 5a027278
|
|
201
|
-
# hit a stale read replica ~6s after publish).
|
|
202
|
-
#
|
|
203
|
-
#
|
|
244
|
+
# hit a stale read replica ~6s after publish). Worse, npm may STAGE a
|
|
245
|
+
# publish: the PUT returns 2xx but the version stays invisible in the
|
|
246
|
+
# packument until it finalizes (~7 min observed 2026-09-17) — a
|
|
247
|
+
# re-PUT then 409s as "previously staged". A short budget would park
|
|
248
|
+
# a landed publish, so the default budget covers staged finalization
|
|
249
|
+
# with headroom (30 x 30s = 15 min, still bounded). A single stale
|
|
250
|
+
# read must never park a landed publish, so this block retries with
|
|
251
|
+
# backoff and client cache-busting instead of failing on the first
|
|
252
|
+
# mismatch. Budget exhaustion fails closed: a publish that never
|
|
253
|
+
# becomes visible is not claimed — a human with 2FA investigates
|
|
254
|
+
# (e.g. `npm stage approve`) and the version is never re-PUT blind.
|
|
204
255
|
# STEP-12-ANCHOR: publish verification (retry-tolerant)
|
|
205
|
-
VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-
|
|
206
|
-
VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-
|
|
256
|
+
VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-30}"
|
|
257
|
+
VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-30}"
|
|
207
258
|
REG=""
|
|
208
259
|
for attempt in $(seq 1 "$VERIFY_ATTEMPTS"); do
|
|
209
260
|
# --prefer-online busts npm's client-side packument cache; the retry
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-publish-preflight.sh — regression tests for the npm publish
|
|
3
|
+
# capability preflight (bugfix 2026-09-17): publish-npm.sh assumed the
|
|
4
|
+
# author-machine publish helper path and failed cryptically AFTER the
|
|
5
|
+
# version-bump release commit when npm publish was not configured.
|
|
6
|
+
#
|
|
7
|
+
# Covers:
|
|
8
|
+
# - structural: the PREFLIGHT block exists in lib/publish-npm.sh
|
|
9
|
+
# (extracted by anchor), sits after env validation and before the
|
|
10
|
+
# DRY_RUN block, checks the helper with -f/-r only (never -x — the
|
|
11
|
+
# helper is invoked as `python3 <path>`), and reads no wall clock or
|
|
12
|
+
# randomness.
|
|
13
|
+
# - full-script runs against a scratch repo:
|
|
14
|
+
# * NPM_PUBLISH_PY missing -> PUBLISH_SKIPPED=no-npm-publish + the
|
|
15
|
+
# actionable line, exit 0, HEAD unchanged, status clean, no tarball
|
|
16
|
+
# * fake helper whose --check reports CREDENTIAL_MISSING=custom.npm
|
|
17
|
+
# (exit 1) -> same skip outcome, exit 0, repo untouched
|
|
18
|
+
# * fake helper whose --check reports CREDENTIAL_OK=custom.npm
|
|
19
|
+
# (exit 0) with DRY_RUN=1 -> preflight passes through to
|
|
20
|
+
# PUBLISH_DRY_RUN=1 (asserts pass-through, not the mutation)
|
|
21
|
+
# * old helper that does not know --check (usage to stderr, exit 1,
|
|
22
|
+
# no CREDENTIAL markers) with DRY_RUN=1 -> no skip (fall-through
|
|
23
|
+
# preserved: unknown is not absent)
|
|
24
|
+
# - --check probe unit test against the real helper: on the author
|
|
25
|
+
# machine this expects exit 0 and exactly CREDENTIAL_OK=custom.npm.
|
|
26
|
+
# NOTE: this case depends on the `custom.npm` credential existing in
|
|
27
|
+
# the secure vault — on a machine without it the probe correctly
|
|
28
|
+
# reports CREDENTIAL_MISSING=custom.npm (exit 1) and this case fails.
|
|
29
|
+
# That is expected outside the author machine, not a regression.
|
|
30
|
+
# - bash -n syntax check on publish-npm.sh
|
|
31
|
+
#
|
|
32
|
+
# Anchors: `# PREFLIGHT-ANCHOR` ... `# PREFLIGHT-END` in lib/publish-npm.sh.
|
|
33
|
+
|
|
34
|
+
set -uo pipefail
|
|
35
|
+
|
|
36
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
37
|
+
PUBLISH="$SCRIPT_DIR/publish-npm.sh"
|
|
38
|
+
REAL_HELPER="$HOME/workspace/skills/npm/bin/npm-publish.py"
|
|
39
|
+
|
|
40
|
+
pass=0
|
|
41
|
+
fail_count=0
|
|
42
|
+
ok() { echo "PASS: $1"; pass=$((pass + 1)); }
|
|
43
|
+
no() { echo "FAIL: $1"; fail_count=$((fail_count + 1)); }
|
|
44
|
+
|
|
45
|
+
TMPBASE="$(mktemp -d)"
|
|
46
|
+
trap 'rm -rf "$TMPBASE"' EXIT
|
|
47
|
+
|
|
48
|
+
# --- Scratch repo (Publish's REPO_PATH must be a git checkout) ---
|
|
49
|
+
SCRATCH="$TMPBASE/repo"
|
|
50
|
+
git init -q -b main "$SCRATCH"
|
|
51
|
+
git -C "$SCRATCH" config user.email "test@crew"
|
|
52
|
+
git -C "$SCRATCH" config user.name "crew-test"
|
|
53
|
+
echo x > "$SCRATCH/f.txt"
|
|
54
|
+
git -C "$SCRATCH" add -A && git -C "$SCRATCH" commit -qm init
|
|
55
|
+
|
|
56
|
+
# --- Fake helpers ---
|
|
57
|
+
cat > "$TMPBASE/helper-ok.py" <<'EOF'
|
|
58
|
+
#!/usr/bin/env python3
|
|
59
|
+
import sys
|
|
60
|
+
if len(sys.argv) == 2 and sys.argv[1] == "--check":
|
|
61
|
+
print("CREDENTIAL_OK=custom.npm")
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
EOF
|
|
65
|
+
cat > "$TMPBASE/helper-missing-cred.py" <<'EOF'
|
|
66
|
+
#!/usr/bin/env python3
|
|
67
|
+
import sys
|
|
68
|
+
if len(sys.argv) == 2 and sys.argv[1] == "--check":
|
|
69
|
+
print("CREDENTIAL_MISSING=custom.npm")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
EOF
|
|
73
|
+
# An old helper that predates --check: usage to stderr, exit 1, no markers.
|
|
74
|
+
cat > "$TMPBASE/helper-old.py" <<'EOF'
|
|
75
|
+
#!/usr/bin/env python3
|
|
76
|
+
import sys
|
|
77
|
+
print("Usage: helper-old.py <tarball-path>", file=sys.stderr)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
EOF
|
|
80
|
+
|
|
81
|
+
ACTIONABLE="npm publish not configured on this machine — see docs/guide.md#npm-releases; code changes are merged, nothing was versioned or published"
|
|
82
|
+
|
|
83
|
+
# --- Structural: extract the shipped preflight block by anchor ---
|
|
84
|
+
BLOCK="$TMPBASE/preflight.sh"
|
|
85
|
+
sed -n '/# PREFLIGHT-ANCHOR/,/# PREFLIGHT-END/p' "$PUBLISH" > "$BLOCK"
|
|
86
|
+
if grep -q 'PUBLISH_SKIPPED=no-npm-publish' "$BLOCK" \
|
|
87
|
+
&& grep -q 'docs/guide.md#npm-releases' "$BLOCK" \
|
|
88
|
+
&& grep -q 'exit 0' "$BLOCK"; then
|
|
89
|
+
ok "extracted PREFLIGHT block from publish-npm.sh"
|
|
90
|
+
else
|
|
91
|
+
no "PREFLIGHT extraction failed (anchors missing)"
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
# The helper is invoked as `python3 <path>`: -f/-r, never -x.
|
|
95
|
+
if grep -q '\[ -f "\$NPM_PUBLISH_PY" \]' "$BLOCK" \
|
|
96
|
+
&& grep -q '\[ -r "\$NPM_PUBLISH_PY" \]' "$BLOCK" \
|
|
97
|
+
&& ! grep -q '\[ -x "\$NPM_PUBLISH_PY" \]' "$BLOCK"; then
|
|
98
|
+
ok "preflight checks -f/-r on the helper, never -x"
|
|
99
|
+
else
|
|
100
|
+
no "preflight helper checks wrong (want -f/-r, no -x)"
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
# No wall-clock or randomness in the preflight block.
|
|
104
|
+
if ! grep -Eq '\$\(date|date \+|\$RANDOM' "$BLOCK"; then
|
|
105
|
+
ok "preflight block reads no wall clock and no randomness"
|
|
106
|
+
else
|
|
107
|
+
no "preflight block contains clock/randomness"
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# Placement: after env validation, before the DRY_RUN block.
|
|
111
|
+
VALIDATE_LINE="$(grep -n 'rev-parse --git-dir' "$PUBLISH" | head -1 | cut -d: -f1)"
|
|
112
|
+
ANCHOR_LINE="$(grep -n '# PREFLIGHT-ANCHOR' "$PUBLISH" | head -1 | cut -d: -f1)"
|
|
113
|
+
END_LINE="$(grep -n '# PREFLIGHT-END' "$PUBLISH" | head -1 | cut -d: -f1)"
|
|
114
|
+
DRYRUN_LINE="$(grep -n 'DRY_RUN:-0}' "$PUBLISH" | head -1 | cut -d: -f1)"
|
|
115
|
+
if [ -n "$VALIDATE_LINE" ] && [ -n "$ANCHOR_LINE" ] && [ -n "$END_LINE" ] \
|
|
116
|
+
&& [ -n "$DRYRUN_LINE" ] \
|
|
117
|
+
&& [ "$VALIDATE_LINE" -lt "$ANCHOR_LINE" ] \
|
|
118
|
+
&& [ "$END_LINE" -lt "$DRYRUN_LINE" ]; then
|
|
119
|
+
ok "PREFLIGHT block sits after env validation, before DRY_RUN"
|
|
120
|
+
else
|
|
121
|
+
no "PREFLIGHT placement wrong (validate=$VALIDATE_LINE anchor=$ANCHOR_LINE end=$END_LINE dryrun=$DRYRUN_LINE)"
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# Runner: full publish-npm.sh against the scratch repo. The preflight exits
|
|
125
|
+
# before any mutation, so the repo needs no package.json.
|
|
126
|
+
run_full() { # run_full <helper_path> [extra_env...]
|
|
127
|
+
local helper="$1"; shift
|
|
128
|
+
local run="$TMPBASE/run-$RANDOM"
|
|
129
|
+
mkdir -p "$run"
|
|
130
|
+
(
|
|
131
|
+
export TASK_ID="preflight-test" REPO_PATH="$SCRATCH" PKG="muse-crew" TARGET_VERSION="9.9.9"
|
|
132
|
+
export NPM_PUBLISH_PY="$helper"
|
|
133
|
+
# A bare `export` (empty "$@") would dump the environment to stdout and
|
|
134
|
+
# corrupt the captured run path — only export when extras are given.
|
|
135
|
+
if [ $# -gt 0 ]; then export "$@"; fi
|
|
136
|
+
bash "$PUBLISH" > "$run/out" 2>&1
|
|
137
|
+
)
|
|
138
|
+
local code=$?
|
|
139
|
+
echo "$code" > "$run/code"
|
|
140
|
+
echo "$run"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
repo_state() { # prints "HEAD=<sha> STATUS=<porcelain-lines> TGZ=<count>"
|
|
144
|
+
local head status tgz
|
|
145
|
+
head="$(git -C "$SCRATCH" rev-parse HEAD)"
|
|
146
|
+
status="$(git -C "$SCRATCH" status --porcelain | wc -l)"
|
|
147
|
+
tgz="$(find "$SCRATCH" -maxdepth 1 -name '*.tgz' | wc -l)"
|
|
148
|
+
echo "HEAD=$head STATUS=$status TGZ=$tgz"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# --- Case 1: helper missing -> clean skip, exit 0, repo untouched ---
|
|
152
|
+
BEFORE="$(repo_state)"
|
|
153
|
+
RUN="$(run_full /nonexistent/path.py)"
|
|
154
|
+
CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state)"
|
|
155
|
+
if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_SKIPPED=no-npm-publish"* ]] \
|
|
156
|
+
&& [[ "$OUT" == *"$ACTIONABLE"* ]] \
|
|
157
|
+
&& [[ "$OUT" != *"PUBLISH_FAILED"* ]] && [[ "$OUT" != *"PUBLISH_COMPLETE"* ]] \
|
|
158
|
+
&& [ "$BEFORE" = "$AFTER" ]; then
|
|
159
|
+
ok "helper missing: skips with marker + actionable line, exit 0, repo untouched"
|
|
160
|
+
else
|
|
161
|
+
no "helper missing: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
# --- Case 2: helper present, credential missing -> same skip, repo untouched ---
|
|
165
|
+
BEFORE="$(repo_state)"
|
|
166
|
+
RUN="$(run_full "$TMPBASE/helper-missing-cred.py")"
|
|
167
|
+
CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state)"
|
|
168
|
+
if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_SKIPPED=no-npm-publish"* ]] \
|
|
169
|
+
&& [[ "$OUT" == *"$ACTIONABLE"* ]] \
|
|
170
|
+
&& [[ "$OUT" != *"PUBLISH_FAILED"* ]] && [[ "$OUT" != *"PUBLISH_COMPLETE"* ]] \
|
|
171
|
+
&& [ "$BEFORE" = "$AFTER" ]; then
|
|
172
|
+
ok "credential missing: skips with marker + actionable line, exit 0, repo untouched"
|
|
173
|
+
else
|
|
174
|
+
no "credential missing: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
|
|
175
|
+
fi
|
|
176
|
+
|
|
177
|
+
# --- Case 3: credential present + DRY_RUN=1 -> preflight passes through ---
|
|
178
|
+
RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1)"
|
|
179
|
+
CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"
|
|
180
|
+
if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_DRY_RUN=1"* ]] \
|
|
181
|
+
&& [[ "$OUT" != *"PUBLISH_SKIPPED"* ]]; then
|
|
182
|
+
ok "credential present: preflight passes through to PUBLISH_DRY_RUN=1"
|
|
183
|
+
else
|
|
184
|
+
no "credential present: code=$CODE out='$OUT'"
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
# --- Case 4: old helper without --check -> no skip (fall-through) ---
|
|
188
|
+
RUN="$(run_full "$TMPBASE/helper-old.py" DRY_RUN=1)"
|
|
189
|
+
CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"
|
|
190
|
+
if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_DRY_RUN=1"* ]] \
|
|
191
|
+
&& [[ "$OUT" != *"PUBLISH_SKIPPED"* ]]; then
|
|
192
|
+
ok "old helper (no --check): unknown, not absent — falls through, no skip"
|
|
193
|
+
else
|
|
194
|
+
no "old helper: code=$CODE out='$OUT'"
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
# --- Case 5: real helper --check probe (author machine: credential present) ---
|
|
198
|
+
CHECK_OUT="$(python3 "$REAL_HELPER" --check 2>&1)"
|
|
199
|
+
CHECK_CODE=$?
|
|
200
|
+
if [ "$CHECK_CODE" -eq 0 ] && [ "$CHECK_OUT" = "CREDENTIAL_OK=custom.npm" ]; then
|
|
201
|
+
ok "real helper --check: CREDENTIAL_OK=custom.npm, exit 0, nothing else printed"
|
|
202
|
+
else
|
|
203
|
+
no "real helper --check: code=$CHECK_CODE out='$CHECK_OUT' (expected CREDENTIAL_OK=custom.npm — this case is credential-dependent; see header)"
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
bash -n "$PUBLISH" && ok "publish-npm.sh syntax OK" || no "publish-npm.sh syntax"
|
|
207
|
+
|
|
208
|
+
echo ""
|
|
209
|
+
echo "$pass passed, $fail_count failed"
|
|
210
|
+
exit $((fail_count > 0))
|