diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3dd654b1..084348d66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -474,6 +474,12 @@ jobs: - name: Build engine package if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' }} run: npx turbo run build --filter=@loopover/engine + # Mirrors "MCP package check"/"Miner package check" below: the published npm tarball is a + # different surface than the workspace build above (files field, forbidden paths/content, + # stale README wording) and needs its own dry-run validation (#8591). + - name: Engine package check + if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' }} + run: npm run test:engine-pack # .tsbuildinfo mutates every run (tsc's own incremental state), unlike node_modules above which is # immutable per lockfile -- so this needs the run_id-suffixed-key + restore-keys-prefix pattern (always # creates a new cache entry to save into, restore falls back to the most recent matching prefix) rather @@ -733,6 +739,11 @@ jobs: - name: Build UI-kit package if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }} run: npx turbo run build --filter=@loopover/ui-kit + # Mirrors "MCP package check"/"Miner package check" above: the published npm tarball is a + # different surface than the workspace build above and needs its own dry-run validation (#8591). + - name: UI-kit package check + if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }} + run: npm run test:ui-kit-pack # apps/loopover-ui's fumadocs-mdx virtual modules (collections/browser, collections/server, imported by # docs-client-loader.tsx/docs-source.ts) are gitignored generated output, normally produced by npm ci's # own `postinstall` hook -- but "Install dependencies" above is SKIPPED on a node_modules cache hit (the @@ -943,6 +954,14 @@ jobs: exit 1 fi done + # This job runs the general test/unit/*.test.ts suite unconditionally whenever it runs at all + # (unscoped on push, and SCOPED_TEST_SELECTION below only excludes mcp-cli-*/miner-* prefixed + # files, never this one) -- so test/unit/check-ui-kit-package.test.ts's own real-package + # regression guard needs packages/loopover-ui-kit/dist/ to exist here too, the same gap "Build + # engine package"/"Build MCP"/"Build miner CLI" above already close for their own pack-checks + # (#8592 confirmed live: this job has no ui-kit build at all, unlike validate-code). + - name: Build UI-kit package + run: npx turbo run build --filter=@loopover/ui-kit - name: Save Turborepo cache if: ${{ !cancelled() }} uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.github/workflows/package-release-watch.yml b/.github/workflows/package-release-watch.yml new file mode 100644 index 000000000..71ac10b2d --- /dev/null +++ b/.github/workflows/package-release-watch.yml @@ -0,0 +1,81 @@ +name: Package Release Watch + +# Same "release due" tracking-issue safety net mcp-release-watch.yml already gives MCP, extended to +# engine/miner/ui-kit (#8591): if release-please ever silently stops proposing a release PR for one of +# these, nothing else would flag it. Deliberately ONE job iterating the three packages sequentially +# (not a matrix) -- the shared Actions runner pool is org-wide, and #8574's investigation found extra +# concurrent runners per scheduled run a direct contributor to queue saturation; this trades a few +# extra seconds of wall-clock for zero additional concurrent runner load. + +on: + workflow_dispatch: + schedule: + - cron: "35 16 * * *" + +permissions: + contents: read + issues: write + +concurrency: + group: package-release-watch-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-watch: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup workspace + uses: ./.github/actions/setup-workspace + + # Each package's check runs independently of the others' outcome (`!cancelled()`, not the + # default "skip on any earlier failure") -- one package's transient failure (e.g. a flaky + # `npm view` registry read) must never hide whether the other two are due for a release. + - name: Check engine release status + if: ${{ !cancelled() }} + env: + GITHUB_TOKEN: ${{ github.token }} + run: npx tsx scripts/check-package-release-due.ts --package engine --json --output engine-release-due.json --upsert-issue + + - name: Check miner release status + if: ${{ !cancelled() }} + env: + GITHUB_TOKEN: ${{ github.token }} + run: npx tsx scripts/check-package-release-due.ts --package miner --json --output miner-release-due.json --upsert-issue + + - name: Check ui-kit release status + if: ${{ !cancelled() }} + env: + GITHUB_TOKEN: ${{ github.token }} + run: npx tsx scripts/check-package-release-due.ts --package ui-kit --json --output ui-kit-release-due.json --upsert-issue + + - name: Summarize package release status + if: ${{ !cancelled() }} + run: | + node <<'NODE' + const fs = require("node:fs"); + const lines = ["## Package Release Watch"]; + for (const [label, file] of [["Engine", "engine-release-due.json"], ["Miner", "miner-release-due.json"], ["ui-kit", "ui-kit-release-due.json"]]) { + if (!fs.existsSync(file)) { + lines.push("", `### ${label}`, "- Check did not complete (see the step above)."); + continue; + } + const report = JSON.parse(fs.readFileSync(file, "utf8")); + lines.push( + "", + `### ${label}`, + `- Due: \`${report.due}\``, + `- Proposed version: \`${report.proposedVersion}\``, + `- Latest tag: \`${report.latestTag ?? "none"}\``, + `- npm latest: \`${report.publishedVersion ?? "unknown"}\``, + `- Related commits: \`${report.commits.length}\``, + ); + } + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`); + NODE diff --git a/package.json b/package.json index 945386fc2..f3e478784 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "build:miner": "turbo run build --filter=@loopover/engine && npm --workspace @loopover/miner run build", "test:mcp-pack": "tsx scripts/check-mcp-package.ts", "test:miner-pack": "tsx scripts/check-miner-package.ts", + "test:engine-pack": "tsx scripts/check-engine-package.ts", + "test:ui-kit-pack": "tsx scripts/check-ui-kit-package.ts", "test:miner-deployment-docs-audit": "tsx scripts/check-miner-deployment-docs.ts", "rees:install": "npm ci --prefix review-enrichment --prefer-offline --no-audit --no-fund", "rees:test": "npm run rees:install && npm --prefix review-enrichment test", @@ -112,7 +114,7 @@ "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts", "pretest:ci": "npm run check-node-version", - "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && 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 build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && 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 test:mcp-pack && 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 coverage-boltons:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc: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:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && 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 build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && 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 test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-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 coverage-boltons:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc: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/packages/loopover-ui-kit/README.md b/packages/loopover-ui-kit/README.md new file mode 100644 index 000000000..967d8813f --- /dev/null +++ b/packages/loopover-ui-kit/README.md @@ -0,0 +1,58 @@ +# @loopover/ui-kit + +Shared design-system tokens and component primitives for `apps/loopover-ui` and `apps/loopover-miner-ui`. + +This package houses the Radix-based component library both apps build on, so the same design tokens, +component behavior, and accessibility conventions stay identical across the two frontends instead of +drifting apart. It is versioned independently and published to npm as `@loopover/ui-kit`. + +## Install + +``` +npm install @loopover/ui-kit +``` + +`react` and `react-dom` (`^19.2.7`) are peer dependencies — the consuming app supplies its own copy. + +## Usage + +Each component is its own subpath export, so a consumer only pulls in the components it actually +imports rather than the whole library: + +```ts +import { Button } from "@loopover/ui-kit/components/button"; +import { useIsMobile } from "@loopover/ui-kit/hooks/use-mobile"; +import { cn } from "@loopover/ui-kit/utils"; +import "@loopover/ui-kit/theme.css"; +``` + +`theme.css` ships as source (not compiled) so a consumer's own Tailwind/PostCSS pipeline processes it +alongside the rest of the app's styles. + +## Components + +`accordion`, `alert`, `alert-dialog`, `aspect-ratio`, `avatar`, `badge`, `breadcrumb`, `button`, +`calendar`, `card`, `carousel`, `chart`, `checkbox`, `collapsible`, `command`, `context-menu`, `dialog`, +`drawer`, `dropdown-menu`, `form`, `hover-card`, `input`, `input-otp`, `label`, `menubar`, +`navigation-menu`, `pagination`, `popover`, `progress`, `radio-group`, `resizable`, `scroll-area`, +`select`, `separator`, `sheet`, `sidebar`, `skeleton`, `slider`, `sonner`, `state-views`, `switch`, +`table`, `tabs`, `textarea`, `toggle`, `toggle-group`, `tooltip`. + +Plus one hook (`hooks/use-mobile`) and shared utilities (`utils`, notably `cn` for Tailwind class merging). + +## Build + +``` +npm run build --workspace @loopover/ui-kit +``` + +Runs `tsc -p tsconfig.json`, emitting `dist/` (the only published output alongside `CHANGELOG.md` and +`src/theme.css`). + +## Test + +``` +npm test --workspace @loopover/ui-kit +``` + +Runs the package's own `vitest` suite (jsdom) against the component source. diff --git a/packages/loopover-ui-kit/package.json b/packages/loopover-ui-kit/package.json index f9ff11bb5..5cf3b9c5c 100644 --- a/packages/loopover-ui-kit/package.json +++ b/packages/loopover-ui-kit/package.json @@ -39,7 +39,8 @@ }, "files": [ "dist", - "src/theme.css" + "src/theme.css", + "CHANGELOG.md" ], "scripts": { "build": "tsc -p tsconfig.json", diff --git a/scripts/check-engine-package.ts b/scripts/check-engine-package.ts new file mode 100644 index 000000000..45ce0e7e5 --- /dev/null +++ b/scripts/check-engine-package.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { FORBIDDEN_CONTENT } from "./forbidden-content.js"; + +// Unlike MCP/miner's small, hand-curated lib/*.js lists, engine's dist/ mirrors its src/ tree 1:1 +// (tsc's default output shape) and grows every time a follow-up issue extracts more logic from the +// app into this package (see the package's own README) -- an enumerated per-file allowlist would need +// editing on every such PR and would silently rot. Pattern-match the dist/ shape itself instead; the +// forbidden-path/forbidden-content/stale-text checks below still catch anything that doesn't belong. +const ALLOWED = [/^dist\/.*\.(js|d\.ts)$/, /^package\.json$/, /^README\.md$/, /^CHANGELOG\.md$/, /^LICENSE$/]; +const REQUIRED = ["package.json", "README.md", "CHANGELOG.md", "dist/index.js", "dist/index.d.ts"]; +const FORBIDDEN_PATH = /(^|\/)(\.dev\.vars|\.env|\.npmrc|.*\.pem|.*private.*key.*|.*secret.*)$/i; +const STALE_PACKAGE_TEXT = /(private beta|zeronode\.workers\.dev|preview URL)/i; + +type PackedFile = string | { path: string }; +type ReadContentFn = (file: string) => string; + +export function validateEnginePackFileList(files: readonly PackedFile[], readContent: ReadContentFn): string[] { + const paths = files.map((file) => (typeof file === "string" ? file : file.path)).sort(); + for (const file of paths) { + if (FORBIDDEN_PATH.test(file)) throw new Error(`Forbidden file in engine package: ${file}`); + if (!ALLOWED.some((pattern) => pattern.test(file))) throw new Error(`Unexpected file in engine package: ${file}`); + const content = readContent(file); + if (FORBIDDEN_CONTENT.test(content)) throw new Error(`Secret-like content found in engine package file: ${file}`); + if (file === "README.md" && STALE_PACKAGE_TEXT.test(content)) throw new Error(`Stale public-package wording found in engine package file: ${file}`); + } + for (const required of REQUIRED) { + if (!paths.includes(required)) throw new Error(`Engine package is missing required file: ${required}`); + } + return paths; +} + +export function runEnginePackCheck(options: { pack?: { files: PackedFile[] }; packageRoot?: string; readContent?: ReadContentFn } = {}): string { + const pack = options.pack ?? loadEnginePackFromNpm(); + const packageRoot = options.packageRoot ?? join(process.cwd(), "packages/loopover-engine"); + const readContent: ReadContentFn = + options.readContent ?? + ((file) => { + if (process.env.CHECK_ENGINE_PACK_TEST_CONTENT !== undefined) return process.env.CHECK_ENGINE_PACK_TEST_CONTENT; + return readFileSync(join(packageRoot, file), "utf8"); + }); + const paths = validateEnginePackFileList(pack.files, readContent); + return `Engine package dry-run ok: ${paths.length} files.\n`; +} + +function loadEnginePackFromNpm(): { files: PackedFile[] } { + if (process.env.CHECK_ENGINE_PACK_TEST_FILES) { + const paths: string[] = JSON.parse(process.env.CHECK_ENGINE_PACK_TEST_FILES); + return { files: paths.map((path) => ({ path })) }; + } + const result = spawnSync("npm", ["pack", "--workspace", "@loopover/engine", "--dry-run", "--json"], { + encoding: "utf8", + maxBuffer: 1024 * 1024 * 16, + }); + if (result.status !== 0) { + const message = result.stderr || result.stdout || "npm pack failed"; + throw new Error(message.trim()); + } + return JSON.parse(result.stdout)[0]; +} + +function main() { + try { + process.stdout.write(runEnginePackCheck()); + } catch (err) { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/check-package-release-due.ts b/scripts/check-package-release-due.ts new file mode 100644 index 000000000..97c3d52fe --- /dev/null +++ b/scripts/check-package-release-due.ts @@ -0,0 +1,231 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { buildPackageReleaseIssue, buildPackageReleaseReport, PACKAGE_RELEASE_CONFIGS, type PackageReleaseCommit, type PackageReleaseConfig, type PackageReleaseReport } from "./package-release-core.js"; +import { latestSemverTagWithPrefix } from "./release-semver-utils.js"; + +// Per-request timeout so a hung api.github.com connection can't block the unattended watch job +// indefinitely, matching check-mcp-release-due.ts's identical guard. +const GITHUB_REQUEST_TIMEOUT_MS = 30_000; + +type ParsedArgs = { + json: boolean; + output: string | null; + upsertIssue: boolean; + package: keyof typeof PACKAGE_RELEASE_CONFIGS | null; +}; + +export type GitHubIssueCandidate = { + pull_request?: unknown; + title?: unknown; + body?: unknown; + user?: { + login?: unknown; + } | null; +}; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.package) throw new Error("--package is required"); + const config = PACKAGE_RELEASE_CONFIGS[args.package]; + + const latestTag = latestSemverTagWithPrefix( + git(["tag", "--list", `${config.tagPrefix}*`, "--sort=-v:refname"]) + .split("\n") + .filter(Boolean), + config.tagPrefix, + ); + const packageVersion = JSON.parse(readFileSync(config.packageJsonPath, "utf8")).version; + const publishedVersion = readPublishedPackageVersion(config); + const commits = latestTag ? readCommits(`${latestTag.tag}..HEAD`) : readCommits("HEAD"); + const report = buildPackageReleaseReport({ config, latestTag, packageVersion, publishedVersion, commits }); + + if (args.output) writeFileSync(args.output, `${JSON.stringify(report, null, 2)}\n`); + if (args.upsertIssue) { + if (report.due) { + await upsertIssue(report, config); + } else { + await closeResolvedIssueIfPresent(report, config); + } + } + if (args.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + if (!args.json && !args.output) { + process.stdout.write(report.due ? `${config.displayName} release due: ${report.proposedVersion}\n` : `No ${config.displayName} release due.\n`); + } +} + +function parseArgs(argv: readonly string[]): ParsedArgs { + const args: ParsedArgs = { json: false, output: null, upsertIssue: false, package: null }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--json") { + args.json = true; + } else if (arg === "--output") { + args.output = argv[++index] ?? null; + } else if (arg === "--upsert-issue") { + args.upsertIssue = true; + } else if (arg === "--package") { + const value = argv[++index]; + if (value !== "engine" && value !== "miner" && value !== "ui-kit") throw new Error(`Unknown --package: ${value}`); + args.package = value; + } else { + throw new Error(`Unknown option: ${arg}`); + } + } + return args; +} + +function readPublishedPackageVersion(config: PackageReleaseConfig): string | null { + const result = spawnSync("npm", ["view", config.npmPackage, "version", "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + if (result.status !== 0) return null; + try { + const parsed = JSON.parse(result.stdout); + return typeof parsed === "string" ? parsed : null; + } catch { + return result.stdout.trim() || null; + } +} + +function readCommits(revisionRange: string): PackageReleaseCommit[] { + const format = "%x1e%H%x1f%s%x1f%B"; + const logOutput = git(["log", "--reverse", "--no-merges", `--format=${format}`, revisionRange]); + return logOutput + .split("\x1e") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [sha, subject, ...bodyParts] = entry.split("\x1f"); + return { + sha: sha!, + subject: subject?.split("\n")[0] ?? "", + body: bodyParts.join("\x1f"), + files: readCommitFiles(sha!), + }; + }); +} + +function readCommitFiles(sha: string): string[] { + return git(["diff-tree", "--no-commit-id", "--name-only", "-r", sha]) + .split("\n") + .filter(Boolean); +} + +function git(args: readonly string[]): string { + return execFileSync("git", args, { encoding: "utf8", maxBuffer: 1024 * 1024 * 200 }); +} + +async function upsertIssue(report: PackageReleaseReport, config: PackageReleaseConfig) { + const { owner, repo, token } = resolveRepoAndToken(); + const issue = buildPackageReleaseIssue(report, config); + const existingIssue = await findExistingIssue({ owner, repo, token, config }); + if (existingIssue) { + await githubRequest({ + token, + method: "PATCH", + path: `/repos/${owner}/${repo}/issues/${existingIssue.number}`, + body: { title: issue.title, body: issue.body }, + }); + process.stdout.write(`Updated issue #${existingIssue.number}: ${issue.title}\n`); + return; + } + + const created = await githubRequest({ + token, + method: "POST", + path: `/repos/${owner}/${repo}/issues`, + body: issue, + }); + process.stdout.write(`Opened issue #${created.number}: ${issue.title}\n`); +} + +// Mirrors check-mcp-release-due.ts's closeResolvedIssueIfPresent (built to fix #6145: a stale +// "release due" issue stayed open after the actual release had already shipped). +export async function closeResolvedIssueIfPresent(report: PackageReleaseReport, config: PackageReleaseConfig) { + const { owner, repo, token } = resolveRepoAndToken(); + const existingIssue = await findExistingIssue({ owner, repo, token, config }); + if (!existingIssue) return; + + const body = `${config.displayName} is caught up: latest tag \`${report.latestTag ?? "none"}\` matches the package version \`${report.packageVersion}\`, and npm's published version is \`${report.publishedVersion ?? "unknown"}\`, with no unreleased ${config.displayName}-related commits. Closing -- release-please's own Release PR reopens this signal automatically if a new release becomes due.`; + await githubRequest({ + token, + method: "POST", + path: `/repos/${owner}/${repo}/issues/${existingIssue.number}/comments`, + body: { body }, + }); + await githubRequest({ + token, + method: "PATCH", + path: `/repos/${owner}/${repo}/issues/${existingIssue.number}`, + body: { state: "closed", state_reason: "completed" }, + }); + process.stdout.write(`Closed issue #${existingIssue.number}: release caught up.\n`); +} + +function resolveRepoAndToken(): { owner: string; repo: string; token: string } { + const repository = process.env.GITHUB_REPOSITORY; + const token = process.env.GITHUB_TOKEN; + if (!repository) throw new Error("GITHUB_REPOSITORY is required for --upsert-issue"); + if (!token) throw new Error("GITHUB_TOKEN is required for --upsert-issue"); + const [owner, repo] = repository.split("/"); + if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`); + return { owner, repo, token }; +} + +async function findExistingIssue({ + owner, + repo, + token, + config, +}: { + owner: string; + repo: string; + token: string; + config: PackageReleaseConfig; +}): Promise<(GitHubIssueCandidate & { number: number }) | null> { + let page = 1; + while (page <= 10) { + const issues = await githubRequest({ + token, + method: "GET", + path: `/repos/${owner}/${repo}/issues?state=open&per_page=100&page=${page}`, + }); + if (!Array.isArray(issues) || issues.length === 0) return null; + const match = issues.find((issue) => isReleaseWatchIssue(issue, config)); + if (match) return match; + page += 1; + } + return null; +} + +export function isReleaseWatchIssue(issue: GitHubIssueCandidate, config: PackageReleaseConfig): boolean { + return !issue.pull_request && issue.user?.login === "github-actions[bot]" && typeof issue.body === "string" && issue.body.includes(config.marker); +} + +async function githubRequest({ token, method, path, body }: { token: string; method: string; path: string; body?: unknown }): Promise { + const response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "user-agent": "loopover-package-release-watch", + "x-github-api-version": "2022-11-28", + }, + signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + if (!response.ok) { + const message = payload?.message ?? response.statusText; + throw new Error(`GitHub API ${method} ${path} failed: ${response.status} ${message}`); + } + return payload; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); +} diff --git a/scripts/check-ui-kit-package.ts b/scripts/check-ui-kit-package.ts new file mode 100644 index 000000000..d309aaaca --- /dev/null +++ b/scripts/check-ui-kit-package.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { FORBIDDEN_CONTENT } from "./forbidden-content.js"; + +// Same reasoning as check-engine-package.ts: dist/ mirrors src/components + src/hooks 1:1 (tsc's default +// output shape, plus .d.ts.map declaration sourcemaps), and grows with every new component -- pattern-match +// the shape instead of enumerating each component file. +const ALLOWED = [/^dist\/.*\.(js|d\.ts|d\.ts\.map)$/, /^src\/theme\.css$/, /^package\.json$/, /^README\.md$/, /^CHANGELOG\.md$/, /^LICENSE$/]; +const REQUIRED = ["package.json", "README.md", "CHANGELOG.md", "src/theme.css", "dist/utils.js", "dist/utils.d.ts"]; +const FORBIDDEN_PATH = /(^|\/)(\.dev\.vars|\.env|\.npmrc|.*\.pem|.*private.*key.*|.*secret.*)$/i; +const STALE_PACKAGE_TEXT = /(private beta|zeronode\.workers\.dev|preview URL)/i; + +type PackedFile = string | { path: string }; +type ReadContentFn = (file: string) => string; + +export function validateUiKitPackFileList(files: readonly PackedFile[], readContent: ReadContentFn): string[] { + const paths = files.map((file) => (typeof file === "string" ? file : file.path)).sort(); + for (const file of paths) { + if (FORBIDDEN_PATH.test(file)) throw new Error(`Forbidden file in ui-kit package: ${file}`); + if (!ALLOWED.some((pattern) => pattern.test(file))) throw new Error(`Unexpected file in ui-kit package: ${file}`); + const content = readContent(file); + if (FORBIDDEN_CONTENT.test(content)) throw new Error(`Secret-like content found in ui-kit package file: ${file}`); + if (file === "README.md" && STALE_PACKAGE_TEXT.test(content)) throw new Error(`Stale public-package wording found in ui-kit package file: ${file}`); + } + for (const required of REQUIRED) { + if (!paths.includes(required)) throw new Error(`ui-kit package is missing required file: ${required}`); + } + if (!paths.some((file) => /^dist\/components\/[a-z0-9-]+\.js$/.test(file))) { + throw new Error("ui-kit package is missing dist/components/*.js artifacts"); + } + return paths; +} + +export function runUiKitPackCheck(options: { pack?: { files: PackedFile[] }; packageRoot?: string; readContent?: ReadContentFn } = {}): string { + const pack = options.pack ?? loadUiKitPackFromNpm(); + const packageRoot = options.packageRoot ?? join(process.cwd(), "packages/loopover-ui-kit"); + const readContent: ReadContentFn = + options.readContent ?? + ((file) => { + if (process.env.CHECK_UI_KIT_PACK_TEST_CONTENT !== undefined) return process.env.CHECK_UI_KIT_PACK_TEST_CONTENT; + return readFileSync(join(packageRoot, file), "utf8"); + }); + const paths = validateUiKitPackFileList(pack.files, readContent); + return `ui-kit package dry-run ok: ${paths.length} files.\n`; +} + +function loadUiKitPackFromNpm(): { files: PackedFile[] } { + if (process.env.CHECK_UI_KIT_PACK_TEST_FILES) { + const paths: string[] = JSON.parse(process.env.CHECK_UI_KIT_PACK_TEST_FILES); + return { files: paths.map((path) => ({ path })) }; + } + const result = spawnSync("npm", ["pack", "--workspace", "@loopover/ui-kit", "--dry-run", "--json"], { + encoding: "utf8", + maxBuffer: 1024 * 1024 * 16, + }); + if (result.status !== 0) { + const message = result.stderr || result.stdout || "npm pack failed"; + throw new Error(message.trim()); + } + return JSON.parse(result.stdout)[0]; +} + +function main() { + try { + process.stdout.write(runUiKitPackCheck()); + } catch (err) { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/mcp-release-core.ts b/scripts/mcp-release-core.ts index 0c8c221ff..ef266b74c 100644 --- a/scripts/mcp-release-core.ts +++ b/scripts/mcp-release-core.ts @@ -1,3 +1,16 @@ +import { + bumpVersion, + compareSemver, + escapeIssueMarkdownText, + inferReleaseTypeFromSubjects, + latestSemverTagWithPrefix, + parseConventionalSubject, + shortSha, + type ParsedConventionalSubject, +} from "./release-semver-utils.js"; + +export { parseConventionalSubject, compareSemver, bumpVersion }; + export const MCP_RELEASE_DUE_MARKER = ""; const DIRECT_MCP_PATHS = [ @@ -48,82 +61,8 @@ export type McpReleaseReport = { changedFiles: string[]; }; -type ParsedConventionalSubject = { - type: string | null; - scope: string | null; - breaking: boolean; - description: string; - conventional: boolean; -}; - -export function parseConventionalSubject(subject: string): ParsedConventionalSubject { - const trimmed = subject.trim(); - const match = /^(?[a-z]+)(?:\((?[^)]+)\))?(?!)?:\s*(?.+)$/.exec(trimmed); - if (match?.groups) { - return { - type: match.groups.type!, - scope: match.groups.scope ?? null, - breaking: Boolean(match.groups.breaking), - description: match.groups.description!.trim(), - conventional: true, - }; - } - - if (/^fix\b/i.test(trimmed)) { - return { - type: "fix", - scope: null, - breaking: false, - description: trimmed.replace(/^fix[:\s-]*/i, "").trim() || trimmed, - conventional: false, - }; - } - - return { - type: null, - scope: null, - breaking: false, - description: trimmed, - conventional: false, - }; -} - -type ParsedSemver = { - major: number; - minor: number; - patch: number; - prerelease: string | null; -}; - -export function compareSemver(leftVersion: string, rightVersion: string): number | null { - const left = parseSemver(leftVersion); - const right = parseSemver(rightVersion); - if (!left || !right) return null; - for (const part of ["major", "minor", "patch"] as const) { - if (left[part] !== right[part]) return left[part] < right[part] ? -1 : 1; - } - if (left.prerelease === right.prerelease) return 0; - if (!left.prerelease) return 1; - if (!right.prerelease) return -1; - const prereleaseComparison = left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true, sensitivity: "base" }); - return prereleaseComparison === 0 ? 0 : prereleaseComparison < 0 ? -1 : 1; -} - -export function bumpVersion(version: string, releaseType: "major" | "minor" | "patch"): string { - const parsed = parseSemver(version); - if (!parsed) throw new Error(`Invalid semver version: ${version}`); - if (releaseType === "major") return `${parsed.major + 1}.0.0`; - if (releaseType === "minor") return `${parsed.major}.${parsed.minor + 1}.0`; - return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; -} - export function latestSemverTag(tags: readonly string[]): { tag: string; version: string } | null { - return ( - tags - .map((tag) => ({ tag, version: tag.replace(/^mcp-v/, "") })) - .filter(({ version }) => parseSemver(version)) - .sort((left, right) => compareSemver(right.version, left.version) ?? 0)[0] ?? null - ); + return latestSemverTagWithPrefix(tags, "mcp-v"); } export function selectMcpReleaseCommits(commits: readonly T[]): T[] { @@ -213,7 +152,7 @@ export function buildMcpReleaseReport({ commits: McpReleaseCommit[]; }): McpReleaseReport { const includedCommits = selectMcpReleaseCommits(commits); - const releaseType = inferReleaseType(includedCommits); + const releaseType = inferReleaseTypeFromSubjects(includedCommits); const latestVersion = latestTag?.version ?? "0.0.0"; const inferredVersion = releaseType ? bumpVersion(latestVersion, releaseType) : latestVersion; const proposedVersion = packageVersion && compareSemver(packageVersion, inferredVersion) === 1 ? packageVersion : inferredVersion; @@ -283,28 +222,10 @@ ${changedFiles} return { title, body }; } -function escapeIssueMarkdownText(value: string | undefined): string { - return String(value ?? "") - .replace(/[\x00-\x1f\x7f]/g, " ") - .replace(/@/g, "@\u200b") - .replace(/([\\`*_{}[\]()#+.!|>-])/g, "\\$1"); -} - export function normalizeNewlines(value: string): string { return value.replace(/\r\n/g, "\n"); } -function parseSemver(version: string | null | undefined): ParsedSemver | null { - const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(version ?? "").trim()); - if (!match) return null; - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - prerelease: match[4] ?? null, - }; -} - function isClientVisibleChange(parsed: ParsedConventionalSubject, subject: string): boolean { if (parsed.breaking) return true; if (parsed.type === "feat" || parsed.type === "fix" || parsed.type === "refactor") return true; @@ -365,25 +286,10 @@ function formatCommitForChangelog(commit: McpReleaseCommit): string { return upperFirst(description); } -function inferReleaseType(commits: readonly McpReleaseCommit[]): "major" | "minor" | "patch" | null { - if (commits.length === 0) return null; - let type: "major" | "minor" | "patch" = "patch"; - for (const commit of commits) { - const parsed = parseConventionalSubject(commit.subject ?? ""); - if (parsed.breaking || /BREAKING CHANGE:/i.test(commit.body ?? "")) return "major"; - if (parsed.type === "feat") type = "minor"; - } - return type; -} - function uniqueSorted(values: readonly string[]): string[] { return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right)); } -function shortSha(sha: string | undefined): string { - return String(sha ?? "").slice(0, 7); -} - function upperFirst(value: string): string { const trimmed = value.trim(); return trimmed ? `${trimmed[0]!.toUpperCase()}${trimmed.slice(1)}` : trimmed; diff --git a/scripts/package-release-core.ts b/scripts/package-release-core.ts new file mode 100644 index 000000000..cccc58ba0 --- /dev/null +++ b/scripts/package-release-core.ts @@ -0,0 +1,186 @@ +// Generic "release due" watcher core for engine/miner/ui-kit (#8591), the simpler siblings of MCP's +// bespoke mcp-release-core.ts. Unlike MCP, none of these three packages have commits that need +// attributing from OUTSIDE their own packages/loopover-/ directory (no cross-cutting src/** +// dependency the way MCP wraps app-side Worker logic), and all three already have release-please +// maintaining their CHANGELOG.md natively (release-please-config.json's "packages" list) -- so this +// core only needs due-detection + issue-tracking, no bespoke changelog renderer. +import { bumpVersion, compareSemver, escapeIssueMarkdownText, inferReleaseTypeFromSubjects, parseConventionalSubject, shortSha } from "./release-semver-utils.js"; + +export type PackageReleaseCommit = { + sha?: string; + subject?: string; + body?: string; + files?: string[]; +}; + +export type PackageReleaseReport = { + due: boolean; + proposedVersion: string; + latestTag: string | null; + latestTagVersion: string | null; + packageVersion: string; + publishedVersion: string | null; + releaseType: "major" | "minor" | "patch" | null; + commits: PackageReleaseCommit[]; + changedFiles: string[]; +}; + +/** Static identity for one of the three simple packages -- the package name, its npm name, tag prefix, + * the path its commits must touch to count, and the marker/checklist text for its tracking issue. */ +export type PackageReleaseConfig = { + /** Human-readable name used in issue titles/bodies, e.g. "engine". */ + displayName: string; + /** The workspace package name, e.g. "@loopover/engine". */ + npmPackage: string; + /** The package.json to read the current committed version from. */ + packageJsonPath: string; + /** The path prefix a commit's files must fall under to count toward this package's release, e.g. "packages/loopover-engine/". */ + packagePathPrefix: string; + /** Git tag prefix, e.g. "engine-v". */ + tagPrefix: string; + /** The npm test script that dry-run-validates the published tarball, e.g. "test:engine-pack". */ + packCheckScript: string; + /** HTML comment marker embedded in the tracking issue body, unique per package so findExistingIssue never cross-matches. */ + marker: string; +}; + +function markerFor(displayName: string): string { + return ``; +} + +export const PACKAGE_RELEASE_CONFIGS: Record<"engine" | "miner" | "ui-kit", PackageReleaseConfig> = { + engine: { + displayName: "engine", + npmPackage: "@loopover/engine", + packageJsonPath: "packages/loopover-engine/package.json", + packagePathPrefix: "packages/loopover-engine/", + tagPrefix: "engine-v", + packCheckScript: "test:engine-pack", + marker: markerFor("engine"), + }, + miner: { + displayName: "miner", + npmPackage: "@loopover/miner", + packageJsonPath: "packages/loopover-miner/package.json", + packagePathPrefix: "packages/loopover-miner/", + tagPrefix: "miner-v", + packCheckScript: "test:miner-pack", + marker: markerFor("miner"), + }, + "ui-kit": { + displayName: "ui-kit", + npmPackage: "@loopover/ui-kit", + packageJsonPath: "packages/loopover-ui-kit/package.json", + packagePathPrefix: "packages/loopover-ui-kit/", + tagPrefix: "ui-kit-v", + packCheckScript: "test:ui-kit-pack", + marker: markerFor("ui-kit"), + }, +}; + +/** A commit counts toward this package's release if it's non-empty, not a merge commit, and touches + * at least one file under the package's own directory -- no cross-cutting-path/scope-exclusion + * machinery, unlike MCP's isMcpReleaseRelevantCommit (see this file's header for why). */ +export function isPackageReleaseRelevantCommit(commit: PackageReleaseCommit, config: PackageReleaseConfig): boolean { + const subject = commit.subject ?? ""; + if (!subject.trim()) return false; + if (/^merge\b/i.test(subject)) return false; + const files = commit.files ?? []; + if (!files.some((file) => file.startsWith(config.packagePathPrefix))) return false; + const parsed = parseConventionalSubject(subject); + if (!parsed.type && !parsed.conventional) return false; + if (parsed.scope === "release" || parsed.scope === "changelog") return false; + return true; +} + +export function selectPackageReleaseCommits(commits: readonly T[], config: PackageReleaseConfig): T[] { + return commits.filter((commit) => isPackageReleaseRelevantCommit(commit, config)); +} + +export function buildPackageReleaseReport({ + config, + latestTag, + packageVersion, + publishedVersion, + commits, +}: { + config: PackageReleaseConfig; + latestTag: { tag: string; version: string } | null; + packageVersion: string; + publishedVersion: string | null; + commits: PackageReleaseCommit[]; +}): PackageReleaseReport { + const includedCommits = selectPackageReleaseCommits(commits, config); + const releaseType = inferReleaseTypeFromSubjects(includedCommits); + const latestVersion = latestTag?.version ?? "0.0.0"; + const inferredVersion = releaseType ? bumpVersion(latestVersion, releaseType) : latestVersion; + const proposedVersion = packageVersion && compareSemver(packageVersion, inferredVersion) === 1 ? packageVersion : inferredVersion; + const tagMatchesPackage = latestTag?.version === packageVersion; + const npmMatchesPackage = publishedVersion === packageVersion; + const due = includedCommits.length > 0 || !tagMatchesPackage || !npmMatchesPackage; + + return { + due, + proposedVersion, + latestTag: latestTag?.tag ?? null, + latestTagVersion: latestTag?.version ?? null, + packageVersion, + publishedVersion, + releaseType, + commits: includedCommits, + changedFiles: uniqueSorted(includedCommits.flatMap((commit) => commit.files ?? [])), + }; +} + +export function buildPackageReleaseIssue(report: PackageReleaseReport, config: PackageReleaseConfig): { title: string; body: string } { + const title = `${capitalize(config.displayName)} release due: ${report.proposedVersion}`; + const npmVersion = report.publishedVersion ?? "unknown"; + const latestTag = report.latestTag ?? "none"; + const commits = + report.commits.length > 0 + ? report.commits.map((commit) => `- \`${shortSha(commit.sha)}\` ${escapeIssueMarkdownText(commit.subject)}`).join("\n") + : `- No unreleased ${config.displayName}-related commits detected.`; + const changedFiles = report.changedFiles.length > 0 ? report.changedFiles.map((file) => `- \`${file}\``).join("\n") : "- No changed files detected."; + const tag = `${config.tagPrefix}${report.proposedVersion}`; + + const body = `${config.marker} + +## Summary + +A ${config.npmPackage} release appears due. + +- Proposed version: \`${report.proposedVersion}\` +- Latest tag: \`${latestTag}\` +- npm latest: \`${npmVersion}\` +- Package version in repo: \`${report.packageVersion}\` +- Unreleased ${config.displayName}-related commits: \`${report.commits.length}\` + +## Unreleased Commits + +${commits} + +## Changed Files + +${changedFiles} + +## Release-Prep Checklist + +- [ ] Bump \`${config.packageJsonPath}\` to \`${report.proposedVersion}\` +- [ ] Run \`npm run build --workspace ${config.npmPackage}\` +- [ ] Run \`npm run ${config.packCheckScript}\` +- [ ] Run \`npm run actionlint\` +- [ ] Merge the release-please PR (it maintains this package's CHANGELOG.md natively) +- [ ] Tag \`${tag}\` +- [ ] Watch npm trusted publishing and the GitHub Release job +`; + + return { title, body }; +} + +function capitalize(value: string): string { + return value ? `${value[0]!.toUpperCase()}${value.slice(1)}` : value; +} + +function uniqueSorted(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} diff --git a/scripts/release-semver-utils.ts b/scripts/release-semver-utils.ts new file mode 100644 index 000000000..95dabf001 --- /dev/null +++ b/scripts/release-semver-utils.ts @@ -0,0 +1,116 @@ +// Pure semver/conventional-commit primitives shared by mcp-release-core.ts (MCP's bespoke +// cross-cutting-path release watcher) and package-release-core.ts (the generic engine/miner/ui-kit +// watcher, #8591). Extracted rather than duplicated so a bug fix in, say, prerelease comparison only +// needs to happen once. + +export type ParsedConventionalSubject = { + type: string | null; + scope: string | null; + breaking: boolean; + description: string; + conventional: boolean; +}; + +export function parseConventionalSubject(subject: string): ParsedConventionalSubject { + const trimmed = subject.trim(); + const match = /^(?[a-z]+)(?:\((?[^)]+)\))?(?!)?:\s*(?.+)$/.exec(trimmed); + if (match?.groups) { + return { + type: match.groups.type!, + scope: match.groups.scope ?? null, + breaking: Boolean(match.groups.breaking), + description: match.groups.description!.trim(), + conventional: true, + }; + } + + if (/^fix\b/i.test(trimmed)) { + return { + type: "fix", + scope: null, + breaking: false, + description: trimmed.replace(/^fix[:\s-]*/i, "").trim() || trimmed, + conventional: false, + }; + } + + return { + type: null, + scope: null, + breaking: false, + description: trimmed, + conventional: false, + }; +} + +export type ParsedSemver = { + major: number; + minor: number; + patch: number; + prerelease: string | null; +}; + +export function parseSemver(version: string | null | undefined): ParsedSemver | null { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(version ?? "").trim()); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null, + }; +} + +export function compareSemver(leftVersion: string, rightVersion: string): number | null { + const left = parseSemver(leftVersion); + const right = parseSemver(rightVersion); + if (!left || !right) return null; + for (const part of ["major", "minor", "patch"] as const) { + if (left[part] !== right[part]) return left[part] < right[part] ? -1 : 1; + } + if (left.prerelease === right.prerelease) return 0; + if (!left.prerelease) return 1; + if (!right.prerelease) return -1; + const prereleaseComparison = left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true, sensitivity: "base" }); + return prereleaseComparison === 0 ? 0 : prereleaseComparison < 0 ? -1 : 1; +} + +export function bumpVersion(version: string, releaseType: "major" | "minor" | "patch"): string { + const parsed = parseSemver(version); + if (!parsed) throw new Error(`Invalid semver version: ${version}`); + if (releaseType === "major") return `${parsed.major + 1}.0.0`; + if (releaseType === "minor") return `${parsed.major}.${parsed.minor + 1}.0`; + return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; +} + +export function latestSemverTagWithPrefix(tags: readonly string[], prefix: string): { tag: string; version: string } | null { + return ( + tags + .filter((tag) => tag.startsWith(prefix)) + .map((tag) => ({ tag, version: tag.slice(prefix.length) })) + .filter(({ version }) => parseSemver(version)) + .sort((left, right) => compareSemver(right.version, left.version) ?? 0)[0] ?? null + ); +} + +export function inferReleaseTypeFromSubjects(subjects: readonly { subject?: string; body?: string }[]): "major" | "minor" | "patch" | null { + if (subjects.length === 0) return null; + let type: "major" | "minor" | "patch" = "patch"; + for (const commit of subjects) { + const parsed = parseConventionalSubject(commit.subject ?? ""); + if (parsed.breaking || /BREAKING CHANGE:/i.test(commit.body ?? "")) return "major"; + if (parsed.type === "feat") type = "minor"; + } + return type; +} + +export function shortSha(sha: string | undefined): string { + return String(sha ?? "").slice(0, 7); +} + +export function escapeIssueMarkdownText(value: string | undefined): string { + return String(value ?? "") + .replace(/[\x00-\x1f\x7f]/g, " ") + .replace(/@/g, "@\u200b") + .replace(/([\\`*_{}[\]()#+.!|>-])/g, "\\$1"); +} diff --git a/test/unit/check-engine-package.test.ts b/test/unit/check-engine-package.test.ts new file mode 100644 index 000000000..0e1dfb6e6 --- /dev/null +++ b/test/unit/check-engine-package.test.ts @@ -0,0 +1,86 @@ +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +// tsx, not plain node: check-engine-package.ts imports forbidden-content.ts directly, so plain node +// can't resolve that local .ts import. +const TSX_BIN = join(process.cwd(), "node_modules", ".bin", "tsx"); + +function runChecker(env: Record = {}): { status: number; out: string } { + try { + const stdout = execFileSync(TSX_BIN, ["scripts/check-engine-package.ts"], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return { status: 0, out: stdout }; + } catch (err) { + const e = err as { status?: number; stdout?: string; stderr?: string }; + return { status: e.status ?? 1, out: `${e.stdout ?? ""}${e.stderr ?? ""}` }; + } +} + +// A minimal, valid engine tarball: the required entry point plus package metadata. +const MINIMAL_PACKAGE = ["package.json", "README.md", "CHANGELOG.md", "LICENSE", "dist/index.js", "dist/index.d.ts"]; + +describe("check-engine-package script", () => { + it("passes on the real engine workspace package (regression guard: run `npm run build --workspace @loopover/engine` first if this fails)", () => { + const result = runChecker(); + expect(result.status).toBe(0); + expect(result.out).toMatch(/^Engine package dry-run ok: \d+ files\.\n$/); + }); + + it("accepts a minimal allowlisted package with nested dist/ modules", () => { + const result = runChecker({ + CHECK_ENGINE_PACK_TEST_FILES: JSON.stringify([...MINIMAL_PACKAGE, "dist/signals/predicted-gate-engine.js", "dist/signals/predicted-gate-engine.d.ts"]), + CHECK_ENGINE_PACK_TEST_CONTENT: "public docs, nothing secret", + }); + expect(result.status).toBe(0); + expect(result.out).toMatch(/^Engine package dry-run ok: 8 files\.\n$/); + }); + + it("rejects a forbidden path", () => { + const result = runChecker({ CHECK_ENGINE_PACK_TEST_FILES: JSON.stringify([".env"]) }); + expect(result.status).toBe(1); + expect(result.out).toContain("Forbidden file in engine package: .env"); + }); + + it("rejects an unexpected file outside dist/", () => { + const result = runChecker({ CHECK_ENGINE_PACK_TEST_FILES: JSON.stringify(["scripts/extra.mjs"]) }); + expect(result.status).toBe(1); + expect(result.out).toContain("Unexpected file in engine package: scripts/extra.mjs"); + }); + + it("rejects a non-js/d.ts file inside dist/", () => { + const result = runChecker({ CHECK_ENGINE_PACK_TEST_FILES: JSON.stringify(["dist/index.js", "dist/index.d.ts", "dist/notes.txt"]) }); + expect(result.status).toBe(1); + expect(result.out).toContain("Unexpected file in engine package: dist/notes.txt"); + }); + + it("rejects a package missing the dist/index entry point", () => { + const result = runChecker({ + CHECK_ENGINE_PACK_TEST_FILES: JSON.stringify(["package.json", "README.md", "CHANGELOG.md", "dist/other.js", "dist/other.d.ts"]), + CHECK_ENGINE_PACK_TEST_CONTENT: "public docs, nothing secret", + }); + expect(result.status).toBe(1); + expect(result.out).toContain("Engine package is missing required file: dist/index.js"); + }); + + it("rejects secret-like content", () => { + const probe = ["PROBE", "_", "SECRET", "=", "value"].join(""); + const result = runChecker({ + CHECK_ENGINE_PACK_TEST_FILES: JSON.stringify(["package.json", "dist/index.js"]), + CHECK_ENGINE_PACK_TEST_CONTENT: probe, + }); + expect(result.status).toBe(1); + expect(result.out).toContain("Secret-like content found in engine package file:"); + }); + + it("rejects a README carrying stale public-package wording (#7013)", () => { + const result = runChecker({ + CHECK_ENGINE_PACK_TEST_FILES: JSON.stringify(MINIMAL_PACKAGE), + CHECK_ENGINE_PACK_TEST_CONTENT: "Join the private beta today!", + }); + expect(result.status).toBe(1); + expect(result.out).toContain("Stale public-package wording found in engine package file: README.md"); + }); +}); diff --git a/test/unit/check-ui-kit-package.test.ts b/test/unit/check-ui-kit-package.test.ts new file mode 100644 index 000000000..28477de6e --- /dev/null +++ b/test/unit/check-ui-kit-package.test.ts @@ -0,0 +1,95 @@ +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +// tsx, not plain node: check-ui-kit-package.ts imports forbidden-content.ts directly, so plain node +// can't resolve that local .ts import. +const TSX_BIN = join(process.cwd(), "node_modules", ".bin", "tsx"); + +function runChecker(env: Record = {}): { status: number; out: string } { + try { + const stdout = execFileSync(TSX_BIN, ["scripts/check-ui-kit-package.ts"], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return { status: 0, out: stdout }; + } catch (err) { + const e = err as { status?: number; stdout?: string; stderr?: string }; + return { status: e.status ?? 1, out: `${e.stdout ?? ""}${e.stderr ?? ""}` }; + } +} + +// A minimal, valid ui-kit tarball: the required entry points, one component, and package metadata. +const MINIMAL_PACKAGE = ["package.json", "README.md", "CHANGELOG.md", "LICENSE", "src/theme.css", "dist/utils.js", "dist/utils.d.ts", "dist/components/button.js", "dist/components/button.d.ts"]; + +describe("check-ui-kit-package script", () => { + it("passes on the real ui-kit workspace package (regression guard: run `npm run build --workspace @loopover/ui-kit` first if this fails)", () => { + const result = runChecker(); + expect(result.status).toBe(0); + expect(result.out).toMatch(/^ui-kit package dry-run ok: \d+ files\.\n$/); + }); + + it("accepts a minimal allowlisted package with nested dist/ components and declaration sourcemaps", () => { + const result = runChecker({ + CHECK_UI_KIT_PACK_TEST_FILES: JSON.stringify([...MINIMAL_PACKAGE, "dist/components/button.d.ts.map", "dist/hooks/use-mobile.js", "dist/hooks/use-mobile.d.ts"]), + CHECK_UI_KIT_PACK_TEST_CONTENT: "public docs, nothing secret", + }); + expect(result.status).toBe(0); + expect(result.out).toMatch(/^ui-kit package dry-run ok: 12 files\.\n$/); + }); + + it("rejects a forbidden path", () => { + const result = runChecker({ CHECK_UI_KIT_PACK_TEST_FILES: JSON.stringify([".env"]) }); + expect(result.status).toBe(1); + expect(result.out).toContain("Forbidden file in ui-kit package: .env"); + }); + + it("rejects an unexpected file outside dist/", () => { + const result = runChecker({ CHECK_UI_KIT_PACK_TEST_FILES: JSON.stringify(["scripts/extra.mjs"]) }); + expect(result.status).toBe(1); + expect(result.out).toContain("Unexpected file in ui-kit package: scripts/extra.mjs"); + }); + + it("rejects a non-js/d.ts/d.ts.map file inside dist/", () => { + const result = runChecker({ CHECK_UI_KIT_PACK_TEST_FILES: JSON.stringify(["dist/utils.js", "dist/utils.d.ts", "dist/notes.txt"]) }); + expect(result.status).toBe(1); + expect(result.out).toContain("Unexpected file in ui-kit package: dist/notes.txt"); + }); + + it("rejects a package missing the dist/utils entry point", () => { + const result = runChecker({ + CHECK_UI_KIT_PACK_TEST_FILES: JSON.stringify(["package.json", "README.md", "CHANGELOG.md", "src/theme.css", "dist/components/button.js", "dist/components/button.d.ts"]), + CHECK_UI_KIT_PACK_TEST_CONTENT: "public docs, nothing secret", + }); + expect(result.status).toBe(1); + expect(result.out).toContain("ui-kit package is missing required file: dist/utils.js"); + }); + + it("rejects a package with no dist/components/*.js artifacts at all", () => { + const result = runChecker({ + CHECK_UI_KIT_PACK_TEST_FILES: JSON.stringify(["package.json", "README.md", "CHANGELOG.md", "src/theme.css", "dist/utils.js", "dist/utils.d.ts"]), + CHECK_UI_KIT_PACK_TEST_CONTENT: "public docs, nothing secret", + }); + expect(result.status).toBe(1); + expect(result.out).toContain("ui-kit package is missing dist/components/*.js artifacts"); + }); + + it("rejects secret-like content", () => { + const probe = ["PROBE", "_", "SECRET", "=", "value"].join(""); + const result = runChecker({ + CHECK_UI_KIT_PACK_TEST_FILES: JSON.stringify(["package.json", "dist/utils.js"]), + CHECK_UI_KIT_PACK_TEST_CONTENT: probe, + }); + expect(result.status).toBe(1); + expect(result.out).toContain("Secret-like content found in ui-kit package file:"); + }); + + it("rejects a README carrying stale public-package wording (#7013)", () => { + const result = runChecker({ + CHECK_UI_KIT_PACK_TEST_FILES: JSON.stringify(MINIMAL_PACKAGE), + CHECK_UI_KIT_PACK_TEST_CONTENT: "Join the private beta today!", + }); + expect(result.status).toBe(1); + expect(result.out).toContain("Stale public-package wording found in ui-kit package file: README.md"); + }); +}); diff --git a/test/unit/package-release.test.ts b/test/unit/package-release.test.ts new file mode 100644 index 000000000..fde151f84 --- /dev/null +++ b/test/unit/package-release.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { closeResolvedIssueIfPresent, isReleaseWatchIssue } from "../../scripts/check-package-release-due.js"; +import { buildPackageReleaseIssue, buildPackageReleaseReport, isPackageReleaseRelevantCommit, PACKAGE_RELEASE_CONFIGS, selectPackageReleaseCommits } from "../../scripts/package-release-core.js"; + +const engineConfig = PACKAGE_RELEASE_CONFIGS.engine; + +type TestCommit = { + sha: string; + subject: string; + files: string[]; +}; + +function commit(subject: string, files: string[], sha = subject): TestCommit { + return { sha: sha.padEnd(40, "0").slice(0, 40), subject, files }; +} + +describe("package release commit selection (#8591)", () => { + it("includes a commit touching the package's own directory", () => { + const commits = selectPackageReleaseCommits([commit("feat(engine): add new signal (#1)", ["packages/loopover-engine/src/foo.ts"])], engineConfig); + expect(commits.map((entry) => entry.subject)).toEqual(["feat(engine): add new signal (#1)"]); + }); + + it("excludes a commit that never touches the package's directory", () => { + const commits = selectPackageReleaseCommits([commit("feat(ui): add dashboard card (#2)", ["apps/loopover-ui/src/routes/app.tsx"])], engineConfig); + expect(commits).toEqual([]); + }); + + it("excludes merge commits", () => { + expect(isPackageReleaseRelevantCommit(commit("Merge pull request #3", ["packages/loopover-engine/src/foo.ts"]), engineConfig)).toBe(false); + }); + + it("excludes non-conventional commit subjects", () => { + expect(isPackageReleaseRelevantCommit(commit("random notes", ["packages/loopover-engine/src/foo.ts"]), engineConfig)).toBe(false); + }); + + it("excludes release/changelog-scoped commits (release-please's own bump commits)", () => { + expect(isPackageReleaseRelevantCommit(commit("chore(release): cut engine v3.15.0", ["packages/loopover-engine/package.json"]), engineConfig)).toBe(false); + expect(isPackageReleaseRelevantCommit(commit("chore(changelog): regenerate", ["packages/loopover-engine/CHANGELOG.md"]), engineConfig)).toBe(false); + }); + + it("scopes independently per package (an engine-scoped commit doesn't count for miner)", () => { + const c = commit("fix(engine): correct rounding (#4)", ["packages/loopover-engine/src/foo.ts"]); + expect(isPackageReleaseRelevantCommit(c, PACKAGE_RELEASE_CONFIGS.engine)).toBe(true); + expect(isPackageReleaseRelevantCommit(c, PACKAGE_RELEASE_CONFIGS.miner)).toBe(false); + }); +}); + +describe("buildPackageReleaseReport / buildPackageReleaseIssue", () => { + it("builds a release-due issue with the version and checklist", () => { + const report = buildPackageReleaseReport({ + config: engineConfig, + latestTag: { tag: "engine-v3.14.0", version: "3.14.0" }, + packageVersion: "3.14.0", + publishedVersion: "3.14.0", + commits: [commit("feat(engine): add new signal (#1)", ["packages/loopover-engine/src/foo.ts"])], + }); + const issue = buildPackageReleaseIssue(report, engineConfig); + + expect(report).toMatchObject({ due: true, proposedVersion: "3.15.0", releaseType: "minor" }); + expect(issue.title).toBe("Engine release due: 3.15.0"); + expect(issue.body).toContain(""); + expect(issue.body).toContain("- [ ] Run `npm run test:engine-pack`"); + expect(issue.body).toContain("- [ ] Tag `engine-v3.15.0`"); + expect(issue.body).toContain("Merge the release-please PR"); + }); + + it("is due when npm hasn't caught up to the package.json version, even with zero relevant commits", () => { + const report = buildPackageReleaseReport({ + config: engineConfig, + latestTag: { tag: "engine-v3.14.0", version: "3.14.0" }, + packageVersion: "3.14.0", + publishedVersion: "3.13.0", + commits: [], + }); + expect(report).toMatchObject({ due: true, proposedVersion: "3.14.0", releaseType: null }); + }); + + it("is not due when tag, package version, and npm all agree with zero relevant commits", () => { + const report = buildPackageReleaseReport({ + config: engineConfig, + latestTag: { tag: "engine-v3.14.0", version: "3.14.0" }, + packageVersion: "3.14.0", + publishedVersion: "3.14.0", + commits: [], + }); + expect(report).toMatchObject({ due: false, proposedVersion: "3.14.0" }); + }); + + it("prefers the already-bumped package.json version over a smaller inferred version", () => { + const report = buildPackageReleaseReport({ + config: engineConfig, + latestTag: { tag: "engine-v3.14.0", version: "3.14.0" }, + packageVersion: "4.0.0", // a manual/release-please major bump already committed + publishedVersion: "3.14.0", + commits: [commit("fix(engine): tiny fix (#5)", ["packages/loopover-engine/src/foo.ts"])], // would only infer a patch bump + }); + expect(report.proposedVersion).toBe("4.0.0"); + }); + + it("escapes untrusted commit subjects in the release-due issue", () => { + const maliciousSubject = "feat(engine): notify @octocat [SECURITY ACTION REQUIRED](https://evil.example/phish) #123"; + const report = buildPackageReleaseReport({ + config: engineConfig, + latestTag: { tag: "engine-v3.14.0", version: "3.14.0" }, + packageVersion: "3.14.0", + publishedVersion: "3.14.0", + commits: [commit(maliciousSubject, ["packages/loopover-engine/src/foo.ts"])], + }); + const issue = buildPackageReleaseIssue(report, engineConfig); + + expect(issue.body).not.toContain(maliciousSubject); + expect(issue.body).toContain("@\u200boctocat"); + expect(issue.body).toContain("\\[SECURITY ACTION REQUIRED\\]\\(https://evil\\.example/phish\\)"); + }); + + it("falls back to placeholder text when there are no commits or changed files", () => { + const report = buildPackageReleaseReport({ + config: PACKAGE_RELEASE_CONFIGS["ui-kit"], + latestTag: { tag: "ui-kit-v1.1.2", version: "1.1.2" }, + packageVersion: "1.1.2", + publishedVersion: "1.1.1", + commits: [], + }); + const issue = buildPackageReleaseIssue(report, PACKAGE_RELEASE_CONFIGS["ui-kit"]); + expect(issue.body).toContain("- No unreleased ui-kit-related commits detected."); + expect(issue.body).toContain("- No changed files detected."); + }); + + it("only updates the bot-owned release reminder issue, scoped by this package's own marker", () => { + expect( + isReleaseWatchIssue( + { title: "Engine release due: 3.15.0", body: "", user: { login: "github-actions[bot]" } }, + engineConfig, + ), + ).toBe(true); + // A different package's marker must not cross-match. + expect( + isReleaseWatchIssue( + { title: "Miner release due: 3.15.0", body: "", user: { login: "github-actions[bot]" } }, + engineConfig, + ), + ).toBe(false); + expect( + isReleaseWatchIssue( + { title: "Engine release due: 3.15.0", body: "", user: { login: "public-contributor" } }, + engineConfig, + ), + ).toBe(false); + }); +}); + +describe("closeResolvedIssueIfPresent (#8591, mirrors #6145's fix for MCP)", () => { + const resolvedReport = { + due: false, + proposedVersion: "3.14.0", + latestTag: "engine-v3.14.0", + latestTagVersion: "3.14.0", + packageVersion: "3.14.0", + publishedVersion: "3.14.0", + releaseType: null, + commits: [], + changedFiles: [], + }; + + afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.GITHUB_REPOSITORY; + delete process.env.GITHUB_TOKEN; + }); + + it("comments and closes an existing open watch issue once the release has caught up", async () => { + process.env.GITHUB_REPOSITORY = "acme/widgets"; + process.env.GITHUB_TOKEN = "test-token"; + const calls: Array<{ method: string; url: string; body: unknown }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? "GET"; + calls.push({ method, url: String(input), body: init?.body ? JSON.parse(init.body as string) : undefined }); + if (method === "GET") { + return new Response( + JSON.stringify([{ number: 555, title: "Engine release due: 4.0.0", body: "", user: { login: "github-actions[bot]" } }]), + { status: 200 }, + ); + } + return new Response("{}", { status: 200 }); + }), + ); + + await closeResolvedIssueIfPresent(resolvedReport, engineConfig); + + expect(calls).toHaveLength(3); + const commentCall = calls.find((call) => call.method === "POST" && call.url.includes("/comments")); + const patchCall = calls.find((call) => call.method === "PATCH"); + expect(commentCall?.url).toContain("/issues/555/comments"); + expect(commentCall?.body).toMatchObject({ body: expect.stringContaining("caught up") }); + expect(patchCall?.url).toContain("/issues/555"); + expect(patchCall?.body).toEqual({ state: "closed", state_reason: "completed" }); + }); + + it("does nothing when no open watch issue exists", async () => { + process.env.GITHUB_REPOSITORY = "acme/widgets"; + process.env.GITHUB_TOKEN = "test-token"; + const fetchMock = vi.fn(async () => new Response("[]", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await closeResolvedIssueIfPresent(resolvedReport, engineConfig); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("ignores an open issue whose marker belongs to a different package", async () => { + process.env.GITHUB_REPOSITORY = "acme/widgets"; + process.env.GITHUB_TOKEN = "test-token"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify([{ number: 9, title: "Miner release due: 4.0.0", body: "", user: { login: "github-actions[bot]" } }]), { status: 200 }), + ) + .mockResolvedValue(new Response("[]", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await closeResolvedIssueIfPresent(resolvedReport, engineConfig); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +});