diff --git a/.claude/skills/contributing-to-loopover/reference.md b/.claude/skills/contributing-to-loopover/reference.md index cfc5405551..eaada3a44e 100644 --- a/.claude/skills/contributing-to-loopover/reference.md +++ b/.claude/skills/contributing-to-loopover/reference.md @@ -29,6 +29,7 @@ path filter matched; on push to `main`, everything runs. | lint → cf-typegen | worker types drift | `npm run cf-typegen:check` | committed `worker-configuration.d.ts` is stale (run `npm run cf-typegen`) | | 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 | | 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) | +| 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 | | lint → observability | Grafana/Prometheus/alert config validation | `npm run selfhost:validate-observability` | a self-host observability config (dashboard/rule/datasource) is malformed | | lint → typecheck | `tsc --noEmit` | `npm run typecheck` | any backend type error | | 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. | ui → version audit | MCP version copy | `npm run ui:version-audit` | stale MCP version strings / non-`@latest` install copy (hits npm registry) | | docs → drift | doc/code claim checker | `npm run docs:drift-check` | a doc makes a claim the mechanical lint can verify is now false | | docs → command-reference | generated CLI reference drift | `npm run command-reference:check` | committed command-reference doc is stale (run `npm run command-reference`) | +| 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` | +| 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 | +| 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`) | +| 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) | | ui → lint | `eslint .` (UI) | `npm run ui:lint` | ESLint **incl. Prettier formatting** + design-token rules | | ui → typecheck | `tsc --noEmit` (UI) | `npm run ui:typecheck` | UI type error | | ui → tests | vitest jsdom (UI) | `npm run ui:test` | failing UI component test | @@ -68,7 +73,6 @@ these for a normal PR:** |---|---| | `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`). | | `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. | -| `npm run manifest:drift-check`, `npm run engine-parity:drift-check` | Appear in `test:ci` only — not in `ci.yml` under any job. | This is a real, previously-hit gap, not a hypothetical: a past PR shipped a genuine, undetected `codecov/patch`-adjacent regression in the engine package specifically because `test --workspace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60f4f1d124..606f8293d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,8 @@ jobs: - 'wrangler.jsonc' - 'worker-configuration.d.ts' - '.loopover.yml' + - '.release-please-manifest.json' + - 'release-please-config.json' observability: - 'grafana/dashboards/**' - 'prometheus/rules/**' @@ -314,6 +316,17 @@ jobs: - name: Branding drift check 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' }} run: npm run branding-drift:check + # .release-please-manifest.json is release-please's own state file -- it normally keeps this in + # sync itself when its generated Release PR merges, but the documented human-override path for + # a stuck release tag (publish-mcp.yml's own header comment) bumps package.json directly with + # no way to touch release-please's state. That gap went uncaught with zero CI signal, then + # surfaced days later as release-please re-proposing an already-published version as a brand-new + # Release PR, which fails on publish (#7086/#7087). `.release-please-manifest.json` and + # `release-please-config.json` are now part of the `backend` path filter above so an edit to + # either alone still re-triggers this job. + - name: Release-please manifest sync check + 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' }} + run: npm run release-manifest:sync:check - name: Validate observability configs if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.observability == 'true' }} env: diff --git a/package.json b/package.json index 0637f8f320..8ee0a78d1c 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "branding-drift:update": "node scripts/check-branding-drift.mjs --update", "manifest:drift-check": "tsx scripts/check-manifest-drift.mjs", "engine-parity:drift-check": "tsx scripts/check-engine-parity.ts", + "release-manifest:sync": "node scripts/sync-release-manifest.mjs", + "release-manifest:sync:check": "node scripts/sync-release-manifest.mjs --check", "ui:deploy": "npm run ui:build && npm run ui:deploy:built", "ui:deploy:built": "wrangler deploy --config apps/loopover-ui/dist/server/wrangler.json", "ui:version:built": "wrangler versions upload --config apps/loopover-ui/dist/server/wrangler.json", @@ -96,7 +98,7 @@ "test:smoke:observability:metrics": "node scripts/smoke-observability-metrics.mjs", "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node scripts/smoke-ui-browser.mjs", - "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", + "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", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci", "test:watch": "vitest", diff --git a/scripts/sync-release-manifest.d.mts b/scripts/sync-release-manifest.d.mts new file mode 100644 index 0000000000..32a5ae9c77 --- /dev/null +++ b/scripts/sync-release-manifest.d.mts @@ -0,0 +1,28 @@ +export const MANIFEST_PATH: string; + +export type SyncManifestStaleEntry = { + workspacePath: string; + from: string; + to: string; +}; + +export type SyncManifestResult = { + content: string; + changed: boolean; + stale: SyncManifestStaleEntry[]; +}; + +export function syncManifestVersions( + manifestJson: string, + packageVersions: Record, +): SyncManifestResult; + +export type SyncManifestIo = { + readFileSync: (path: string, encoding: string) => string; + writeFileSync: (path: string, content: string) => void; + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + exit: (code: number) => void; +}; + +export function main(argv?: string[], io?: SyncManifestIo): number; diff --git a/scripts/sync-release-manifest.mjs b/scripts/sync-release-manifest.mjs new file mode 100644 index 0000000000..d129495326 --- /dev/null +++ b/scripts/sync-release-manifest.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// Keeps .release-please-manifest.json in sync with each listed package's actual package.json +// version. release-please normally maintains this file itself (manifest mode) as part of merging +// its own generated Release PR -- but the documented human-override path for a stuck/broken release +// tag (publish-mcp.yml's own header comment: "A bare manual dispatch ... self-tags HEAD from +// packages/loopover-mcp/package.json's version") bumps package.json directly and has no way to touch +// release-please's own state file. When that happens the manifest goes stale, and release-please's +// NEXT run -- blind to the fact a version already shipped out-of-band -- recomputes and re-proposes +// that SAME already-published version as a brand-new Release PR, which then fails on publish (npm +// rejects republishing a version). Confirmed live: #7086/#7087 re-proposed mcp/miner v3.1.1 days +// after both had already shipped to npm via #7064's manual release. +// +// package.json is always the source of truth here, never a hand-typed version number: this script +// only ever reads it and writes the manifest to match, so fixing drift is one command, never a +// manual JSON edit (the exact mistake that produced the stale manifest in the first place). +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const MANIFEST_PATH = ".release-please-manifest.json"; + +/** + * Pure: diff a manifest's recorded versions against each workspace's real package.json version. + * @param {string} manifestJson + * @param {Record} packageVersions workspacePath -> package.json's "version" + * @returns {{ content: string, changed: boolean, stale: Array<{ workspacePath: string, from: string, to: string }> }} + */ +export function syncManifestVersions(manifestJson, packageVersions) { + const manifest = JSON.parse(manifestJson); + const stale = []; + for (const [workspacePath, version] of Object.entries(packageVersions)) { + if (!(workspacePath in manifest)) continue; // not a manifest-tracked component -- nothing to sync + if (manifest[workspacePath] === version) continue; + stale.push({ workspacePath, from: manifest[workspacePath], to: version }); + manifest[workspacePath] = version; + } + const changed = stale.length > 0; + return { content: changed ? `${JSON.stringify(manifest, null, 2)}\n` : manifestJson, changed, stale }; +} + +export function main(argv = process.argv.slice(2), io = { + readFileSync, + writeFileSync, + log: console.log.bind(console), + error: console.error.bind(console), + exit: (code) => process.exit(code), +}) { + const check = argv.includes("--check"); + const manifestJson = io.readFileSync(MANIFEST_PATH, "utf8"); + const manifestKeys = Object.keys(JSON.parse(manifestJson)); + const packageVersions = Object.fromEntries( + manifestKeys.map((workspacePath) => [ + workspacePath, + JSON.parse(io.readFileSync(`${workspacePath}/package.json`, "utf8")).version, + ]), + ); + + const result = syncManifestVersions(manifestJson, packageVersions); + for (const { workspacePath, from, to } of result.stale) { + io.error(`${MANIFEST_PATH}: ${workspacePath} is ${from}, package.json says ${to}.`); + } + if (check) { + if (result.stale.length > 0) { + io.error(`${MANIFEST_PATH} is stale; run npm run release-manifest:sync.`); + io.exit(1); + return 1; + } + io.log(`sync-release-manifest: checked ${manifestKeys.length} package version(s), all in sync.`); + return 0; + } + if (result.changed) io.writeFileSync(MANIFEST_PATH, result.content); + io.log( + `sync-release-manifest: ${result.changed ? `synced ${result.stale.length}` : "checked"} of ${manifestKeys.length} package version(s).`, + ); + return 0; +} + +const invokedDirectly = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) main(); diff --git a/test/unit/sync-release-manifest.test.ts b/test/unit/sync-release-manifest.test.ts new file mode 100644 index 0000000000..0f5d19a388 --- /dev/null +++ b/test/unit/sync-release-manifest.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import { main, MANIFEST_PATH, syncManifestVersions } from "../../scripts/sync-release-manifest.mjs"; + +const SAMPLE_MANIFEST = JSON.stringify( + { + "packages/loopover-mcp": "3.1.0", + "packages/loopover-engine": "3.2.0", + "packages/loopover-miner": "3.1.0", + }, + null, + 2, +); + +describe("syncManifestVersions (#release-manifest-drift)", () => { + it("rewrites a stale entry to match the real package.json version", () => { + const result = syncManifestVersions(SAMPLE_MANIFEST, { "packages/loopover-mcp": "3.1.1" }); + expect(result.changed).toBe(true); + expect(result.stale).toEqual([{ workspacePath: "packages/loopover-mcp", from: "3.1.0", to: "3.1.1" }]); + expect(JSON.parse(result.content)["packages/loopover-mcp"]).toBe("3.1.1"); + // Untouched entries survive the round-trip unchanged. + expect(JSON.parse(result.content)["packages/loopover-engine"]).toBe("3.2.0"); + }); + + it("reports no drift and leaves content byte-identical when every version already matches", () => { + const result = syncManifestVersions(SAMPLE_MANIFEST, { + "packages/loopover-mcp": "3.1.0", + "packages/loopover-engine": "3.2.0", + "packages/loopover-miner": "3.1.0", + }); + expect(result.changed).toBe(false); + expect(result.stale).toEqual([]); + expect(result.content).toBe(SAMPLE_MANIFEST); + }); + + it("ignores a package.json version for a workspace the manifest doesn't track", () => { + const result = syncManifestVersions(SAMPLE_MANIFEST, { "packages/loopover-ui-kit": "9.9.9" }); + expect(result.changed).toBe(false); + expect(result.stale).toEqual([]); + expect(JSON.parse(result.content)).not.toHaveProperty("packages/loopover-ui-kit"); + }); + + it("collects every stale entry, not just the first", () => { + const result = syncManifestVersions(SAMPLE_MANIFEST, { + "packages/loopover-mcp": "3.1.1", + "packages/loopover-miner": "3.1.1", + }); + expect(result.stale.map((entry) => entry.workspacePath)).toEqual([ + "packages/loopover-mcp", + "packages/loopover-miner", + ]); + expect(JSON.parse(result.content)["packages/loopover-mcp"]).toBe("3.1.1"); + expect(JSON.parse(result.content)["packages/loopover-miner"]).toBe("3.1.1"); + }); +}); + +function fakeIo(packageVersions: Record) { + const written = new Map(); + const readFileSync = vi.fn((path: string) => { + if (path === MANIFEST_PATH) return SAMPLE_MANIFEST; + const match = /^(.+)\/package\.json$/.exec(path); + const key = match?.[1]; + if (key !== undefined && key in packageVersions) { + return JSON.stringify({ version: packageVersions[key] }); + } + throw new Error(`unexpected read: ${path}`); + }); + const writeFileSync = vi.fn((path: string, content: string) => { + written.set(path, content); + }); + const log = vi.fn(); + const error = vi.fn(); + const exit = vi.fn(); + return { readFileSync, writeFileSync, log, error, exit, written }; +} + +describe("sync-release-manifest main (#release-manifest-drift)", () => { + it("--check exits non-zero and never writes when the manifest is stale", () => { + const io = fakeIo({ + "packages/loopover-mcp": "3.1.1", + "packages/loopover-engine": "3.2.0", + "packages/loopover-miner": "3.1.1", + }); + + const code = main(["--check"], io); + + expect(code).toBe(1); + expect(io.exit).toHaveBeenCalledWith(1); + expect(io.error).toHaveBeenCalledWith( + expect.stringContaining("packages/loopover-mcp is 3.1.0, package.json says 3.1.1"), + ); + expect(io.error).toHaveBeenCalledWith(expect.stringContaining("run npm run release-manifest:sync")); + expect(io.writeFileSync).not.toHaveBeenCalled(); + }); + + it("--check exits 0 and logs a clean summary when everything already matches", () => { + const io = fakeIo({ + "packages/loopover-mcp": "3.1.0", + "packages/loopover-engine": "3.2.0", + "packages/loopover-miner": "3.1.0", + }); + + const code = main(["--check"], io); + + expect(code).toBe(0); + expect(io.exit).not.toHaveBeenCalled(); + expect(io.writeFileSync).not.toHaveBeenCalled(); + expect(io.log).toHaveBeenCalledWith( + expect.stringContaining("checked 3 package version(s), all in sync"), + ); + }); + + it("without --check, writes the synced manifest and reports how many entries changed", () => { + const io = fakeIo({ + "packages/loopover-mcp": "3.1.1", + "packages/loopover-engine": "3.2.0", + "packages/loopover-miner": "3.1.1", + }); + + const code = main([], io); + + expect(code).toBe(0); + expect(io.exit).not.toHaveBeenCalled(); + expect(io.writeFileSync).toHaveBeenCalledOnce(); + const [writtenPath, writtenContent] = io.writeFileSync.mock.calls[0]!; + expect(writtenPath).toBe(MANIFEST_PATH); + expect(JSON.parse(writtenContent as string)).toEqual({ + "packages/loopover-mcp": "3.1.1", + "packages/loopover-engine": "3.2.0", + "packages/loopover-miner": "3.1.1", + }); + expect(io.log).toHaveBeenCalledWith(expect.stringContaining("synced 2 of 3 package version(s)")); + }); + + it("without --check, never writes when nothing is stale", () => { + const io = fakeIo({ + "packages/loopover-mcp": "3.1.0", + "packages/loopover-engine": "3.2.0", + "packages/loopover-miner": "3.1.0", + }); + + const code = main([], io); + + expect(code).toBe(0); + expect(io.writeFileSync).not.toHaveBeenCalled(); + expect(io.log).toHaveBeenCalledWith(expect.stringContaining("checked of 3 package version(s)")); + }); +});