Skip to content

Commit 1b42787

Browse files
committed
fix(release): auto-derive .release-please-manifest.json instead of hand-editing it
#7100 fixed the immediate mismatch by hand-typing corrected version numbers into .release-please-manifest.json -- exactly the failure mode that caused the drift in the first place (a human-authored value instead of one derived from source of truth). This replaces that with a real mechanism: - scripts/sync-release-manifest.mjs treats each package's own package.json "version" as authoritative and syncs the manifest to match -- `npm run release-manifest:sync` is now the only supported way to fix drift, never a manual edit. - `npm run release-manifest:sync:check` (wired into both test:ci and .github/workflows/ci.yml, gated on backend/mcp/engine/miner/ui) fails CI the moment the manifest and a package.json disagree, instead of surfacing days later as release-please re-proposing an already-published version (#7086/#7087). - .release-please-manifest.json and release-please-config.json are now part of the `backend` path filter so editing either alone still triggers the check. Also corrects reference.md's CI check table, which still claimed manifest:drift-check/engine-parity:drift-check weren't wired into ci.yml -- stale since #7067 actually added them.
1 parent eaedbd3 commit 1b42787

6 files changed

Lines changed: 274 additions & 2 deletions

File tree

.claude/skills/contributing-to-loopover/reference.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ path filter matched; on push to `main`, everything runs.
2929
| lint → cf-typegen | worker types drift | `npm run cf-typegen:check` | committed `worker-configuration.d.ts` is stale (run `npm run cf-typegen`) |
3030
| lint → schema-drift | `src/db/schema.ts` vs `migrations/` | `npm run db:schema-drift:check` | a Drizzle table's schema doesn't match the migration history |
3131
| lint → selfhost-env-reference | self-host env-var doc drift | `npm run selfhost:env-reference:check` | committed `apps/loopover-ui/src/lib/selfhost-env-reference.ts` is stale (run `npm run selfhost:env-reference`) — triggers when `src/selfhost/**` (+ a few other scanned files) adds/removes an env var read; a pure line shift of an existing read does NOT trigger it, since the doc cites the file only, not `file:line` (#env-reference-churn) |
32+
| lint → miner-env-reference | miner/AMS env-var doc drift | `npm run miner:env-reference:check` | committed `packages/loopover-miner/docs/env-reference.md` / `apps/loopover-ui/src/lib/ams-env-reference.ts` is stale (run `npm run miner:env-reference`) — miner/AMS twin of the selfhost check above |
3233
| lint → observability | Grafana/Prometheus/alert config validation | `npm run selfhost:validate-observability` | a self-host observability config (dashboard/rule/datasource) is malformed |
3334
| lint → typecheck | `tsc --noEmit` | `npm run typecheck` | any backend type error |
3435
| test (1/2) | sharded vitest + coverage | `npm run test:coverage` (unsharded) | any failing `test/**/*.test.ts` (excl. `test/workers/**`) |
@@ -43,6 +44,10 @@ path filter matched; on push to `main`, everything runs.
4344
| ui → version audit | MCP version copy | `npm run ui:version-audit` | stale MCP version strings / non-`@latest` install copy (hits npm registry) |
4445
| docs → drift | doc/code claim checker | `npm run docs:drift-check` | a doc makes a claim the mechanical lint can verify is now false |
4546
| docs → command-reference | generated CLI reference drift | `npm run command-reference:check` | committed command-reference doc is stale (run `npm run command-reference`) |
47+
| manifest drift | `.loopover.yml` vs bundled fallback YAML | `npm run manifest:drift-check` | `src/config/loopover-repo-focus-manifest.ts`'s bundled YAML diverges from the real root `.loopover.yml` |
48+
| engine-parity drift | `src/{review,settings,signals}` vs `loopover-engine` twins | `npm run engine-parity:drift-check` | a hand-duplicated twin file pair diverges, or the installed `@loopover/engine` semver skews from the monorepo package |
49+
| branding drift | "gittensory" leaking into runtime source | `npm run branding-drift:check` | a file's gittensory-string hit count rises above the recorded baseline (`scripts/branding-drift-baseline.json`) |
50+
| release-please manifest sync | `.release-please-manifest.json` vs each package's real `package.json` version | `npm run release-manifest:sync:check` | the manifest and a package's `package.json` version disagree — normally only possible after a manual out-of-band release (release-please's own PR-merge flow keeps them in sync automatically); fix with `npm run release-manifest:sync` (never hand-edit the manifest) |
4651
| ui → lint | `eslint .` (UI) | `npm run ui:lint` | ESLint **incl. Prettier formatting** + design-token rules |
4752
| ui → typecheck | `tsc --noEmit` (UI) | `npm run ui:typecheck` | UI type error |
4853
| ui → tests | vitest jsdom (UI) | `npm run ui:test` | failing UI component test |
@@ -68,7 +73,6 @@ these for a normal PR:**
6873
|---|---|
6974
| `npm run test:engine-parity`, `npm run test:live-gate-parity`, `npm run test:driver-parity` | Plain `test/contract/*.test.ts` files — no dedicated CI job, but they DO run in CI as part of whichever `test (1/2)` shard happens to contain them (sharded `vitest run`). |
7075
| `npm run test --workspace @loopover/engine` | The engine package's own `node --test` suite. **Not run by `ci.yml` on a PR at all** — only by `.github/workflows/publish-engine.yml` at release time. A regression here is invisible to Codecov and to every PR-gating CI check; `test:ci` locally is the only pre-merge signal. |
71-
| `npm run manifest:drift-check`, `npm run engine-parity:drift-check` | Appear in `test:ci` only — not in `ci.yml` under any job. |
7276

7377
This is a real, previously-hit gap, not a hypothetical: a past PR shipped a genuine, undetected
7478
`codecov/patch`-adjacent regression in the engine package specifically because `test --workspace

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ jobs:
6767
- 'wrangler.jsonc'
6868
- 'worker-configuration.d.ts'
6969
- '.loopover.yml'
70+
- '.release-please-manifest.json'
71+
- 'release-please-config.json'
7072
observability:
7173
- 'grafana/dashboards/**'
7274
- 'prometheus/rules/**'
@@ -314,6 +316,17 @@ jobs:
314316
- name: Branding drift check
315317
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' }}
316318
run: npm run branding-drift:check
319+
# .release-please-manifest.json is release-please's own state file -- it normally keeps this in
320+
# sync itself when its generated Release PR merges, but the documented human-override path for
321+
# a stuck release tag (publish-mcp.yml's own header comment) bumps package.json directly with
322+
# no way to touch release-please's state. That gap went uncaught with zero CI signal, then
323+
# surfaced days later as release-please re-proposing an already-published version as a brand-new
324+
# Release PR, which fails on publish (#7086/#7087). `.release-please-manifest.json` and
325+
# `release-please-config.json` are now part of the `backend` path filter above so an edit to
326+
# either alone still re-triggers this job.
327+
- name: Release-please manifest sync check
328+
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' }}
329+
run: npm run release-manifest:sync:check
317330
- name: Validate observability configs
318331
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.observability == 'true' }}
319332
env:

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@
6969
"branding-drift:update": "node scripts/check-branding-drift.mjs --update",
7070
"manifest:drift-check": "tsx scripts/check-manifest-drift.mjs",
7171
"engine-parity:drift-check": "tsx scripts/check-engine-parity.ts",
72+
"release-manifest:sync": "node scripts/sync-release-manifest.mjs",
73+
"release-manifest:sync:check": "node scripts/sync-release-manifest.mjs --check",
7274
"ui:deploy": "npm run ui:build && npm run ui:deploy:built",
7375
"ui:deploy:built": "wrangler deploy --config apps/loopover-ui/dist/server/wrangler.json",
7476
"ui:version:built": "wrangler versions upload --config apps/loopover-ui/dist/server/wrangler.json",
@@ -96,7 +98,7 @@
9698
"test:smoke:observability:metrics": "node scripts/smoke-observability-metrics.mjs",
9799
"test:smoke:browser:install": "playwright install chromium",
98100
"test:smoke:browser": "node scripts/smoke-ui-browser.mjs",
99-
"test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run test:miner-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
101+
"test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run test:miner-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
100102
"test:release": "npm run test:ci && npm run changelog:check",
101103
"test:release:mcp": "npm run test:ci",
102104
"test:watch": "vitest",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export const MANIFEST_PATH: string;
2+
3+
export type SyncManifestStaleEntry = {
4+
workspacePath: string;
5+
from: string;
6+
to: string;
7+
};
8+
9+
export type SyncManifestResult = {
10+
content: string;
11+
changed: boolean;
12+
stale: SyncManifestStaleEntry[];
13+
};
14+
15+
export function syncManifestVersions(
16+
manifestJson: string,
17+
packageVersions: Record<string, string>,
18+
): SyncManifestResult;
19+
20+
export type SyncManifestIo = {
21+
readFileSync: (path: string, encoding: string) => string;
22+
writeFileSync: (path: string, content: string) => void;
23+
log: (...args: unknown[]) => void;
24+
error: (...args: unknown[]) => void;
25+
exit: (code: number) => void;
26+
};
27+
28+
export function main(argv?: string[], io?: SyncManifestIo): number;

scripts/sync-release-manifest.mjs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env node
2+
// Keeps .release-please-manifest.json in sync with each listed package's actual package.json
3+
// version. release-please normally maintains this file itself (manifest mode) as part of merging
4+
// its own generated Release PR -- but the documented human-override path for a stuck/broken release
5+
// tag (publish-mcp.yml's own header comment: "A bare manual dispatch ... self-tags HEAD from
6+
// packages/loopover-mcp/package.json's version") bumps package.json directly and has no way to touch
7+
// release-please's own state file. When that happens the manifest goes stale, and release-please's
8+
// NEXT run -- blind to the fact a version already shipped out-of-band -- recomputes and re-proposes
9+
// that SAME already-published version as a brand-new Release PR, which then fails on publish (npm
10+
// rejects republishing a version). Confirmed live: #7086/#7087 re-proposed mcp/miner v3.1.1 days
11+
// after both had already shipped to npm via #7064's manual release.
12+
//
13+
// package.json is always the source of truth here, never a hand-typed version number: this script
14+
// only ever reads it and writes the manifest to match, so fixing drift is one command, never a
15+
// manual JSON edit (the exact mistake that produced the stale manifest in the first place).
16+
import { readFileSync, writeFileSync } from "node:fs";
17+
import { pathToFileURL } from "node:url";
18+
19+
export const MANIFEST_PATH = ".release-please-manifest.json";
20+
21+
/**
22+
* Pure: diff a manifest's recorded versions against each workspace's real package.json version.
23+
* @param {string} manifestJson
24+
* @param {Record<string, string>} packageVersions workspacePath -> package.json's "version"
25+
* @returns {{ content: string, changed: boolean, stale: Array<{ workspacePath: string, from: string, to: string }> }}
26+
*/
27+
export function syncManifestVersions(manifestJson, packageVersions) {
28+
const manifest = JSON.parse(manifestJson);
29+
const stale = [];
30+
for (const [workspacePath, version] of Object.entries(packageVersions)) {
31+
if (!(workspacePath in manifest)) continue; // not a manifest-tracked component -- nothing to sync
32+
if (manifest[workspacePath] === version) continue;
33+
stale.push({ workspacePath, from: manifest[workspacePath], to: version });
34+
manifest[workspacePath] = version;
35+
}
36+
const changed = stale.length > 0;
37+
return { content: changed ? `${JSON.stringify(manifest, null, 2)}\n` : manifestJson, changed, stale };
38+
}
39+
40+
export function main(argv = process.argv.slice(2), io = {
41+
readFileSync,
42+
writeFileSync,
43+
log: console.log.bind(console),
44+
error: console.error.bind(console),
45+
exit: (code) => process.exit(code),
46+
}) {
47+
const check = argv.includes("--check");
48+
const manifestJson = io.readFileSync(MANIFEST_PATH, "utf8");
49+
const manifestKeys = Object.keys(JSON.parse(manifestJson));
50+
const packageVersions = Object.fromEntries(
51+
manifestKeys.map((workspacePath) => [
52+
workspacePath,
53+
JSON.parse(io.readFileSync(`${workspacePath}/package.json`, "utf8")).version,
54+
]),
55+
);
56+
57+
const result = syncManifestVersions(manifestJson, packageVersions);
58+
for (const { workspacePath, from, to } of result.stale) {
59+
io.error(`${MANIFEST_PATH}: ${workspacePath} is ${from}, package.json says ${to}.`);
60+
}
61+
if (check) {
62+
if (result.stale.length > 0) {
63+
io.error(`${MANIFEST_PATH} is stale; run npm run release-manifest:sync.`);
64+
io.exit(1);
65+
return 1;
66+
}
67+
io.log(`sync-release-manifest: checked ${manifestKeys.length} package version(s), all in sync.`);
68+
return 0;
69+
}
70+
if (result.changed) io.writeFileSync(MANIFEST_PATH, result.content);
71+
io.log(
72+
`sync-release-manifest: ${result.changed ? `synced ${result.stale.length}` : "checked"} of ${manifestKeys.length} package version(s).`,
73+
);
74+
return 0;
75+
}
76+
77+
const invokedDirectly = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href;
78+
if (invokedDirectly) main();
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { main, MANIFEST_PATH, syncManifestVersions } from "../../scripts/sync-release-manifest.mjs";
3+
4+
const SAMPLE_MANIFEST = JSON.stringify(
5+
{
6+
"packages/loopover-mcp": "3.1.0",
7+
"packages/loopover-engine": "3.2.0",
8+
"packages/loopover-miner": "3.1.0",
9+
},
10+
null,
11+
2,
12+
);
13+
14+
describe("syncManifestVersions (#release-manifest-drift)", () => {
15+
it("rewrites a stale entry to match the real package.json version", () => {
16+
const result = syncManifestVersions(SAMPLE_MANIFEST, { "packages/loopover-mcp": "3.1.1" });
17+
expect(result.changed).toBe(true);
18+
expect(result.stale).toEqual([{ workspacePath: "packages/loopover-mcp", from: "3.1.0", to: "3.1.1" }]);
19+
expect(JSON.parse(result.content)["packages/loopover-mcp"]).toBe("3.1.1");
20+
// Untouched entries survive the round-trip unchanged.
21+
expect(JSON.parse(result.content)["packages/loopover-engine"]).toBe("3.2.0");
22+
});
23+
24+
it("reports no drift and leaves content byte-identical when every version already matches", () => {
25+
const result = syncManifestVersions(SAMPLE_MANIFEST, {
26+
"packages/loopover-mcp": "3.1.0",
27+
"packages/loopover-engine": "3.2.0",
28+
"packages/loopover-miner": "3.1.0",
29+
});
30+
expect(result.changed).toBe(false);
31+
expect(result.stale).toEqual([]);
32+
expect(result.content).toBe(SAMPLE_MANIFEST);
33+
});
34+
35+
it("ignores a package.json version for a workspace the manifest doesn't track", () => {
36+
const result = syncManifestVersions(SAMPLE_MANIFEST, { "packages/loopover-ui-kit": "9.9.9" });
37+
expect(result.changed).toBe(false);
38+
expect(result.stale).toEqual([]);
39+
expect(JSON.parse(result.content)).not.toHaveProperty("packages/loopover-ui-kit");
40+
});
41+
42+
it("collects every stale entry, not just the first", () => {
43+
const result = syncManifestVersions(SAMPLE_MANIFEST, {
44+
"packages/loopover-mcp": "3.1.1",
45+
"packages/loopover-miner": "3.1.1",
46+
});
47+
expect(result.stale.map((entry) => entry.workspacePath)).toEqual([
48+
"packages/loopover-mcp",
49+
"packages/loopover-miner",
50+
]);
51+
expect(JSON.parse(result.content)["packages/loopover-mcp"]).toBe("3.1.1");
52+
expect(JSON.parse(result.content)["packages/loopover-miner"]).toBe("3.1.1");
53+
});
54+
});
55+
56+
function fakeIo(packageVersions: Record<string, string>) {
57+
const written = new Map<string, string>();
58+
const readFileSync = vi.fn((path: string) => {
59+
if (path === MANIFEST_PATH) return SAMPLE_MANIFEST;
60+
const match = /^(.+)\/package\.json$/.exec(path);
61+
const key = match?.[1];
62+
if (key !== undefined && key in packageVersions) {
63+
return JSON.stringify({ version: packageVersions[key] });
64+
}
65+
throw new Error(`unexpected read: ${path}`);
66+
});
67+
const writeFileSync = vi.fn((path: string, content: string) => {
68+
written.set(path, content);
69+
});
70+
const log = vi.fn();
71+
const error = vi.fn();
72+
const exit = vi.fn();
73+
return { readFileSync, writeFileSync, log, error, exit, written };
74+
}
75+
76+
describe("sync-release-manifest main (#release-manifest-drift)", () => {
77+
it("--check exits non-zero and never writes when the manifest is stale", () => {
78+
const io = fakeIo({
79+
"packages/loopover-mcp": "3.1.1",
80+
"packages/loopover-engine": "3.2.0",
81+
"packages/loopover-miner": "3.1.1",
82+
});
83+
84+
const code = main(["--check"], io);
85+
86+
expect(code).toBe(1);
87+
expect(io.exit).toHaveBeenCalledWith(1);
88+
expect(io.error).toHaveBeenCalledWith(
89+
expect.stringContaining("packages/loopover-mcp is 3.1.0, package.json says 3.1.1"),
90+
);
91+
expect(io.error).toHaveBeenCalledWith(expect.stringContaining("run npm run release-manifest:sync"));
92+
expect(io.writeFileSync).not.toHaveBeenCalled();
93+
});
94+
95+
it("--check exits 0 and logs a clean summary when everything already matches", () => {
96+
const io = fakeIo({
97+
"packages/loopover-mcp": "3.1.0",
98+
"packages/loopover-engine": "3.2.0",
99+
"packages/loopover-miner": "3.1.0",
100+
});
101+
102+
const code = main(["--check"], io);
103+
104+
expect(code).toBe(0);
105+
expect(io.exit).not.toHaveBeenCalled();
106+
expect(io.writeFileSync).not.toHaveBeenCalled();
107+
expect(io.log).toHaveBeenCalledWith(
108+
expect.stringContaining("checked 3 package version(s), all in sync"),
109+
);
110+
});
111+
112+
it("without --check, writes the synced manifest and reports how many entries changed", () => {
113+
const io = fakeIo({
114+
"packages/loopover-mcp": "3.1.1",
115+
"packages/loopover-engine": "3.2.0",
116+
"packages/loopover-miner": "3.1.1",
117+
});
118+
119+
const code = main([], io);
120+
121+
expect(code).toBe(0);
122+
expect(io.exit).not.toHaveBeenCalled();
123+
expect(io.writeFileSync).toHaveBeenCalledOnce();
124+
const [writtenPath, writtenContent] = io.writeFileSync.mock.calls[0]!;
125+
expect(writtenPath).toBe(MANIFEST_PATH);
126+
expect(JSON.parse(writtenContent as string)).toEqual({
127+
"packages/loopover-mcp": "3.1.1",
128+
"packages/loopover-engine": "3.2.0",
129+
"packages/loopover-miner": "3.1.1",
130+
});
131+
expect(io.log).toHaveBeenCalledWith(expect.stringContaining("synced 2 of 3 package version(s)"));
132+
});
133+
134+
it("without --check, never writes when nothing is stale", () => {
135+
const io = fakeIo({
136+
"packages/loopover-mcp": "3.1.0",
137+
"packages/loopover-engine": "3.2.0",
138+
"packages/loopover-miner": "3.1.0",
139+
});
140+
141+
const code = main([], io);
142+
143+
expect(code).toBe(0);
144+
expect(io.writeFileSync).not.toHaveBeenCalled();
145+
expect(io.log).toHaveBeenCalledWith(expect.stringContaining("checked of 3 package version(s)"));
146+
});
147+
});

0 commit comments

Comments
 (0)