@adia-ai/adia-ui-forge 0.8.7 → 0.8.9
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/skills/adia-release/SKILL.md +1 -1
- package/skills/adia-release/references/cut-procedure.md +16 -4
- package/skills/adia-release/references/gates-catalog.md +9 -14
- package/skills/adia-release/scripts/bump.mjs +143 -22
- package/skills/adia-release/scripts/dispatch-publish.mjs +3 -14
- package/skills/adia-release/scripts/gate-roster.mjs +9 -1
- package/skills/adia-release/scripts/package-paths.mjs +38 -0
- package/skills/adia-release/scripts/pr-bridge.mjs +353 -0
- package/skills/adia-release/scripts/release-pack.mjs +107 -27
- package/skills/adia-release/scripts/tag-lockstep.mjs +4 -15
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adia-forge",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
4
4
|
"description": "Maintain the adia-ui (@adia-ai) framework itself \u2014 author primitives and shells, run the A2UI generation pipeline and its corpus, review gen-UI quality, sweep QA, cut releases, deploy. The maintainer counterpart to adia-factory (the consumer/app-author plugin).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Kim",
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog — adia-forge
|
|
2
2
|
|
|
3
|
+
## [0.8.9] — 2026-07-19
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **`release-pack.mjs` now actually creates `release/vX.Y.Z`** before staging — `step5Commit()` previously committed on whatever branch was checked out (the v0.8.8 cut landed its release commit on local `main`, caught and recovered by hand). Idempotent (checkout if the branch exists — a resumed run — create with `-b` otherwise); `step1ReBaseline()` now asserts the starting branch is `main` or the release branch itself for cut modes. Guarded: checking out an *existing* release branch on a dirty tree now fails with a real diagnostic (which files, what to do) instead of an opaque git error.
|
|
7
|
+
- **Step 9's settle-wait polls the registry itself**, not one proxy workflow — the old code waited for `publish-a2ui-validator.yml` alone as a stand-in for all 11 publishes; a straggler on any other package made the immediately-following fail-closed registry check abort on a pure timing false alarm (hit live on the v0.8.8 cut). Now polls all 11 packages + `latest` directly, every 15s up to ~10-18min, per the skill's own doctrine ("the registry is the verify target, never a workflow's green check"). The existing fail-closed check remains as a final confirmation.
|
|
8
|
+
- **`pr-bridge.mjs` refuses to merge a DRAFT PR** — a real incident: it attempted to merge an author-marked draft, which GitHub's API rejects, and the old code (no `isDraft` check anywhere) crashed uncaught. `bridgeDecision()` now treats `isDraft` as an unconditional stop; the `gh pr merge` call and the top-level `main().catch` are both wrapped so no rejection reason — draft or otherwise — can crash invisibly under the script's own documented `grep -E "pr-bridge" | tail -N` usage pattern.
|
|
9
|
+
|
|
10
|
+
### Maintenance
|
|
11
|
+
- **`.claude-plugin/plugin.json` version bump** — moves in lockstep with package.json (the `/plugin update` cache key).
|
|
12
|
+
- **`skills/` touched in this release window** (2 file(s), e.g. `scripts/pr-bridge.mjs`) — carried by the entries above.
|
|
13
|
+
|
|
14
|
+
## [0.8.8] — 2026-07-19
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **`pr-bridge.mjs`** (adia-release `skills/adia-release/scripts/`): the cut's two-phase PR bridge, mechanized — pushes the release branch, opens/reuses the PR, polls CI, and admin-merges ONLY on all-non-fail-soft-checks-green + zero unresolved review threads + no changes-requested + MERGEABLE; anything else waits or stops with full evidence (`--dry` previews the decision). Independently reviewed and hardened pre-ship: never merges on an empty gating-check set ("nothing failed" ≠ "something passed"); operator-supplied args go through `execFileSync` arg arrays (no shell); the merge-landing proof never skips silently. 14-case selftest; registered in CI's Plugin bin selftests; battle-tested on 8 real merges before first release use. cut-procedure §5.7 documents both the mechanized and manual forms.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **`package-paths.mjs` is now the single source of the 11-package roster** (`PACKAGE_ROSTER` + `PACKAGE_NAMES`/`PACKAGE_DIRS`): the lockstep list was re-declared in 8 scripts (bump, tag-lockstep, dispatch-publish, release-pack, and the repo-side check-release/check-lockstep/check-release-docs/generate-release-notes), each with its own projection — the same list-drift class the gate-roster incident proved out. Skill-side home is deliberate: cache-executed skill scripts can't import repo modules; repo scripts import the skill module by its stable in-repo path.
|
|
21
|
+
- **`gate-roster.mjs` grew gate 18** (`check-cut-hygiene --version X.Y.Z`, via the new `versionArg` field release-pack substitutes) and **gate 4 now runs `test:unit:serial`** (`vitest --no-file-parallelism` — gates-catalog §Cat 8's documented source of truth IS now the gate). References updated same-change: cut-procedure §3.1 (18-gate table), gates-catalog Cat 1/7/8/9.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **`bump.mjs` moves every cross-file version reference in the same pass**: the factory `.mcp.json` `@adia-ai/a2ui-mcp@X.Y.Z` pin (invariant 8 — hand-fixed on BOTH the 0.8.6 and 0.8.7 cuts; `check:lockstep` now guards it too) and the root README "Current version" claim (`REPO_PINNED_REFS` — gate 18 exact-matches it, so a hand-set value fails the next cut by construction). All pins pre-flight-validated alongside `validateAllAtFrom`, so a doomed bump writes NOTHING (reviewer finding: the mid-loop check fired only after two files were already written).
|
|
25
|
+
- **adia-release docs**: SKILL.md Mechanization + cut-procedure §5.7/§3.1 + gates-catalog reflect pr-bridge, the 18-gate roster, the umbrella tag's `info` classification (the repo-side `check-release.mjs` fix that killed the Step-7 false-stop which forced manual handoffs on 0.8.6/0.8.7), and the deleted superseded `check-changelog-coverage` gate.
|
|
26
|
+
|
|
27
|
+
### Maintenance
|
|
28
|
+
- **`.claude-plugin/plugin.json` version bump** — moves in lockstep with package.json (the `/plugin update` cache key).
|
|
29
|
+
|
|
3
30
|
## [0.8.7] — 2026-07-19
|
|
4
31
|
|
|
5
32
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/adia-ui-forge",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
4
4
|
"description": "Maintain the adia-ui (@adia-ai) framework itself \u2014 author primitives and shells, run the A2UI generation pipeline and its corpus, review gen-UI quality, sweep QA, cut releases, deploy. The maintainer counterpart to adia-factory (the consumer/app-author plugin).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"adia-ui",
|
|
@@ -95,7 +95,7 @@ assumed pass, or "published" without the actual `npm view` output.
|
|
|
95
95
|
|
|
96
96
|
`release-pack.mjs` walks the cycle in two phases per invariant 3 — `--mode cut` / `--mode from-scratch` promote `[Unreleased]` (BOTH modes now — a peer's hand-authored section must be promoted whichever mode cut it, and a loud guard blocks the bump if any of the 11 packages still carries non-empty `[Unreleased]` content after promotion), run Step 4f (pre-tag coverage `--fix` with the authoritative F-N1 matcher), and stop at the release commit (PR → merge); `--mode handoff` tags/publishes/deploys from post-merge main. **`--go` is the standard invocation for an operator-initiated release** — the single authorization covers the cycle; evidence blocks print as a log; granular `--yes`/`--push`/`--publish` remain for cautious manual runs (`--push` still refuses to auto-fire past any F-N1 finding). `--mode batch` is not implemented — it hard-rejects with a pointer to [`recovery-paths.md`](references/recovery-paths.md) §Scenario 2 rather than silently misbehaving; a real batch tags each version at its own release-merge SHA, which this single-version orchestrator has no model for. Pre-flight (Step 3) runs the full 17-gate roster from the single shared list in `scripts/gate-roster.mjs` — the same list [`cut-procedure.md`](references/cut-procedure.md) §3.1 transcribes, so a subset run requires editing that one file, not two.
|
|
97
97
|
|
|
98
|
-
The pieces are callable alone: `bump.mjs` (version fields), `promote-unreleased.mjs` / `insert-stub.mjs` (CHANGELOG blocks), `tag-lockstep.mjs` (12 tags, `--delete` to move), `dispatch-publish.mjs` (publish workflows; `--verify-triggered` re-dispatches only missing runs; `--after` enforces batch ordering — dispatch output states what WAS done, never a to-do phrased as if it hadn't fired). All fail fast outside a monorepo root (`assert-monorepo-root.mjs`); fork-configurable via `--host` / `--scope`. All 6 CLI scripts (plus `scripts/release/check-release.mjs`) have a `selftest` mode wired into CI's Plugin bin selftests step; the 3 helper modules (`assert-monorepo-root`, `gate-roster`, `package-paths`) are exercised through the CLIs' selftests, not their own.
|
|
98
|
+
The pieces are callable alone: `bump.mjs` (version fields + the factory `.mcp.json` a2ui-mcp pin, invariant 8), `promote-unreleased.mjs` / `insert-stub.mjs` (CHANGELOG blocks), `tag-lockstep.mjs` (12 tags, `--delete` to move), `dispatch-publish.mjs` (publish workflows; `--verify-triggered` re-dispatches only missing runs; `--after` enforces batch ordering — dispatch output states what WAS done, never a to-do phrased as if it hadn't fired), and `pr-bridge.mjs` (the two-phase bridge: pushes the release branch, opens the PR, waits out CI, and merges ONLY on all-non-fail-soft-checks-green + zero unresolved threads + no changes-requested; any other state stops with evidence — §5.7 mechanized, `--dry` to preview the decision). All fail fast outside a monorepo root (`assert-monorepo-root.mjs`); fork-configurable via `--host` / `--scope`. All 6 CLI scripts (plus `scripts/release/check-release.mjs`) have a `selftest` mode wired into CI's Plugin bin selftests step; the 3 helper modules (`assert-monorepo-root`, `gate-roster`, `package-paths`) are exercised through the CLIs' selftests, not their own.
|
|
99
99
|
|
|
100
100
|
Site deploy (Step 10 of the handoff) dispatches `deploy-site.yml` (its own pre-flight verify → snapshot → hardened rsync → post-deploy verify → auto-rollback), never a raw local `rsync` — H1 (forge-campaign gh#268 audit): the script had drifted into bypassing that pipeline even though this doc already taught the workflow-dispatch path. The dispatch rides the cycle's single authorization (`--go`, or `--publish` on a manual run); the workflow's own GitHub environment gate is the human stop for the deploy itself.
|
|
101
101
|
|
|
@@ -78,13 +78,13 @@ npm run build:bundles # dist CSS+JS bundles
|
|
|
78
78
|
```bash
|
|
79
79
|
node scripts/build/components.mjs --verify # 1 yaml ↔ sidecar ↔ .d.ts
|
|
80
80
|
npm run verify:traits # 2 trait coverage
|
|
81
|
-
npm run check:lockstep # 3 version coherence
|
|
82
|
-
npm run test:unit
|
|
81
|
+
npm run check:lockstep # 3 version coherence (+ factory .mcp.json a2ui-mcp pin, invariant 8)
|
|
82
|
+
npm run test:unit:serial # 4 vitest, serial — the source of truth (§Cat 8; parallel flakes under load)
|
|
83
83
|
npm run typecheck # 5 tsc --noEmit
|
|
84
84
|
npm run check:demo-shells # 6 demo imports cover composes:
|
|
85
85
|
npm run check:lightningcss-build # 7 CSS minifies
|
|
86
86
|
npm run check:css-bundles-fresh # 8 dist CSS matches source
|
|
87
|
-
npm run check:js-bundles-fresh # 9 dist JS matches source
|
|
87
|
+
npm run check:js-bundles-fresh # 9 dist JS matches source + semantic content (everything-import, ≥100 -ui tags, icons-manifest ×2)
|
|
88
88
|
npm run smoke:engines # 10 gen-UI engines
|
|
89
89
|
npm run smoke:register-engine # 11 register-engine 11/11
|
|
90
90
|
npm run verify:corpus # 12 corpus 0 errors
|
|
@@ -93,6 +93,7 @@ npm run check:embeddings-fresh # 14 embeddings vs chunk index
|
|
|
93
93
|
npm run check:links # 15 intra-repo links
|
|
94
94
|
npm run eval:diff -- --engine zettel # 16 eval floors
|
|
95
95
|
npm run dogfood:status # 17 P0/P1 dogfood floor
|
|
96
|
+
node scripts/release/check-cut-hygiene.mjs --version <mode-dependent> # 18 README CDN-pin + version currency — CUT modes pre-flight at the PREVIOUS version (the README claim is exact-match and only moves at the Step-4 bump; Step 4g re-proves at the cut version), HANDOFF pre-flights at the CUT version (post-merge, the claim already moved)
|
|
96
97
|
```
|
|
97
98
|
|
|
98
99
|
Any red → route via [`gates-catalog.md`](gates-catalog.md); fix at the source, re-run the narrowest gate, then re-run the full sequence. The canonical miss: a cut that ran 7 of the gates shipped a stale-embeddings defect that surfaced a day later and cost a tag-move recovery.
|
|
@@ -157,7 +158,18 @@ Drift here means a regen output was left out of the allowlist — stage it and r
|
|
|
157
158
|
|
|
158
159
|
## §Step 5.7 — Release PR: push the branch, merge, re-baseline
|
|
159
160
|
|
|
160
|
-
The release commit reaches `main` through the standard PR flow
|
|
161
|
+
The release commit reaches `main` through the standard PR flow. Mechanized
|
|
162
|
+
form (preferred — `pr-bridge.mjs` pushes, opens the PR, polls, merges ONLY
|
|
163
|
+
when every non-fail-soft check is green AND zero review threads are
|
|
164
|
+
unresolved AND no review requests changes; any other state stops with the
|
|
165
|
+
evidence, never force-merges):
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
node "${CLAUDE_PLUGIN_ROOT}/skills/adia-release/scripts/pr-bridge.mjs" \
|
|
169
|
+
--branch "release/vX.Y.Z" --title "release: vX.Y.Z lockstep" --body-file <path>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Manual form (the same flow the bridge mechanizes):
|
|
161
173
|
|
|
162
174
|
```bash
|
|
163
175
|
git -C "$REPO" push -u origin "release/vX.Y.Z"
|
|
@@ -25,12 +25,9 @@ Row layout per gate: **What** · **Typical failure** · **Recovery**.
|
|
|
25
25
|
- **Cosmetic failure:** "diff `packages/<pkg>/components/` touched but CHANGELOG `[X.Y.Z]` doesn't mention 'components'" — the entry names the component (`table-ui`) but not the literal path keyword. Regex miss, change IS documented.
|
|
26
26
|
- **Real failure:** a touched directory has NO matching CHANGELOG entry at all — the cycle missed documenting a change.
|
|
27
27
|
- **Recovery:** cosmetic → add the path keyword to a relevant entry (`table.yaml` → `components/table/table.yaml`); real → author the missing entry. Either way: land it as a new commit through the PR flow (the release commit is already merged — `--amend` is not possible post-invariant-3), delete + re-create the tags at the new post-merge SHA, re-run. Full discipline: [`changelog-discipline.md`](changelog-discipline.md) §F-N1 enrichment.
|
|
28
|
-
- The umbrella tag
|
|
28
|
+
- The umbrella tag `vX.Y.Z` classifies **`info`** (since 2026-07-19 — it used to score `error`, which false-stopped release-pack's Step 7 on every cut); only per-package findings need action.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
- **What:** every published package has a `[vX.Y.Z]` CHANGELOG block for the current `package.json` version.
|
|
33
|
-
- **Recovery:** add the block (stub if genuinely no source change — but classify by the diff first, see [`changelog-discipline.md`](changelog-discipline.md) §Detecting source-changing packages).
|
|
30
|
+
(The former `check:changelog-coverage` gate was deleted 2026-07-19 — its drifted 9-package matcher could report "clean" where the authoritative gate fails. Pre-tag coverage is `check-release.mjs --pending-version X.Y.Z [--fix]`, Step 4f.)
|
|
34
31
|
|
|
35
32
|
---
|
|
36
33
|
|
|
@@ -141,14 +138,7 @@ The two `audit:*` probes are soft-fail pre-cut (exit-1 only on criticals); findi
|
|
|
141
138
|
|
|
142
139
|
- **What:** the `dist/` CDN bundles (`web-components.min.{css,js}`, `everything.min.js`, per-shell `*.min.js`, `icons-manifest.js`) match source. Stale bundles mean jsdelivr/unpkg serve yesterday's build. **Blocking in CI** (2026-06-08).
|
|
143
140
|
- **Recovery:** `npm run build -w @adia-ai/llm` **FIRST** (its `index.js` is a gitignored tsc artifact `build:bundle-js` resolves — a fresh worktree can't bundle without it), then `npm run build:bundles` (or `build:bundle-css` / `build:bundle-js` individually). Stage both `dist/` trees.
|
|
144
|
-
- **
|
|
145
|
-
|
|
146
|
-
```bash
|
|
147
|
-
grep "import '@adia-ai/web-components'" packages/web-modules/everything.js # must be present
|
|
148
|
-
grep -oE '"[a-z][a-z-]*-ui"' packages/web-modules/dist/everything.min.js | sort -u | wc -l # ≥100
|
|
149
|
-
node -e "const m=require('./packages/web-components/dist/icons-manifest.js'); \
|
|
150
|
-
console.log('regular:', m.default.regular.length, '| cdn:', m.default.cdn)" # non-empty + cdn key
|
|
151
|
-
```
|
|
141
|
+
- **Semantic content checks — mechanized 2026-07-19** (formerly manual spot-checks; the v0.6.29–31 cycles shipped a shells-only `everything.min.js` that hard-crashed CDN consumers): `bundle-js.mjs` now enforces, on every build AND `--verify` run, that (1) `everything.js` still imports `@adia-ai/web-components`, (2) `everything.min.js` carries ≥100 distinct `-ui` tags, and (3) `icons-manifest.js` exists populated in BOTH dist trees. A fresh-but-semantically-wrong bundle fails the gate itself — no separate manual step remains.
|
|
152
142
|
|
|
153
143
|
### `npm run audit:chunk-reconcile`
|
|
154
144
|
|
|
@@ -162,7 +152,7 @@ The two `audit:*` probes are soft-fail pre-cut (exit-1 only on criticals); findi
|
|
|
162
152
|
|
|
163
153
|
- **What:** the vitest suite (~1000+ tests).
|
|
164
154
|
- **Stale-test failure:** the test asserts a behavior a peer deliberately changed (CHANGELOG-documented) without updating the assertion. Tell it apart by reading assertion vs CHANGELOG vs source: if the code matches the CHANGELOG's described behavior, the test is stale — update the assertion (see [`recovery-paths.md`](recovery-paths.md) §Scenario 5). Real regression → fix the regression.
|
|
165
|
-
- **Parallel-contention flake:** heavy web-modules composite suites can fail under full parallelism on a loaded machine (`signals: drain loop exceeded 100 iterations`). Load-dependent, not a code defect. **
|
|
155
|
+
- **Parallel-contention flake:** heavy web-modules composite suites can fail under full parallelism on a loaded machine (`signals: drain loop exceeded 100 iterations`). Load-dependent, not a code defect. Sequential is the source of truth, and **the roster gate now runs it directly** (`npm run test:unit:serial` = `vitest run --no-file-parallelism`, gate 4 since 2026-07-19) — a parallel `test:unit` flake outside the roster still isn't a blocker, and don't raise the drain guard.
|
|
166
156
|
|
|
167
157
|
### `npm run eval:diff -- --engine zettel`
|
|
168
158
|
|
|
@@ -179,6 +169,11 @@ Siblings: `typecheck` (`tsc --noEmit`) · `smoke:engines` (gen-UI engines + retr
|
|
|
179
169
|
|
|
180
170
|
## §Category 9 — Misc release safety
|
|
181
171
|
|
|
172
|
+
### `node scripts/release/check-cut-hygiene.mjs --version X.Y.Z`
|
|
173
|
+
|
|
174
|
+
- **What:** roster gate 18 (wired 2026-07-19; release-pack fills `--version` — the roster's one `versionArg` entry). The objective half of the v0.7.13-retro hygiene checker: published README CDN pins / "Current version" claims must not sit below the cut's minor (`doc-currency`, error-level); merged release branches + extra worktrees print as `limbo` warns (advisory without `--strict`). The judgment half of §4e — content *currency* of what the READMEs say — stays yours.
|
|
175
|
+
- **Recovery:** update the stale README pin/claim, stage it into the release commit. A `limbo` warn routes to branch/worktree cleanup, never blocks the cut.
|
|
176
|
+
|
|
182
177
|
### `npm run dogfood:status`
|
|
183
178
|
|
|
184
179
|
- **What:** aggregator over the component dogfood audits; classifies findings P0–P3 and regenerates the tracker at `qa/findings/dogfood-tracker.md`. Exit-1 when P0+P1 > 0; P2/P3 are advisory.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// bump.mjs — bump
|
|
2
|
+
// bump.mjs — bump the 11 lockstep @adia-ai/* package.json from --from to --to.
|
|
3
3
|
//
|
|
4
4
|
// Idempotent. Stops on the first mismatch (a package not at --from) so
|
|
5
5
|
// you can investigate before any mutation lands.
|
|
@@ -20,32 +20,55 @@
|
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import process from 'node:process';
|
|
23
|
+
import { PACKAGE_DIRS, PACKAGE_ROSTER } from './package-paths.mjs';
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'packages/a2ui/compose',
|
|
29
|
-
'packages/a2ui/corpus',
|
|
30
|
-
'packages/a2ui/mcp',
|
|
31
|
-
'packages/a2ui/retrieval',
|
|
32
|
-
'packages/a2ui/runtime',
|
|
33
|
-
'packages/a2ui/validator',
|
|
34
|
-
// Plugins joined the lockstep 2026-07-15 (see check-lockstep.mjs's POLICY
|
|
35
|
-
// note). Each also carries a .claude-plugin/plugin.json whose version MUST
|
|
36
|
-
// move with package.json — plugin.json is the /plugin-update cache key
|
|
37
|
-
// (verify:plugins gates the sync).
|
|
38
|
-
'packages/plugins/adia-ui-factory',
|
|
39
|
-
'packages/plugins/adia-ui-forge',
|
|
40
|
-
];
|
|
25
|
+
// Single-sourced roster (H3, package-paths.mjs). Each plugin also carries a
|
|
26
|
+
// .claude-plugin/plugin.json whose version MUST move with package.json —
|
|
27
|
+
// plugin.json is the /plugin-update cache key (verify:plugins gates the sync).
|
|
28
|
+
const PACKAGES = PACKAGE_DIRS;
|
|
41
29
|
|
|
42
30
|
// Sibling manifests bumped in the same pass as package.json (path relative to
|
|
43
|
-
// the package dir)
|
|
44
|
-
const SIBLING_MANIFESTS =
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
// the package dir) — derived from the roster's plugin flag.
|
|
32
|
+
const SIBLING_MANIFESTS = Object.fromEntries(
|
|
33
|
+
PACKAGE_ROSTER.filter((p) => p.plugin).map((p) => [p.dir, '.claude-plugin/plugin.json']),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Cross-package EXACT pins bumped in the same pass — a different shape from a
|
|
37
|
+
// package.json "version" field, so SIBLING_MANIFESTS' regex can't reach them.
|
|
38
|
+
// The factory plugin's .mcp.json pins @adia-ai/a2ui-mcp to the exact lockstep
|
|
39
|
+
// version (SKILL invariant 8 — the consumer never floats). bump.mjs left this
|
|
40
|
+
// untouched through the 0.8.6 + 0.8.7 cuts, forcing a manual edit + amend each
|
|
41
|
+
// time; check-lockstep's mcp-pin guard now catches a drift, this moves it.
|
|
42
|
+
const PINNED_REFS = {
|
|
43
|
+
'packages/plugins/adia-ui-factory': {
|
|
44
|
+
file: '.mcp.json',
|
|
45
|
+
label: '@adia-ai/a2ui-mcp pin',
|
|
46
|
+
// `@adia-ai/a2ui-mcp@<from>` (not a prefix of a longer version) → `@<to>`
|
|
47
|
+
pattern: (from) => new RegExp(`(@adia-ai/a2ui-mcp@)${from.replace(/\./g, '\\.')}(?![\\d.])`),
|
|
48
|
+
replace: (to) => `$1${to}`,
|
|
49
|
+
},
|
|
47
50
|
};
|
|
48
51
|
|
|
52
|
+
// Repo-level version references (path relative to REPO ROOT, not a package) —
|
|
53
|
+
// same mechanism, different anchor. The root README's "Current version" claim
|
|
54
|
+
// is gate 18's EXACT-match check (check-cut-hygiene doc-currency): hand-set it
|
|
55
|
+
// once and the next cut fails the gate — so the bump moves it, like the
|
|
56
|
+
// .mcp.json pin (H2 follow-through, 2026-07-19: found 0.7.13 claimed while
|
|
57
|
+
// 0.8.7 was live — a full minor of README drift shipped to npm).
|
|
58
|
+
const REPO_PINNED_REFS = [
|
|
59
|
+
{
|
|
60
|
+
file: 'README.md',
|
|
61
|
+
label: '"Current version" claim',
|
|
62
|
+
pattern: (from) => new RegExp(`([Cc]urrent version[^\\d]*v?)${from.replace(/\./g, '\\.')}(?![\\d.])`),
|
|
63
|
+
replace: (to) => `$1${to}`,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Pure so the selftest can lock the transform without touching a real file.
|
|
68
|
+
function bumpPinnedRef(text, cfg, from, to) {
|
|
69
|
+
return text.replace(cfg.pattern(from), cfg.replace(to));
|
|
70
|
+
}
|
|
71
|
+
|
|
49
72
|
function parseArgs(argv) {
|
|
50
73
|
const args = { from: null, to: null, dry: false, repo: process.cwd() };
|
|
51
74
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -84,6 +107,52 @@ function loadPackageJsons(repo) {
|
|
|
84
107
|
return result;
|
|
85
108
|
}
|
|
86
109
|
|
|
110
|
+
// Pre-flight the cross-package pins the same way validateAllAtFrom pre-flights
|
|
111
|
+
// versions: a pin not at --from means invariant-8 drift, and catching it HERE —
|
|
112
|
+
// before bumpAll writes anything — keeps the failure mutation-free. (Reviewer
|
|
113
|
+
// finding on the first PINNED_REFS cut: the mid-loop pin check fired only after
|
|
114
|
+
// factory's package.json + plugin.json were already written, leaving a
|
|
115
|
+
// half-bumped tree whose re-run then failed validateAllAtFrom.)
|
|
116
|
+
function loadPinnedRefs(repo) {
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const [pkg, cfg] of Object.entries(PINNED_REFS)) {
|
|
119
|
+
const p = path.join(repo, pkg, cfg.file);
|
|
120
|
+
if (!fs.existsSync(p)) {
|
|
121
|
+
console.error(`error: missing ${p}`);
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
out.push({ pkg, cfg, path: p, txt: fs.readFileSync(p, 'utf8') });
|
|
125
|
+
}
|
|
126
|
+
for (const cfg of REPO_PINNED_REFS) {
|
|
127
|
+
const p = path.join(repo, cfg.file);
|
|
128
|
+
if (!fs.existsSync(p)) {
|
|
129
|
+
console.error(`error: missing ${p}`);
|
|
130
|
+
process.exit(2);
|
|
131
|
+
}
|
|
132
|
+
out.push({ pkg: '(repo)', cfg, path: p, txt: fs.readFileSync(p, 'utf8') });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Repo-level pins aren't tied to a package iteration in bumpAll — apply them
|
|
138
|
+
// after it, from the same pre-validated set (validatePinsAtFrom guarantees the
|
|
139
|
+
// pattern matches, so this cannot fail mid-write).
|
|
140
|
+
function applyRepoPins(pins, from, to, dry) {
|
|
141
|
+
for (const x of pins.filter((x) => x.pkg === '(repo)')) {
|
|
142
|
+
const out = bumpPinnedRef(x.txt, x.cfg, from, to);
|
|
143
|
+
if (!dry) fs.writeFileSync(x.path, out);
|
|
144
|
+
console.log(` ${dry ? '[dry] ' : ''}bumped ${x.cfg.file} ${x.cfg.label}: ${from} → ${to}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validatePinsAtFrom(pins, from) {
|
|
149
|
+
const bad = pins.filter((x) => !x.cfg.pattern(from).test(x.txt));
|
|
150
|
+
for (const x of bad) {
|
|
151
|
+
console.error(`error: ${x.pkg}/${x.cfg.file} — ${x.cfg.label} '@${from}' not found (invariant-8 drift? fix the pin first: \`npm run check:lockstep:fix\`). Nothing written.`);
|
|
152
|
+
}
|
|
153
|
+
return bad.length === 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
87
156
|
function validateAllAtFrom(packages, from) {
|
|
88
157
|
const wrong = packages.filter((p) => p.version !== from);
|
|
89
158
|
if (wrong.length > 0) {
|
|
@@ -121,6 +190,18 @@ function bumpAll(packages, from, to, dry) {
|
|
|
121
190
|
if (!dry) fs.writeFileSync(sibPath, sibOut);
|
|
122
191
|
console.log(` ${dry ? '[dry] ' : ''}bumped ${p.pkg}/${sibling}: ${from} → ${to}`);
|
|
123
192
|
}
|
|
193
|
+
const pin = PINNED_REFS[p.pkg];
|
|
194
|
+
if (pin) {
|
|
195
|
+
const pinPath = path.join(path.dirname(p.path), pin.file);
|
|
196
|
+
const pinTxt = fs.readFileSync(pinPath, 'utf8');
|
|
197
|
+
const pinOut = bumpPinnedRef(pinTxt, pin, from, to);
|
|
198
|
+
if (pinOut === pinTxt) {
|
|
199
|
+
console.error(`error: ${p.pkg}/${pin.file} — ${pin.label} '@${from}' not found (invariant 8 drift? set the pin to @${from} and re-run, or use \`npm run check:lockstep:fix\`)`);
|
|
200
|
+
return -1;
|
|
201
|
+
}
|
|
202
|
+
if (!dry) fs.writeFileSync(pinPath, pinOut);
|
|
203
|
+
console.log(` ${dry ? '[dry] ' : ''}bumped ${p.pkg}/${pin.file} ${pin.label}: @${from} → @${to}`);
|
|
204
|
+
}
|
|
124
205
|
}
|
|
125
206
|
return bumped;
|
|
126
207
|
}
|
|
@@ -128,8 +209,11 @@ function bumpAll(packages, from, to, dry) {
|
|
|
128
209
|
function main() {
|
|
129
210
|
const args = parseArgs(process.argv.slice(2));
|
|
130
211
|
const packages = loadPackageJsons(args.repo);
|
|
212
|
+
const pins = loadPinnedRefs(args.repo);
|
|
131
213
|
if (!validateAllAtFrom(packages, args.from)) process.exit(1);
|
|
214
|
+
if (!validatePinsAtFrom(pins, args.from)) process.exit(1);
|
|
132
215
|
const bumped = bumpAll(packages, args.from, args.to, args.dry);
|
|
216
|
+
if (bumped >= 0) applyRepoPins(pins, args.from, args.to, args.dry);
|
|
133
217
|
if (bumped < 0) process.exit(3);
|
|
134
218
|
console.log(`\n[bump] ${bumped}/${PACKAGES.length} ${args.dry ? '(dry — no files written)' : 'bumped'}`);
|
|
135
219
|
if (!args.dry) {
|
|
@@ -166,6 +250,43 @@ function selftest() {
|
|
|
166
250
|
if (bumpAll(corrupted, '0.8.4', '0.8.5', true) !== -1) {
|
|
167
251
|
console.error('selftest FAIL: corrupted package.json (no version match) not caught'); process.exit(1);
|
|
168
252
|
}
|
|
253
|
+
|
|
254
|
+
// PINNED_REFS — the .mcp.json a2ui-mcp pin (invariant 8) moves in lockstep.
|
|
255
|
+
const pinCfg = PINNED_REFS['packages/plugins/adia-ui-factory'];
|
|
256
|
+
const pinBefore = '{"args":["-y","@adia-ai/a2ui-mcp@0.8.6"]}';
|
|
257
|
+
const pinAfter = bumpPinnedRef(pinBefore, pinCfg, '0.8.6', '0.8.7');
|
|
258
|
+
if (!pinAfter.includes('@adia-ai/a2ui-mcp@0.8.7') || pinAfter.includes('@0.8.6')) {
|
|
259
|
+
console.error('selftest FAIL: PINNED_REFS did not bump the .mcp.json a2ui-mcp pin'); process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
// Must NOT prefix-match a longer version (0.8.6 inside 0.8.60).
|
|
262
|
+
if (bumpPinnedRef('@adia-ai/a2ui-mcp@0.8.60', pinCfg, '0.8.6', '0.8.7') !== '@adia-ai/a2ui-mcp@0.8.60') {
|
|
263
|
+
console.error('selftest FAIL: PINNED_REFS pattern prefix-matched a longer version'); process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// validatePinsAtFrom — the mutation-free pre-flight (reviewer finding: the
|
|
267
|
+
// mid-loop pin check fired only after two files were already written).
|
|
268
|
+
const goodPin = [{ pkg: 'packages/plugins/adia-ui-factory', cfg: pinCfg, path: '/nonexistent/.mcp.json', txt: pinBefore }];
|
|
269
|
+
if (!validatePinsAtFrom(goodPin, '0.8.6')) {
|
|
270
|
+
console.error('selftest FAIL: validatePinsAtFrom rejected a pin at --from'); process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
if (validatePinsAtFrom(goodPin, '0.8.5')) {
|
|
273
|
+
console.error('selftest FAIL: validatePinsAtFrom accepted a drifted pin (should pre-flight-fail before any write)'); process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// REPO_PINNED_REFS — the root README "Current version" claim moves per-cut
|
|
277
|
+
// (gate 18's doc-currency check is EXACT-match, so a static value fails the
|
|
278
|
+
// next cut by construction).
|
|
279
|
+
const readmeCfg = REPO_PINNED_REFS[0];
|
|
280
|
+
const claimBefore = 'lockstep. Current version: **`v0.8.7`** — see CHANGELOG';
|
|
281
|
+
const claimAfter = bumpPinnedRef(claimBefore, readmeCfg, '0.8.7', '0.8.8');
|
|
282
|
+
if (!claimAfter.includes('v0.8.8') || claimAfter.includes('0.8.7')) {
|
|
283
|
+
console.error('selftest FAIL: REPO_PINNED_REFS did not move the Current-version claim'); process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
// Must not touch an already-moved or unrelated version string.
|
|
286
|
+
if (bumpPinnedRef('Current version: v0.8.70', readmeCfg, '0.8.7', '0.8.8') !== 'Current version: v0.8.70') {
|
|
287
|
+
console.error('selftest FAIL: Current-version pattern prefix-matched a longer version'); process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
|
|
169
290
|
console.log('selftest OK');
|
|
170
291
|
}
|
|
171
292
|
|
|
@@ -20,25 +20,14 @@
|
|
|
20
20
|
import { execSync } from 'node:child_process';
|
|
21
21
|
import process from 'node:process';
|
|
22
22
|
import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
|
|
23
|
+
import { PACKAGE_NAMES } from './package-paths.mjs';
|
|
23
24
|
|
|
24
25
|
// Instance data — fork-configurable. The npm scope the 11 packages publish under.
|
|
25
26
|
// Default preserves @adia-ai behavior; override via --scope or $ADIA_NPM_SCOPE.
|
|
26
27
|
const DEFAULT_SCOPE = '@adia-ai';
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
'web-modules',
|
|
31
|
-
'llm',
|
|
32
|
-
'a2ui-runtime',
|
|
33
|
-
'a2ui-compose',
|
|
34
|
-
'a2ui-corpus',
|
|
35
|
-
'a2ui-mcp',
|
|
36
|
-
'a2ui-retrieval',
|
|
37
|
-
'a2ui-validator',
|
|
38
|
-
// Plugins joined the lockstep 2026-07-15 (check-lockstep.mjs POLICY note).
|
|
39
|
-
'adia-ui-factory',
|
|
40
|
-
'adia-ui-forge',
|
|
41
|
-
];
|
|
29
|
+
// Single-sourced roster (H3, package-paths.mjs); order = dispatch order.
|
|
30
|
+
const PACKAGES = PACKAGE_NAMES;
|
|
42
31
|
|
|
43
32
|
function parseArgs(argv) {
|
|
44
33
|
const args = {
|
|
@@ -23,7 +23,10 @@ export const GATE_ROSTER = [
|
|
|
23
23
|
{ n: 1, cmd: 'node scripts/build/components.mjs --verify', what: 'yaml ↔ sidecar ↔ .d.ts' },
|
|
24
24
|
{ n: 2, cmd: 'npm run verify:traits', what: 'trait coverage' },
|
|
25
25
|
{ n: 3, cmd: 'npm run check:lockstep', what: 'version coherence' },
|
|
26
|
-
|
|
26
|
+
// Serial on purpose: heavy web-modules suites flake under full parallelism
|
|
27
|
+
// ("drain loop exceeded 100 iterations") — gates-catalog §Cat 8 names the
|
|
28
|
+
// sequential run as the source of truth; the gate now IS that run (H5).
|
|
29
|
+
{ n: 4, cmd: 'npm run test:unit:serial', what: 'vitest (serial — the source of truth)' },
|
|
27
30
|
{ n: 5, cmd: 'npm run typecheck', what: 'tsc --noEmit' },
|
|
28
31
|
{ n: 6, cmd: 'npm run check:demo-shells', what: 'demo imports cover composes:' },
|
|
29
32
|
{ n: 7, cmd: 'npm run check:lightningcss-build', what: 'CSS minifies' },
|
|
@@ -37,6 +40,11 @@ export const GATE_ROSTER = [
|
|
|
37
40
|
{ n: 15, cmd: 'npm run check:links', what: 'intra-repo links' },
|
|
38
41
|
{ n: 16, cmd: 'npm run eval:diff -- --engine zettel', what: 'eval floors' },
|
|
39
42
|
{ n: 17, cmd: 'npm run dogfood:status', what: 'P0/P1 dogfood floor' },
|
|
43
|
+
// versionArg: the runner appends ` --version X.Y.Z` (release-pack knows the
|
|
44
|
+
// cut version; the roster stays a static list). Objective half of the
|
|
45
|
+
// v0.7.13-retro hygiene checker — README CDN pins / "Current version"
|
|
46
|
+
// claims vs the cut version; branch/worktree limbo prints as warns (H2).
|
|
47
|
+
{ n: 18, cmd: 'node scripts/release/check-cut-hygiene.mjs', what: 'README CDN-pin + version currency', versionArg: true },
|
|
40
48
|
];
|
|
41
49
|
|
|
42
50
|
// -- CLI ------------------------------------------------------------------
|
|
@@ -23,6 +23,44 @@
|
|
|
23
23
|
import fs from 'node:fs';
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
|
|
26
|
+
// ─── THE package roster — single source of truth (H3, 2026-07-19) ─────────
|
|
27
|
+
//
|
|
28
|
+
// The 11-package lockstep list was re-declared in ~8 files (bump, tag-lockstep,
|
|
29
|
+
// dispatch-publish, release-pack, check-release, check-lockstep,
|
|
30
|
+
// check-release-docs, generate-release-notes), each carrying its own
|
|
31
|
+
// name/path projection — adding the 12th package meant 8 hand-edits, and the
|
|
32
|
+
// gate-roster.mjs incident (release-pack silently running 12 of 17 gates)
|
|
33
|
+
// proved what list-drift does. Same fix, same pattern: ONE list here, every
|
|
34
|
+
// consumer projects from it.
|
|
35
|
+
//
|
|
36
|
+
// This module lives in the SKILL scripts dir deliberately: skill scripts must
|
|
37
|
+
// work from the installed plugin cache (CLAUDE_PLUGIN_ROOT), where repo
|
|
38
|
+
// modules aren't importable — but repo scripts (scripts/release/*) always run
|
|
39
|
+
// in-repo and CAN import this by its stable in-repo path. The asymmetry
|
|
40
|
+
// decides the home.
|
|
41
|
+
//
|
|
42
|
+
// Order is the canonical publish/tag order (release-pack's historical order);
|
|
43
|
+
// consumers needing a different ordering (check-lockstep sorts by path for
|
|
44
|
+
// stable output) re-sort locally.
|
|
45
|
+
export const PACKAGE_ROSTER = [
|
|
46
|
+
{ name: 'web-components', dir: 'packages/web-components' },
|
|
47
|
+
{ name: 'web-modules', dir: 'packages/web-modules' },
|
|
48
|
+
{ name: 'llm', dir: 'packages/llm' },
|
|
49
|
+
{ name: 'a2ui-runtime', dir: 'packages/a2ui/runtime' },
|
|
50
|
+
{ name: 'a2ui-compose', dir: 'packages/a2ui/compose' },
|
|
51
|
+
{ name: 'a2ui-corpus', dir: 'packages/a2ui/corpus' },
|
|
52
|
+
{ name: 'a2ui-mcp', dir: 'packages/a2ui/mcp' },
|
|
53
|
+
{ name: 'a2ui-retrieval', dir: 'packages/a2ui/retrieval' },
|
|
54
|
+
{ name: 'a2ui-validator', dir: 'packages/a2ui/validator' },
|
|
55
|
+
// Plugins joined the lockstep 2026-07-15 (check-lockstep.mjs POLICY note);
|
|
56
|
+
// each carries a .claude-plugin/plugin.json that bumps with package.json.
|
|
57
|
+
{ name: 'adia-ui-factory', dir: 'packages/plugins/adia-ui-factory', plugin: true },
|
|
58
|
+
{ name: 'adia-ui-forge', dir: 'packages/plugins/adia-ui-forge', plugin: true },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export const PACKAGE_NAMES = PACKAGE_ROSTER.map((p) => p.name);
|
|
62
|
+
export const PACKAGE_DIRS = PACKAGE_ROSTER.map((p) => p.dir);
|
|
63
|
+
|
|
26
64
|
// Resolve a package arg to its on-disk directory under packages/, accepting
|
|
27
65
|
// BOTH the name form (`a2ui-corpus`) and the path form (`a2ui/corpus`).
|
|
28
66
|
// Returns the resolved relative directory (e.g. `a2ui/corpus`); does not
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pr-bridge.mjs — babysit a release PR from push to merged main (the cut's
|
|
3
|
+
// two-phase bridge, mechanized).
|
|
4
|
+
//
|
|
5
|
+
// The cut modes of release-pack.mjs stop at the release commit (invariant 3:
|
|
6
|
+
// release commits land via PR, never a direct push to main). The bridge from
|
|
7
|
+
// there to `--mode handoff` — push the branch, open the PR, wait out CI,
|
|
8
|
+
// merge, re-baseline main — was the release cycle's last hand-carried leg
|
|
9
|
+
// (the operator babysat it on every cut). This script mechanizes EXACTLY the
|
|
10
|
+
// documented house flow (cut-procedure.md §5.7), no policy expansion:
|
|
11
|
+
//
|
|
12
|
+
// MERGES only when ALL of:
|
|
13
|
+
// - every non-fail-soft check has landed SUCCESS (jobs whose name carries
|
|
14
|
+
// the repo's "(fail-soft)" marker are advisory — reported, never gating,
|
|
15
|
+
// matching §5.7's "required checks (fail-soft jobs excluded)")
|
|
16
|
+
// - zero unresolved review threads (admin merge bypasses required review
|
|
17
|
+
// but NOT unresolved threads — repo-verified behavior)
|
|
18
|
+
// - no CHANGES_REQUESTED review decision
|
|
19
|
+
// - GitHub reports the PR MERGEABLE
|
|
20
|
+
// WAITS while anything is still pending/queued/UNKNOWN (up to --timeout).
|
|
21
|
+
// STOPS (exit 1, full evidence, no mutation) on anything else: a failed
|
|
22
|
+
// check, an unresolved thread, changes requested, a merge conflict, or
|
|
23
|
+
// timeout. It never force-merges past a red or contested state — a stop
|
|
24
|
+
// here is a human's turn, by design.
|
|
25
|
+
//
|
|
26
|
+
// Usage:
|
|
27
|
+
// node pr-bridge.mjs --branch release/v0.8.8 \
|
|
28
|
+
// --title "release: v0.8.8 lockstep" --body-file /tmp/pr-body.md
|
|
29
|
+
// node pr-bridge.mjs --branch <existing-pr-branch> # reuses the open PR
|
|
30
|
+
// node pr-bridge.mjs selftest
|
|
31
|
+
//
|
|
32
|
+
// Flags:
|
|
33
|
+
// --poll-interval <s> seconds between polls (default 30)
|
|
34
|
+
// --timeout <s> max seconds to wait for pending checks (default 2400)
|
|
35
|
+
// --dry report the current decision for the PR and exit;
|
|
36
|
+
// no push, no create, no merge
|
|
37
|
+
//
|
|
38
|
+
// Exit: 0 merged + main re-baselined · 1 stopped (evidence printed) · 2 bad args.
|
|
39
|
+
//
|
|
40
|
+
// Part of the adia-release skill. Registered in CI's "Plugin bin selftests".
|
|
41
|
+
|
|
42
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import process from 'node:process';
|
|
45
|
+
import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
|
|
46
|
+
|
|
47
|
+
const REPO = process.cwd();
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const args = {
|
|
51
|
+
branch: null, title: null, bodyFile: null,
|
|
52
|
+
pollInterval: 30, timeout: 2400, dry: false,
|
|
53
|
+
};
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const k = argv[i];
|
|
56
|
+
if (k === '--branch') args.branch = argv[++i];
|
|
57
|
+
else if (k === '--title') args.title = argv[++i];
|
|
58
|
+
else if (k === '--body-file') args.bodyFile = argv[++i];
|
|
59
|
+
else if (k === '--poll-interval') args.pollInterval = Number(argv[++i]);
|
|
60
|
+
else if (k === '--timeout') args.timeout = Number(argv[++i]);
|
|
61
|
+
else if (k === '--dry') args.dry = true;
|
|
62
|
+
else if (k === '-h' || k === '--help') { help(); process.exit(0); }
|
|
63
|
+
}
|
|
64
|
+
if (!args.branch) {
|
|
65
|
+
console.error('error: --branch is required (see --help)');
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isFinite(args.pollInterval) || args.pollInterval < 5 ||
|
|
69
|
+
!Number.isFinite(args.timeout) || args.timeout < args.pollInterval) {
|
|
70
|
+
console.error('error: --poll-interval must be ≥5s and --timeout ≥ --poll-interval');
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
return args;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function help() {
|
|
77
|
+
console.log(`Usage:
|
|
78
|
+
node pr-bridge.mjs --branch release/vX.Y.Z --title "release: vX.Y.Z lockstep" --body-file <path>
|
|
79
|
+
node pr-bridge.mjs --branch <branch-with-open-pr> # reuse the PR
|
|
80
|
+
node pr-bridge.mjs --branch <branch> --dry # decision only
|
|
81
|
+
Flags: --poll-interval <s=30> --timeout <s=2400> --dry`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sh(cmd) {
|
|
85
|
+
return execSync(cmd, { cwd: REPO, encoding: 'utf8' }).trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// No-shell variant for every call that carries an operator-supplied value
|
|
89
|
+
// (branch, title, body path) — reviewer finding #2: string interpolation left
|
|
90
|
+
// `$(…)`/backticks live inside double quotes and broke on metachar branch
|
|
91
|
+
// names. execFileSync + arg array = no shell, no expansion, no quoting bugs.
|
|
92
|
+
function shFile(cmd, argv) {
|
|
93
|
+
return execFileSync(cmd, argv, { cwd: REPO, encoding: 'utf8' }).trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── The decision predicate — pure, selftest-locked ────────────────
|
|
97
|
+
// state: {
|
|
98
|
+
// checks: [{ name, status, conclusion }] normalized check runs + contexts
|
|
99
|
+
// unresolvedThreads: number
|
|
100
|
+
// reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null
|
|
101
|
+
// mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
|
|
102
|
+
// }
|
|
103
|
+
// → { action: 'merge' | 'wait' | 'stop', reasons: [string] }
|
|
104
|
+
//
|
|
105
|
+
// Gating checks = every check whose name does NOT carry the "(fail-soft)"
|
|
106
|
+
// marker (the repo's own advisory-job convention). NEUTRAL and SKIPPED count
|
|
107
|
+
// as pass; anything not landed counts as pending; every other conclusion is
|
|
108
|
+
// a failure. CHANGES_REQUESTED, unresolved threads, and CONFLICTING always
|
|
109
|
+
// stop — no flag overrides them.
|
|
110
|
+
export const FAIL_SOFT_RE = /fail-soft/i;
|
|
111
|
+
const PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
112
|
+
const PENDING = new Set(['PENDING', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED', 'EXPECTED', '']);
|
|
113
|
+
|
|
114
|
+
export function bridgeDecision(state) {
|
|
115
|
+
const reasons = [];
|
|
116
|
+
let pending = false;
|
|
117
|
+
let gatingLanded = 0;
|
|
118
|
+
|
|
119
|
+
for (const c of state.checks) {
|
|
120
|
+
if (FAIL_SOFT_RE.test(c.name)) continue; // advisory by convention
|
|
121
|
+
const landed = (c.status || '').toUpperCase() === 'COMPLETED' || c.conclusion;
|
|
122
|
+
const concl = (c.conclusion || c.status || '').toUpperCase();
|
|
123
|
+
if (!landed || PENDING.has(concl)) { pending = true; continue; }
|
|
124
|
+
if (!PASS.has(concl)) reasons.push(`check '${c.name}' landed ${concl}`);
|
|
125
|
+
else gatingLanded++;
|
|
126
|
+
}
|
|
127
|
+
// gh#(pr-bridge draft guard): a draft PR is the AUTHOR's own "not ready"
|
|
128
|
+
// signal — GitHub's API refuses to merge one anyway (GraphQL: "Pull
|
|
129
|
+
// Request is still a draft"), and the old code had no isDraft check, so
|
|
130
|
+
// bridgeDecision said MERGE, the gh pr merge call threw, and the whole
|
|
131
|
+
// script crashed uncaught. A caller piping through `grep -E "pr-bridge" |
|
|
132
|
+
// tail -N` (this script's own documented usage pattern) sees only the
|
|
133
|
+
// last N lines BEFORE the crash and no error — reads as quiet success.
|
|
134
|
+
// Unconditional stop (review nit: placed after the per-check loop above,
|
|
135
|
+
// not literally first — but every reasons.push() here is unconditional and
|
|
136
|
+
// the "stop" decision below fires on ANY non-empty reasons list, so a
|
|
137
|
+
// draft overrides an otherwise-perfect merge regardless of ordering):
|
|
138
|
+
// never attempt the merge call on a draft, no matter how green everything
|
|
139
|
+
// else is.
|
|
140
|
+
if (state.isDraft) reasons.push('PR is a DRAFT — the author has not marked it ready for review; never auto-merge a draft');
|
|
141
|
+
if (state.reviewDecision === 'CHANGES_REQUESTED') reasons.push('a review requests changes');
|
|
142
|
+
if (state.unresolvedThreads > 0) reasons.push(`${state.unresolvedThreads} unresolved review thread(s) — admin merge cannot bypass these`);
|
|
143
|
+
if (state.mergeable === 'CONFLICTING') reasons.push('PR is CONFLICTING with the base branch');
|
|
144
|
+
|
|
145
|
+
if (reasons.length > 0) return { action: 'stop', reasons };
|
|
146
|
+
if (pending || state.mergeable === 'UNKNOWN') return { action: 'wait', reasons: ['checks or mergeability still pending'] };
|
|
147
|
+
// Never merge on an empty gating set (reviewer finding #1): "nothing failed"
|
|
148
|
+
// must not pass for "something passed" — a fresh PR's rollup can be briefly
|
|
149
|
+
// empty (or carry only advisory fail-soft jobs) while CI spins up, and this
|
|
150
|
+
// window would otherwise admin-merge with ZERO verified checks. WAIT (not
|
|
151
|
+
// stop): the checks appear within moments, or the timeout reports it.
|
|
152
|
+
if (gatingLanded === 0) {
|
|
153
|
+
return { action: 'wait', reasons: ['no gating check has landed SUCCESS yet — refusing to merge on an empty check set'] };
|
|
154
|
+
}
|
|
155
|
+
return { action: 'merge', reasons: [] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── GitHub reads ──────────────────────────────────────────────────
|
|
159
|
+
function readPrState(prNumber) {
|
|
160
|
+
const view = JSON.parse(sh(
|
|
161
|
+
`gh pr view ${prNumber} --json state,isDraft,mergeable,reviewDecision,statusCheckRollup,headRefName,baseRefName`,
|
|
162
|
+
));
|
|
163
|
+
const checks = (view.statusCheckRollup || []).map((c) => ({
|
|
164
|
+
name: c.name || c.context || '?',
|
|
165
|
+
status: c.status || c.state || '',
|
|
166
|
+
conclusion: c.conclusion || (c.state === 'SUCCESS' ? 'SUCCESS' : c.state === 'FAILURE' || c.state === 'ERROR' ? 'FAILURE' : ''),
|
|
167
|
+
}));
|
|
168
|
+
const threads = JSON.parse(sh(
|
|
169
|
+
`gh api graphql -f query='query($n:Int!){repository(owner:"${OWNER}",name:"${NAME}"){pullRequest(number:$n){reviewThreads(first:100){nodes{isResolved path line}}}}}' -F n=${prNumber}`,
|
|
170
|
+
)).data.repository.pullRequest.reviewThreads.nodes;
|
|
171
|
+
return {
|
|
172
|
+
state: view.state,
|
|
173
|
+
isDraft: view.isDraft,
|
|
174
|
+
headRefName: view.headRefName,
|
|
175
|
+
baseRefName: view.baseRefName,
|
|
176
|
+
checks,
|
|
177
|
+
unresolvedThreads: threads.filter((t) => !t.isResolved).length,
|
|
178
|
+
unresolvedList: threads.filter((t) => !t.isResolved).map((t) => `${t.path}:${t.line}`),
|
|
179
|
+
reviewDecision: view.reviewDecision || null,
|
|
180
|
+
mergeable: view.mergeable || 'UNKNOWN',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function printEvidence(st) {
|
|
185
|
+
console.log(` checks (${st.checks.length}):`);
|
|
186
|
+
for (const c of st.checks) {
|
|
187
|
+
const soft = FAIL_SOFT_RE.test(c.name) ? ' [advisory]' : '';
|
|
188
|
+
console.log(` ${c.name}: ${c.status} ${c.conclusion}${soft}`);
|
|
189
|
+
}
|
|
190
|
+
console.log(` unresolved threads: ${st.unresolvedThreads}${st.unresolvedThreads ? ' — ' + st.unresolvedList.join(', ') : ''}`);
|
|
191
|
+
console.log(` reviewDecision: ${st.reviewDecision || '(none)'} · mergeable: ${st.mergeable}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let OWNER = 'adiahealth';
|
|
195
|
+
let NAME = 'gen-ui-kit';
|
|
196
|
+
|
|
197
|
+
// ── Main flow ─────────────────────────────────────────────────────
|
|
198
|
+
async function main() {
|
|
199
|
+
const args = parseArgs(process.argv.slice(2));
|
|
200
|
+
assertMonorepoRoot(REPO);
|
|
201
|
+
const slug = JSON.parse(sh('gh repo view --json nameWithOwner')).nameWithOwner;
|
|
202
|
+
[OWNER, NAME] = slug.split('/');
|
|
203
|
+
|
|
204
|
+
// Locate or create the PR for the branch.
|
|
205
|
+
const existing = JSON.parse(
|
|
206
|
+
shFile('gh', ['pr', 'list', '--head', args.branch, '--state', 'open', '--json', 'number,title']));
|
|
207
|
+
let prNumber;
|
|
208
|
+
if (existing.length > 0) {
|
|
209
|
+
prNumber = existing[0].number;
|
|
210
|
+
console.log(`[pr-bridge] reusing open PR #${prNumber} for ${args.branch} ("${existing[0].title}")`);
|
|
211
|
+
} else if (args.dry) {
|
|
212
|
+
console.error(`[pr-bridge] --dry: no open PR for ${args.branch}; nothing to report`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
} else {
|
|
215
|
+
if (!args.title || !args.bodyFile) {
|
|
216
|
+
console.error('error: no open PR for this branch — --title and --body-file are required to create one');
|
|
217
|
+
process.exit(2);
|
|
218
|
+
}
|
|
219
|
+
if (!fs.existsSync(args.bodyFile)) {
|
|
220
|
+
console.error(`error: body file not found: ${args.bodyFile}`);
|
|
221
|
+
process.exit(2);
|
|
222
|
+
}
|
|
223
|
+
// Push the branch (idempotent if already pushed), then create.
|
|
224
|
+
shFile('git', ['push', '-u', 'origin', args.branch]);
|
|
225
|
+
const url = shFile('gh', ['pr', 'create', '--base', 'main', '--head', args.branch, '--title', args.title, '--body-file', args.bodyFile]);
|
|
226
|
+
prNumber = Number(url.split('/').pop());
|
|
227
|
+
console.log(`[pr-bridge] opened PR #${prNumber}: ${url}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Poll until the predicate resolves.
|
|
231
|
+
const deadline = Date.now() + args.timeout * 1000;
|
|
232
|
+
for (;;) {
|
|
233
|
+
const st = readPrState(prNumber);
|
|
234
|
+
if (st.state === 'MERGED') {
|
|
235
|
+
console.log(`[pr-bridge] PR #${prNumber} is already merged`);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
if (st.state !== 'OPEN') {
|
|
239
|
+
console.error(`[pr-bridge] STOP — PR #${prNumber} is ${st.state}`);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
const d = bridgeDecision(st);
|
|
243
|
+
console.log(`\n[pr-bridge] ${new Date().toISOString()} — decision: ${d.action.toUpperCase()}`);
|
|
244
|
+
printEvidence(st);
|
|
245
|
+
|
|
246
|
+
if (args.dry) {
|
|
247
|
+
console.log(`[pr-bridge] --dry: would ${d.action}${d.reasons.length ? ` (${d.reasons.join('; ')})` : ''}`);
|
|
248
|
+
process.exit(d.action === 'stop' ? 1 : 0);
|
|
249
|
+
}
|
|
250
|
+
if (d.action === 'stop') {
|
|
251
|
+
console.error(`\n[pr-bridge] STOP — not merging PR #${prNumber}:`);
|
|
252
|
+
for (const r of d.reasons) console.error(` ✗ ${r}`);
|
|
253
|
+
console.error(' This state needs a human (or a fix pushed to the branch); pr-bridge never merges past it.');
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
if (d.action === 'merge') {
|
|
257
|
+
console.log(`\n[pr-bridge] all gates green — merging PR #${prNumber} (admin merge per house flow §5.7)`);
|
|
258
|
+
// Defense in depth: the isDraft check above should catch this, but
|
|
259
|
+
// GitHub's own merge call is the actual authority — never let its
|
|
260
|
+
// rejection crash uncaught (a caller filtering output for the
|
|
261
|
+
// "pr-bridge" prefix, this script's own documented usage, would see
|
|
262
|
+
// the run go quiet mid-merge with no clear failure, easy to misread
|
|
263
|
+
// as success). Report loudly and exit 1 instead of throwing.
|
|
264
|
+
try {
|
|
265
|
+
sh(`gh pr merge ${prNumber} --admin --merge --delete-branch`);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error(`\n[pr-bridge] STOP — gh pr merge rejected PR #${prNumber}: ${e.message || e}`);
|
|
268
|
+
console.error(' Nothing else mutated by this run; investigate and merge manually if appropriate.');
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
if (Date.now() > deadline) {
|
|
274
|
+
console.error(`\n[pr-bridge] STOP — timeout after ${args.timeout}s with checks still pending. Re-run to resume waiting.`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
await new Promise((r) => setTimeout(r, args.pollInterval * 1000));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Re-baseline main and prove the merge landed.
|
|
281
|
+
sh('git checkout main');
|
|
282
|
+
sh('git pull origin main');
|
|
283
|
+
const mergedSha = JSON.parse(sh(`gh pr view ${prNumber} --json mergeCommit`)).mergeCommit?.oid;
|
|
284
|
+
if (mergedSha) {
|
|
285
|
+
sh(`git merge-base --is-ancestor ${mergedSha} HEAD`); // throws if not
|
|
286
|
+
console.log(`[pr-bridge] main re-baselined at ${sh('git rev-parse --short HEAD')} (merge ${mergedSha.slice(0, 9)} confirmed in history)`);
|
|
287
|
+
} else {
|
|
288
|
+
// Reviewer finding #4: never skip the landing proof SILENTLY.
|
|
289
|
+
console.log(`[pr-bridge] note: GitHub has not populated mergeCommit yet — landing proof skipped (main was pulled; verify with \`git log\` if it matters)`);
|
|
290
|
+
}
|
|
291
|
+
// Local branch cleanup (remote side handled by --delete-branch).
|
|
292
|
+
try { shFile('git', ['branch', '-D', args.branch]); } catch { /* already gone */ }
|
|
293
|
+
console.log(`[pr-bridge] done — PR #${prNumber} merged, main current. Resume with --mode handoff.`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Selftest — the predicate is the load-bearing part ─────────────
|
|
297
|
+
function selftest() {
|
|
298
|
+
const ck = (name, status, conclusion) => ({ name, status, conclusion });
|
|
299
|
+
const base = { unresolvedThreads: 0, reviewDecision: 'REVIEW_REQUIRED', mergeable: 'MERGEABLE', isDraft: false };
|
|
300
|
+
const cases = [
|
|
301
|
+
['all green → merge',
|
|
302
|
+
{ ...base, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS'), ck('CodeRabbit', '', 'SUCCESS')] }, 'merge'],
|
|
303
|
+
['DRAFT PR with all-green checks → stop, never attempt gh pr merge (real incident: PR #362)',
|
|
304
|
+
{ ...base, isDraft: true, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS'), ck('CodeRabbit', '', 'SUCCESS')] }, 'stop'],
|
|
305
|
+
['fail-soft failure is advisory → merge',
|
|
306
|
+
{ ...base, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS'), ck('Audit health (fail-soft)', 'COMPLETED', 'FAILURE')] }, 'merge'],
|
|
307
|
+
['EMPTY check set → wait, never blind-merge (reviewer finding #1)',
|
|
308
|
+
{ ...base, checks: [] }, 'wait'],
|
|
309
|
+
['fail-soft-only set → wait, zero gating checks verified (reviewer finding #1)',
|
|
310
|
+
{ ...base, checks: [ck('Audit health (fail-soft)', 'COMPLETED', 'SUCCESS')] }, 'wait'],
|
|
311
|
+
['gating check failed → stop',
|
|
312
|
+
{ ...base, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'FAILURE')] }, 'stop'],
|
|
313
|
+
['gating check pending → wait',
|
|
314
|
+
{ ...base, checks: [ck('Static checks + MCP smoke', 'IN_PROGRESS', '')] }, 'wait'],
|
|
315
|
+
['status-context pending → wait',
|
|
316
|
+
{ ...base, checks: [ck('CodeRabbit', 'PENDING', '')] }, 'wait'],
|
|
317
|
+
['unresolved thread → stop even when green',
|
|
318
|
+
{ ...base, unresolvedThreads: 2, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS')] }, 'stop'],
|
|
319
|
+
['changes requested → stop even when green',
|
|
320
|
+
{ ...base, reviewDecision: 'CHANGES_REQUESTED', checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS')] }, 'stop'],
|
|
321
|
+
['conflicting → stop',
|
|
322
|
+
{ ...base, mergeable: 'CONFLICTING', checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS')] }, 'stop'],
|
|
323
|
+
['mergeable UNKNOWN with green checks → wait (never merge blind)',
|
|
324
|
+
{ ...base, mergeable: 'UNKNOWN', checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS')] }, 'wait'],
|
|
325
|
+
['SKIPPED + NEUTRAL count as pass → merge',
|
|
326
|
+
{ ...base, checks: [ck('a', 'COMPLETED', 'SKIPPED'), ck('b', 'COMPLETED', 'NEUTRAL')] }, 'merge'],
|
|
327
|
+
['CANCELLED is a failure → stop',
|
|
328
|
+
{ ...base, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'CANCELLED')] }, 'stop'],
|
|
329
|
+
['failure + pending → stop wins (report now, not after the wait)',
|
|
330
|
+
{ ...base, checks: [ck('a', 'COMPLETED', 'FAILURE'), ck('b', 'IN_PROGRESS', '')] }, 'stop'],
|
|
331
|
+
];
|
|
332
|
+
let failed = 0;
|
|
333
|
+
for (const [label, state, want] of cases) {
|
|
334
|
+
const got = bridgeDecision(state).action;
|
|
335
|
+
const ok = got === want;
|
|
336
|
+
if (!ok) failed++;
|
|
337
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}: expected ${want}, got ${got}`);
|
|
338
|
+
}
|
|
339
|
+
console.log(failed === 0 ? '\nselftest OK' : `\nselftest: ${failed} failure(s)`);
|
|
340
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const argv = process.argv.slice(2);
|
|
344
|
+
if (argv[0] === 'selftest') selftest();
|
|
345
|
+
else main().catch((e) => {
|
|
346
|
+
// Reviewer finding (PR #364): every OTHER error path (readPrState()'s gh
|
|
347
|
+
// pr view/graphql calls on every poll, git push/gh pr create, the
|
|
348
|
+
// re-baseline sequence) throws up to this top-level handler, which had NO
|
|
349
|
+
// "[pr-bridge]" prefix — invisible under the exact `grep -E "pr-bridge" |
|
|
350
|
+
// tail -N` filter this PR exists to fix. Same class, different call site.
|
|
351
|
+
console.error(`[pr-bridge] STOP — ${e.message || e}`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
});
|
|
@@ -68,7 +68,7 @@ import os from 'node:os';
|
|
|
68
68
|
import path from 'node:path';
|
|
69
69
|
import process from 'node:process';
|
|
70
70
|
import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
|
|
71
|
-
import { resolvePackageChangelog } from './package-paths.mjs';
|
|
71
|
+
import { resolvePackageChangelog, PACKAGE_NAMES, PACKAGE_ROSTER } from './package-paths.mjs';
|
|
72
72
|
import { GATE_ROSTER } from './gate-roster.mjs';
|
|
73
73
|
|
|
74
74
|
const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
@@ -80,11 +80,8 @@ const REPO = process.cwd();
|
|
|
80
80
|
const DEFAULT_DEPLOY_HOST = 'ui-kit.exe.xyz';
|
|
81
81
|
const DEFAULT_NPM_SCOPE = '@adia-ai';
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'a2ui-validator',
|
|
86
|
-
// Plugins joined the lockstep 2026-07-15 (check-lockstep.mjs POLICY note).
|
|
87
|
-
'adia-ui-factory', 'adia-ui-forge'];
|
|
83
|
+
// Single-sourced roster (H3, package-paths.mjs); order = publish order.
|
|
84
|
+
const PACKAGES = PACKAGE_NAMES;
|
|
88
85
|
|
|
89
86
|
function parseArgs(argv) {
|
|
90
87
|
const args = {
|
|
@@ -232,6 +229,21 @@ function shCapture(cmd, args) {
|
|
|
232
229
|
// ── Step 1 — Re-baseline ──────────────────────────────────────────
|
|
233
230
|
function step1ReBaseline(args) {
|
|
234
231
|
console.log('\n=== Step 1 — Re-baseline ===');
|
|
232
|
+
// cut-procedure.md §Step 1 requires `git branch --show-current` == main
|
|
233
|
+
// before a cut starts — cutting on any other branch pushes a stale main ref
|
|
234
|
+
// while tags point at the wrong tip (recovery-paths.md §Scenario 8). The doc
|
|
235
|
+
// asserted this; nothing here ever checked it (gh#358). The one legitimate
|
|
236
|
+
// exception is resuming this SAME cut after step5Commit already created and
|
|
237
|
+
// checked out release/vX.Y.Z — allow only that exact branch name back in.
|
|
238
|
+
if ((args.mode === 'cut' || args.mode === 'from-scratch') && !args.dry) {
|
|
239
|
+
const branch = shQuiet('git branch --show-current', args).trim();
|
|
240
|
+
const releaseBranch = `release/v${args.version}`;
|
|
241
|
+
if (branch !== 'main' && branch !== releaseBranch) {
|
|
242
|
+
console.error(`ERROR: on branch '${branch}' — cut modes start from 'main' (or resume on '${releaseBranch}'). Switch branches before proceeding.`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
console.log(` branch: ${branch} (ok)`);
|
|
246
|
+
}
|
|
235
247
|
console.log('git status --short:');
|
|
236
248
|
sh('git status --short', args);
|
|
237
249
|
console.log('\norigin/main..HEAD (unpushed):');
|
|
@@ -253,11 +265,19 @@ function step3PreFlight(args) {
|
|
|
253
265
|
}
|
|
254
266
|
console.log(`\n=== Step 3 — Pre-flight gates (${GATE_ROSTER.length}-gate roster — gate-roster.mjs) ===`);
|
|
255
267
|
for (const g of GATE_ROSTER) {
|
|
256
|
-
|
|
268
|
+
// versionArg gates take a MODE-DEPENDENT version (both directions bit
|
|
269
|
+
// the first 0.8.8 run): cut modes run pre-flight PRE-BUMP, where the
|
|
270
|
+
// README "Current version" claim still equals the PREVIOUS version (the
|
|
271
|
+
// bump moves it in Step 4; Step 4g then re-proves at the cut version) —
|
|
272
|
+
// while handoff runs POST-MERGE, where the claim already equals the CUT
|
|
273
|
+
// version and validating at previous fails by construction.
|
|
274
|
+
const hygieneVersion = args.mode === 'handoff' ? args.version : args.previous;
|
|
275
|
+
const cmd = g.versionArg ? `${g.cmd} --version ${hygieneVersion}` : g.cmd;
|
|
276
|
+
console.log(`\n ${g.n}/${GATE_ROSTER.length}. ${cmd} # ${g.what}`);
|
|
257
277
|
try {
|
|
258
|
-
sh(
|
|
278
|
+
sh(cmd, args, { stdio: 'inherit' });
|
|
259
279
|
} catch (e) {
|
|
260
|
-
console.error(`\n ✗ FAILED (${g.n}/${GATE_ROSTER.length}): ${
|
|
280
|
+
console.error(`\n ✗ FAILED (${g.n}/${GATE_ROSTER.length}): ${cmd}`);
|
|
261
281
|
console.error(' Pre-flight aborted. Fix the gate failure before proceeding.');
|
|
262
282
|
console.error(' See references/gates-catalog.md for failure-mode → recovery.');
|
|
263
283
|
process.exit(1);
|
|
@@ -339,6 +359,15 @@ function step4PromoteAndBump(args) {
|
|
|
339
359
|
// delete/retag rounds discovering the same gaps only after tagging.
|
|
340
360
|
console.log('\n Step 4f — pre-tag coverage (+auto-fix) with the authoritative F-N1 matcher:');
|
|
341
361
|
sh(`node scripts/release/check-release.mjs --pending-version ${args.version} --fix`, args);
|
|
362
|
+
|
|
363
|
+
// Step 4g — re-run the versionArg gates at the CUT version, post-bump: the
|
|
364
|
+
// pre-flight ran them against the previous version (the README claim can't
|
|
365
|
+
// equal the cut version before bump.mjs moves it); this run proves the bump
|
|
366
|
+
// moved every cross-file reference to the new version.
|
|
367
|
+
console.log('\n Step 4g — cut-hygiene at the cut version (post-bump proof):');
|
|
368
|
+
for (const g of GATE_ROSTER.filter((g) => g.versionArg)) {
|
|
369
|
+
sh(`${g.cmd} --version ${args.version}`, args);
|
|
370
|
+
}
|
|
342
371
|
}
|
|
343
372
|
|
|
344
373
|
// ── Step 5 — Commit ──────────────────────────────────────────────
|
|
@@ -348,21 +377,45 @@ function step5Commit(args) {
|
|
|
348
377
|
return;
|
|
349
378
|
}
|
|
350
379
|
console.log('\n=== Step 5 — Stage + Commit ===');
|
|
380
|
+
// cut-procedure.md §Step 5: "Branch FIRST, then stage" — this step never
|
|
381
|
+
// actually created release/vX.Y.Z, so the commit landed on whatever branch
|
|
382
|
+
// was checked out (main, on the v0.8.8 cut — gh#358). Idempotent: if the
|
|
383
|
+
// branch already exists (a resumed run), check it out rather than failing;
|
|
384
|
+
// step1ReBaseline's branch assertion is what stops a genuinely wrong branch
|
|
385
|
+
// from reaching this point.
|
|
386
|
+
const releaseBranch = `release/v${args.version}`;
|
|
387
|
+
const currentBranch = args.dry ? null : shQuiet('git branch --show-current', args).trim();
|
|
388
|
+
if (currentBranch !== releaseBranch) {
|
|
389
|
+
const exists = !args.dry && shQuiet(`git branch --list ${releaseBranch}`, args).trim().length > 0;
|
|
390
|
+
// Reviewer finding (PR #359): checking out an EXISTING release branch (a
|
|
391
|
+
// resumed run) on a dirty tree fails with a raw, opaque git error — Step 4
|
|
392
|
+
// (promote/bump/lockfile) runs immediately before this and dirties exactly
|
|
393
|
+
// the files a release branch differs from main in. A fresh `-b` create
|
|
394
|
+
// from clean main can't hit this, so the check only guards the resume path.
|
|
395
|
+
if (exists && !args.dry) {
|
|
396
|
+
const dirty = shQuiet('git status --porcelain', args).trim();
|
|
397
|
+
if (dirty) {
|
|
398
|
+
console.error(`ERROR: '${releaseBranch}' already exists, but the working tree is dirty — checking out would fail or silently carry these changes onto the resumed branch:\n${dirty}\n Commit, stash, or discard first, then re-run.`);
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
sh(`git checkout ${exists ? '' : '-b '}${releaseBranch}`, args);
|
|
403
|
+
}
|
|
351
404
|
sh('git reset HEAD >/dev/null 2>&1 || true', args);
|
|
352
405
|
const releaseFiles = [
|
|
353
406
|
'package-lock.json',
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
407
|
+
// Root README's "Current version" claim — bump.mjs moves it per-cut
|
|
408
|
+
// (REPO_PINNED_REFS); gate 18 (check-cut-hygiene doc-currency) exact-matches
|
|
409
|
+
// it against the cut version, so it must ride the release commit.
|
|
410
|
+
'README.md',
|
|
411
|
+
...PACKAGE_ROSTER.flatMap(({ name, dir, plugin }) => {
|
|
412
|
+
// Roster-driven (H3): each package stages its manifest + CHANGELOG;
|
|
413
|
+
// plugins also stage .claude-plugin/plugin.json (the /plugin-update
|
|
414
|
+
// cache key, moved by bump.mjs); factory additionally pins a2ui-mcp in
|
|
415
|
+
// .mcp.json (SKILL invariant 8 — bump.mjs moves the pin too).
|
|
363
416
|
const files = [`${dir}/package.json`, `${dir}/CHANGELOG.md`];
|
|
364
|
-
if (
|
|
365
|
-
if (
|
|
417
|
+
if (plugin) files.push(`${dir}/.claude-plugin/plugin.json`);
|
|
418
|
+
if (name === 'adia-ui-factory') files.push(`${dir}/.mcp.json`);
|
|
366
419
|
return files;
|
|
367
420
|
}),
|
|
368
421
|
];
|
|
@@ -498,11 +551,34 @@ async function step9Publish(args) {
|
|
|
498
551
|
// Give GH a moment to index the runs created by the per-tag push in Step 8.
|
|
499
552
|
if (!args.dry) sh('sleep 6', args);
|
|
500
553
|
sh(`node ${SCRIPT_DIR}/dispatch-publish.mjs --version ${args.version} --scope ${args.scope} --verify-triggered`, args);
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
554
|
+
// gh#354: this used to poll ONE workflow (publish-a2ui-validator.yml) as a
|
|
555
|
+
// sample-of-one proxy for all 11 publishes settling — a straggler on any
|
|
556
|
+
// OTHER package's workflow made the fail-closed check below abort on a pure
|
|
557
|
+
// timing false alarm (hit live on the v0.8.8 cut: a2ui-retrieval published
|
|
558
|
+
// successfully ~1 minute after validator did, after the old proxy had
|
|
559
|
+
// already declared "settled" and the registry read caught it mid-flight).
|
|
560
|
+
// Poll the REGISTRY itself instead — the skill's own doctrine (SKILL.md:
|
|
561
|
+
// "the registry is the verify target, never a workflow's green check")
|
|
562
|
+
// applies here as much as it does to the final check.
|
|
563
|
+
console.log(`\n Waiting for the registry to confirm all ${PACKAGES.length} packages at ${args.version} (polling every 15s, up to 10min)...`);
|
|
564
|
+
sh(
|
|
565
|
+
`for i in $(seq 1 40); do ` +
|
|
566
|
+
`fail=0; ` +
|
|
567
|
+
`for pkg in ${PACKAGES.join(' ')}; do ` +
|
|
568
|
+
`v="$(npm view "${args.scope}/$pkg" version 2>/dev/null || true)"; ` +
|
|
569
|
+
`[ "$v" = "${args.version}" ] || fail=1; ` +
|
|
570
|
+
`done; ` +
|
|
571
|
+
`latest="$(npm view ${args.scope}/web-components dist-tags.latest 2>/dev/null || true)"; ` +
|
|
572
|
+
`[ "$latest" = "${args.version}" ] || fail=1; ` +
|
|
573
|
+
`[ "$fail" = 0 ] && { echo " ✓ registry confirms all ${PACKAGES.length} + latest, after $i check(s)"; exit 0; }; ` +
|
|
574
|
+
`sleep 15; ` +
|
|
575
|
+
`done; ` +
|
|
576
|
+
`echo " registry did not converge after 10min — the fail-closed check below will report exactly which package(s) are still stale"`,
|
|
577
|
+
args,
|
|
578
|
+
);
|
|
504
579
|
// Fail-closed: the registry is the verify target (SKILL.md) — a missing or
|
|
505
|
-
// stale package aborts the cut here, before GH releases + site deploy.
|
|
580
|
+
// stale package aborts the cut here, before GH releases + site deploy. This
|
|
581
|
+
// is now a final confirmation of the poll above, not the first real read.
|
|
506
582
|
console.log(`\n Post-publish registry verification, all ${PACKAGES.length} at ${args.version} + npm latest (fail-closed):`);
|
|
507
583
|
sh(
|
|
508
584
|
`fail=0; ` +
|
|
@@ -579,9 +655,11 @@ async function main() {
|
|
|
579
655
|
console.log(`
|
|
580
656
|
✓ release commit ready — STOPPING per invariant 3 (release commits land via PR).
|
|
581
657
|
|
|
582
|
-
Next (
|
|
583
|
-
|
|
584
|
-
|
|
658
|
+
Next — mechanized bridge (pushes, opens the PR, waits out CI, merges only
|
|
659
|
+
green + zero unresolved threads, re-baselines main; stops on anything else):
|
|
660
|
+
node ${SCRIPT_DIR}/pr-bridge.mjs --branch release/v${args.version} \\
|
|
661
|
+
--title "release: v${args.version} lockstep" --body-file <path>
|
|
662
|
+
(or the manual flow: git push -u origin release/v${args.version} && gh pr create … && merge)
|
|
585
663
|
Then resume from post-merge main:
|
|
586
664
|
git checkout main && git pull origin main
|
|
587
665
|
node release-pack.mjs --mode handoff --version ${args.version} --date ${args.date} \\
|
|
@@ -632,6 +710,8 @@ function selftest() {
|
|
|
632
710
|
`${GATE_ROSTER.length}-gate roster`,
|
|
633
711
|
`✓ all ${GATE_ROSTER.length}/${GATE_ROSTER.length} gates passed`,
|
|
634
712
|
'STOPPING per invariant 3',
|
|
713
|
+
// gh#358 — the release branch must actually be created, not just documented.
|
|
714
|
+
'git checkout -b release/v9.9.9',
|
|
635
715
|
];
|
|
636
716
|
for (const marker of need) {
|
|
637
717
|
if (!out.includes(marker)) {
|
|
@@ -15,22 +15,11 @@
|
|
|
15
15
|
import { execSync } from 'node:child_process';
|
|
16
16
|
import process from 'node:process';
|
|
17
17
|
import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
|
|
18
|
+
import { PACKAGE_NAMES } from './package-paths.mjs';
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'llm',
|
|
23
|
-
'a2ui-runtime',
|
|
24
|
-
'a2ui-compose',
|
|
25
|
-
'a2ui-corpus',
|
|
26
|
-
'a2ui-mcp',
|
|
27
|
-
'a2ui-retrieval',
|
|
28
|
-
'a2ui-validator',
|
|
29
|
-
// Plugins joined the lockstep 2026-07-15 (check-lockstep.mjs POLICY note);
|
|
30
|
-
// their `adia-ui-*-v*` tags trigger publish-adia-ui-{factory,forge}.yml.
|
|
31
|
-
'adia-ui-factory',
|
|
32
|
-
'adia-ui-forge',
|
|
33
|
-
];
|
|
20
|
+
// The roster is single-sourced in package-paths.mjs (H3) — the `adia-ui-*-v*`
|
|
21
|
+
// tags trigger publish-adia-ui-{factory,forge}.yml like every other package.
|
|
22
|
+
const PER_PACKAGE_NAMES = PACKAGE_NAMES;
|
|
34
23
|
|
|
35
24
|
function parseArgs(argv) {
|
|
36
25
|
const args = { version: null, at: null, deleteMode: false, dry: false, repo: process.cwd() };
|