diff --git a/DEVLOG.md b/DEVLOG.md index c1550c8a..179ab48a 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,25 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-20 — AXI-136 fast local verification and release workflow + +Replaced the Unix-only local CI shell chain with a cross-platform TypeScript +runner. Local verification now verifies or starts the guarded local service +containers, supplies safe local endpoints in isolated worktrees, generates +Prisma explicitly, offers a fast quality-only loop, avoids duplicate quality +passes, and serializes Playwright with a portable lock while selecting an +available port after the lock is held. Added focused unit coverage for option +parsing, plans, port selection, and lock behavior. + +Local container inspection now times out with an actionable Docker Desktop +diagnostic instead of leaving `pnpm dev:services` or the CI gate hanging +indefinitely when the daemon is unresponsive. + +Documented when to use the quality, E2E-only, and complete local gates. Also +defined a narrow single-PR patch-release path when an operator assigns the +version, authority, and release owner before implementation; unassigned or +batched releases retain the separate release issue/PR workflow. + ## 2026-07-20 — v0.29.2 release preparation Prepared the serialized patch release containing AXI-134 and merged PR #76. diff --git a/RELEASE.md b/RELEASE.md index fb2e857d..5f105e6e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -32,14 +32,14 @@ fix/-* ← bugfix branches from main Forge's explicit branch contract: -| Setting | Value | -| --- | --- | -| Integration branch / normal PR base | `main` | -| Release branch / tag source | `main` | -| Staging source | Exact tested `main` SHA | -| Production source | Immutable `vX.Y.Z` tag contained in `main` | -| Hotfix base | Current production tag or `main` | -| Back-merge target | None; `main` is the only long-lived branch | +| Setting | Value | +| ----------------------------------- | ------------------------------------------ | +| Integration branch / normal PR base | `main` | +| Release branch / tag source | `main` | +| Staging source | Exact tested `main` SHA | +| Production source | Immutable `vX.Y.Z` tag contained in `main` | +| Hotfix base | Current production tag or `main` | +| Back-merge target | None; `main` is the only long-lived branch | ## Versioning @@ -56,12 +56,39 @@ Forge's explicit branch contract: ## Release Process -For the same local gate used before direct releases, run: +Use the fast quality gate while iterating: + +```bash +pnpm ci:local:quality +``` + +Before declaring a PR release-ready, run the complete local gate once: ```bash pnpm ci:local ``` +The TypeScript runner is cross-platform. It starts or verifies the guarded +local services, supplies only the known local development endpoints, generates +the Prisma client, and runs lint, typecheck, and Vitest once. It then serializes +Playwright behind a portable machine-local lock and selects an available port. +`pnpm ci:local:e2e` runs only the E2E phase; add `-- --reuse-e2e-build` only when +the existing production build is known to match the current source. These +local commands complement, but do not replace, CI on the exact PR head. + +### Authorized single-PR patch releases + +A small, cohesive patch may include its version bump and curated changelog in +the implementation PR only when the operator authorizes that release and +assigns its version and release owner before implementation begins. The exact +PR head must pass CI; after squash-merge, tag the exact resulting `main` SHA and +follow the normal deploy and verification steps below. + +Use a separate release issue/PR when release authority or the version was not +preassigned, when multiple merged changes are being batched, or when release +assembly needs independent review. Never add an uncoordinated version bump to +an ordinary feature PR. + ### 1. Land the work - Open a PR `/-* → main`. CI (`.github/workflows/ci.yml`: lint · typecheck · unit · diff --git a/docs/engineering/work-management.md b/docs/engineering/work-management.md index ac5baed5..65029df9 100644 --- a/docs/engineering/work-management.md +++ b/docs/engineering/work-management.md @@ -133,6 +133,10 @@ shared routers, foundational UI, and deployment configuration. - Feature PRs do not independently cut competing releases. A designated release owner assembles release notes, bumps the version, tags the exact merged SHA, and deploys it. +- A cohesive patch may carry its version and curated changelog in its primary + implementation PR only when an operator assigns the release version, release + owner, and release authority before implementation. Otherwise, or when + batching multiple changes, use a separate release issue and `RELEASES` PR. - Never silently resolve a schema or migration conflict by dropping another contributor's migration. diff --git a/docs/guide/local-development.md b/docs/guide/local-development.md index 31a82a9f..ec6d0865 100644 --- a/docs/guide/local-development.md +++ b/docs/guide/local-development.md @@ -170,11 +170,18 @@ pnpm worker ## Before you ship ```bash -pnpm lint && pnpm typecheck && pnpm test -pnpm test:e2e # needs Postgres + Redis +pnpm ci:local:quality # Prisma generate + lint + typecheck + Vitest +pnpm ci:local # the same checks, then Playwright ``` -Then append a line to `DEVLOG.md` and commit. +Use `pnpm ci:local:quality` during implementation and run the complete gate once +before release readiness. `pnpm ci:local:e2e` runs only Playwright when a +focused E2E repeat is needed. The cross-platform runner verifies or starts the +guarded local services, supplies the known local endpoints even in an isolated +worktree, serializes E2E runs with a machine-local lock, selects an available +port, resets only the dedicated `forge_e2e` database, and starts a fresh server. Pass +`pnpm ci:local:e2e -- --reuse-e2e-build` only when `.next` was built from the +current source. Then append a line to `DEVLOG.md` and commit. ## Measuring issue UI load diff --git a/package.json b/package.json index 0826c1e6..e57fa59e 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,9 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "ci:local": "pnpm lint && pnpm typecheck && pnpm test --no-file-parallelism && flock /tmp/forge-e2e.lock -c 'bash scripts/prepare-e2e-port.sh && E2E_FORCE_FRESH_SERVER=1 E2E_RESET_DB=1 E2E_FORCE_BUILD=1 pnpm exec playwright test --workers=1'", + "ci:local": "tsx scripts/ci-local.ts --full", + "ci:local:quality": "tsx scripts/ci-local.ts --quality", + "ci:local:e2e": "tsx scripts/ci-local.ts --e2e-only", "worker": "NODE_OPTIONS='--require ./scripts/ignore-server-only.cjs' tsx src/server/worker.ts", "worker:live": "./scripts/worker-live.sh", "worker:live:watch": "./scripts/worker-live.sh --watch", @@ -52,7 +54,7 @@ "build:cli": "tsc -p tools/forge-cli", "forge": "node tools/forge-cli/dist/index.js", "changelog": "tsx tools/scripts/changelog-scaffold.ts", - "e2e": "flock /tmp/forge-e2e.lock -c 'bash scripts/prepare-e2e-port.sh && E2E_FORCE_FRESH_SERVER=1 E2E_RESET_DB=1 E2E_FORCE_BUILD=1 playwright test'", + "e2e": "tsx scripts/ci-local.ts --e2e-only", "e2e:web": "bash scripts/e2e-web.sh", "test:lifecycle": "playwright test --config=playwright.lifecycle.config.ts" }, diff --git a/scripts/ci-local.ts b/scripts/ci-local.ts new file mode 100644 index 00000000..e682a740 --- /dev/null +++ b/scripts/ci-local.ts @@ -0,0 +1,120 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + acquireDirectoryLock, + buildLocalCiPlan, + buildPortCandidates, + findAvailablePort, + parseLocalCiOptions, + type LocalCiStep, +} from "./lib/ci-local-workflow"; +import { LOCAL_DEV, assertSafeLocalEnvironment, parseWindowsPnpmEntry } from "./lib/dev-workflow"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function resolvePnpm(): { command: string; prefix: string[] } { + if (process.platform !== "win32") return { command: "pnpm", prefix: [] }; + const located = spawnSync("where.exe", ["pnpm.cmd"], { encoding: "utf8", shell: false }); + const wrapper = located.stdout?.split(/\r?\n/).find(Boolean); + if (!wrapper) throw new Error("pnpm.cmd was not found on PATH"); + const entry = parseWindowsPnpmEntry(readFileSync(wrapper, "utf8")); + if (!entry) throw new Error(`Could not resolve the pnpm JavaScript entry from ${wrapper}`); + return { command: process.execPath, prefix: [resolve(dirname(wrapper), entry)] }; +} + +function formatStep(step: LocalCiStep): string { + const env = Object.entries(step.env ?? {}) + .map(([key, value]) => `${key}=${value}`) + .join(" "); + return `${env ? `${env} ` : ""}pnpm ${step.args.join(" ")}`; +} + +const localCiEnv: Record = { + DATABASE_URL: LOCAL_DEV.databaseUrl, + REDIS_URL: LOCAL_DEV.redisUrl, + S3_ENDPOINT: LOCAL_DEV.s3Endpoint, + S3_PUBLIC_ENDPOINT: LOCAL_DEV.s3Endpoint, + S3_REGION: process.env.S3_REGION ?? "us-east-1", + S3_ACCESS_KEY: process.env.S3_ACCESS_KEY ?? "forgeminio", + S3_SECRET_KEY: process.env.S3_SECRET_KEY ?? "forgeminio-dev-password", + S3_FORCE_PATH_STYLE: "true", + AUTH_SECRET: process.env.AUTH_SECRET ?? "dev-local-secret-changeme-32bytes!!", + AUTH_URL: process.env.AUTH_URL_DEV ?? "http://localhost:3000", + NEXT_PUBLIC_APP_URL: process.env.AUTH_URL_DEV ?? "http://localhost:3000", + AUTH_TRUST_HOST: "true", + PLUGIN_JWT_SECRET: process.env.PLUGIN_JWT_SECRET ?? "dev-plugin-signing-key", + PLUGIN_JWT_ISSUER: process.env.PLUGIN_JWT_ISSUER ?? "forge", + PLUGIN_JWT_AUDIENCE: process.env.PLUGIN_JWT_AUDIENCE ?? "forge-plugins", +}; + +function runStep(step: LocalCiStep, pnpm: { command: string; prefix: string[] }): void { + const startedAt = Date.now(); + console.log(`\n[ci:local] ${step.label}\n[ci:local] $ ${formatStep(step)}`); + const result = spawnSync(pnpm.command, [...pnpm.prefix, ...step.args], { + cwd: root, + env: { ...process.env, ...localCiEnv, ...step.env }, + stdio: "inherit", + shell: false, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${step.label} failed with exit ${result.status ?? "unknown"}.`); + } + console.log( + `[ci:local] ${step.label} passed in ${Math.round((Date.now() - startedAt) / 1000)}s.`, + ); +} + +async function main(): Promise { + const options = parseLocalCiOptions(process.argv.slice(2)); + assertSafeLocalEnvironment(process.env); + const needsE2e = options.mode !== "quality"; + const preferredE2ePort = Number(process.env.E2E_PORT ?? 3200); + if (needsE2e) buildPortCandidates(preferredE2ePort, 1); + + if (options.dryRun) { + const plan = buildLocalCiPlan(options, preferredE2ePort); + console.log( + `[ci:local] mode=${options.mode}${needsE2e ? ` preferredE2ePort=${preferredE2ePort}` : ""}${options.reuseE2eBuild ? " reuseBuild=true" : ""}`, + ); + for (const step of plan) console.log(`[ci:local] ${step.label}: ${formatStep(step)}`); + return; + } + + const pnpm = resolvePnpm(); + console.log(`[ci:local] mode=${options.mode}${options.reuseE2eBuild ? " reuseBuild=true" : ""}`); + const quality = buildLocalCiPlan(options).filter( + (candidate) => candidate.label !== "Playwright E2E", + ); + for (const step of quality) { + runStep(step, pnpm); + } + if (!needsE2e) return; + + const lockPath = join(tmpdir(), "forge-e2e.lock"); + const releaseLock = await acquireDirectoryLock(lockPath, { + onWait: (owner) => { + const detail = owner ? ` (PID ${owner.pid} on ${owner.host})` : ""; + console.log(`[ci:local] Waiting for another Forge E2E run${detail}…`); + }, + }); + try { + const e2ePort = await findAvailablePort(preferredE2ePort); + const e2e = buildLocalCiPlan(options, e2ePort).find( + (candidate) => candidate.label === "Playwright E2E", + ); + if (!e2e) throw new Error("The local CI plan did not contain its expected E2E step."); + console.log(`[ci:local] Acquired E2E lock; using port ${e2ePort}.`); + runStep(e2e, pnpm); + } finally { + await releaseLock(); + } +} + +main().catch((error) => { + console.error(`[ci:local] ${error instanceof Error ? error.message : error}`); + process.exitCode = 1; +}); diff --git a/scripts/dev.ts b/scripts/dev.ts index f85473f6..a7fb2be6 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -100,8 +100,14 @@ function inspectService(name: keyof typeof LOCAL_DEV.containers): ServiceState { const result = spawnSync( "docker", ["inspect", "--format", "{{json .State}}", LOCAL_DEV.containers[name]], - { encoding: "utf8", shell: false }, + { encoding: "utf8", shell: false, timeout: 15_000 }, ); + if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") { + throw new Error( + `Docker did not respond while inspecting ${LOCAL_DEV.containers[name]}; check Docker Desktop before retrying.`, + ); + } + if (result.error) throw result.error; if (result.status !== 0) return { exists: false, running: false, healthy: false }; const state = JSON.parse(result.stdout) as { Running?: boolean; Health?: { Status?: string } }; return { diff --git a/scripts/lib/ci-local-workflow.ts b/scripts/lib/ci-local-workflow.ts new file mode 100644 index 00000000..31d41e25 --- /dev/null +++ b/scripts/lib/ci-local-workflow.ts @@ -0,0 +1,167 @@ +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { hostname } from "node:os"; + +export type LocalCiMode = "quality" | "full" | "e2e-only"; + +export type LocalCiOptions = { + mode: LocalCiMode; + dryRun: boolean; + reuseE2eBuild: boolean; +}; + +export type LocalCiStep = { + label: string; + args: string[]; + env?: Record; +}; + +export function parseLocalCiOptions(argv: string[]): LocalCiOptions { + let mode: LocalCiMode = "full"; + let modeWasSet = false; + let dryRun = false; + let reuseE2eBuild = false; + + for (const value of argv.filter((arg) => arg !== "--")) { + const requestedMode = + value === "--quality" + ? "quality" + : value === "--full" + ? "full" + : value === "--e2e-only" + ? "e2e-only" + : undefined; + if (requestedMode) { + if (modeWasSet && mode !== requestedMode) { + throw new Error("Choose exactly one of --quality, --full, or --e2e-only."); + } + mode = requestedMode; + modeWasSet = true; + } else if (value === "--dry-run") { + dryRun = true; + } else if (value === "--reuse-e2e-build") { + reuseE2eBuild = true; + } else { + throw new Error(`Unknown local CI option: ${value}`); + } + } + + return { mode, dryRun, reuseE2eBuild }; +} + +export function buildLocalCiPlan(options: LocalCiOptions, e2ePort = 3200): LocalCiStep[] { + const quality: LocalCiStep[] = [ + { label: "Ensure local services", args: ["dev:services"] }, + { label: "Generate Prisma client", args: ["prisma:generate"] }, + { label: "Lint", args: ["lint"] }, + { label: "Typecheck", args: ["typecheck"] }, + { label: "Unit and integration tests", args: ["test", "--no-file-parallelism"] }, + ]; + const e2e: LocalCiStep = { + label: "Playwright E2E", + args: ["exec", "playwright", "test", "--workers=1"], + env: { + E2E_PORT: String(e2ePort), + PLAYWRIGHT_BASE_URL: `http://localhost:${e2ePort}`, + E2E_FORCE_FRESH_SERVER: "1", + E2E_RESET_DB: "1", + E2E_FORCE_BUILD: options.reuseE2eBuild ? "0" : "1", + }, + }; + + if (options.mode === "quality") return quality; + if (options.mode === "e2e-only") return [e2e]; + return [...quality, e2e]; +} + +export function buildPortCandidates(preferred: number, count = 100): number[] { + if (!Number.isInteger(preferred) || preferred < 1 || preferred > 65_535) { + throw new Error(`Invalid preferred E2E port: ${preferred}`); + } + return Array.from({ length: count }, (_, index) => preferred + index).filter( + (port) => port <= 65_535, + ); +} + +async function canListen(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => server.close(() => resolve(true))); + }); +} + +export async function findAvailablePort(preferred = 3200): Promise { + for (const port of buildPortCandidates(preferred)) { + if (await canListen(port)) return port; + } + throw new Error(`No available E2E port found from ${preferred} through ${preferred + 99}.`); +} + +type LockOwner = { pid: number; host: string; acquiredAt: string }; + +export async function acquireDirectoryLock( + lockPath: string, + options: { + timeoutMs?: number; + pollMs?: number; + staleMs?: number; + onWait?: (owner?: LockOwner) => void; + } = {}, +): Promise<() => Promise> { + const timeoutMs = options.timeoutMs ?? 10 * 60_000; + const pollMs = options.pollMs ?? 1_000; + const staleMs = options.staleMs ?? 12 * 60 * 60_000; + const startedAt = Date.now(); + let announcedWait = false; + + while (true) { + try { + await mkdir(lockPath); + const owner: LockOwner = { + pid: process.pid, + host: hostname(), + acquiredAt: new Date().toISOString(), + }; + await writeFile(`${lockPath}/owner.json`, `${JSON.stringify(owner, null, 2)}\n`, "utf8"); + return async () => { + await rm(lockPath, { recursive: true, force: true }); + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + + let owner: LockOwner | undefined; + try { + owner = JSON.parse(await readFile(`${lockPath}/owner.json`, "utf8")) as LockOwner; + } catch { + // A contender can observe the directory before owner.json is written. + } + + let lockAge: number; + try { + lockAge = Date.now() - (await stat(lockPath)).mtimeMs; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (lockAge > staleMs) { + try { + await rm(lockPath, { recursive: true, force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + continue; + } + if (!announcedWait) { + options.onWait?.(owner); + announcedWait = true; + } + if (Date.now() - startedAt >= timeoutMs) { + const detail = owner ? ` held by PID ${owner.pid} on ${owner.host}` : ""; + throw new Error(`Timed out waiting for local E2E lock ${lockPath}${detail}.`); + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } +} diff --git a/scripts/prepare-e2e-port.sh b/scripts/prepare-e2e-port.sh deleted file mode 100644 index f84d0a2c..00000000 --- a/scripts/prepare-e2e-port.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# Called only after acquiring /tmp/forge-e2e.lock. Ensure Playwright cannot -# silently reuse a stale local server and wait for its wrapper process to -# finish releasing the socket before starting a fresh one. -set -euo pipefail - -PORT="${E2E_PORT:-3200}" - -fuser -k "${PORT}/tcp" 2>/dev/null || true - -for _ in $(seq 1 30); do - if ! fuser "${PORT}/tcp" >/dev/null 2>&1; then - exit 0 - fi - sleep 1 -done - -echo "[e2e] Port ${PORT} is still occupied after waiting for stale-server shutdown." >&2 -exit 1 diff --git a/tests/unit/ci-local-workflow.test.ts b/tests/unit/ci-local-workflow.test.ts new file mode 100644 index 00000000..4c338763 --- /dev/null +++ b/tests/unit/ci-local-workflow.test.ts @@ -0,0 +1,84 @@ +import { mkdtemp, mkdir, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + acquireDirectoryLock, + buildLocalCiPlan, + buildPortCandidates, + parseLocalCiOptions, +} from "../../scripts/lib/ci-local-workflow"; + +describe("local CI workflow", () => { + it("defaults to the full release gate and supports explicit modes", () => { + expect(parseLocalCiOptions([])).toEqual({ + mode: "full", + dryRun: false, + reuseE2eBuild: false, + }); + expect(parseLocalCiOptions(["--", "--quality", "--dry-run"])).toMatchObject({ + mode: "quality", + dryRun: true, + }); + expect(parseLocalCiOptions(["--e2e-only", "--reuse-e2e-build"])).toMatchObject({ + mode: "e2e-only", + reuseE2eBuild: true, + }); + }); + + it("rejects conflicting modes and unknown options", () => { + expect(() => parseLocalCiOptions(["--quality", "--full"])).toThrow(/exactly one/); + expect(() => parseLocalCiOptions(["--quick"])).toThrow(/Unknown/); + }); + + it("generates Prisma before one quality pass and adds E2E only for full mode", () => { + const quality = buildLocalCiPlan(parseLocalCiOptions(["--quality"])); + expect(quality.map((step) => step.label)).toEqual([ + "Ensure local services", + "Generate Prisma client", + "Lint", + "Typecheck", + "Unit and integration tests", + ]); + expect(quality.filter((step) => step.label === "Unit and integration tests")).toHaveLength(1); + + const full = buildLocalCiPlan(parseLocalCiOptions([]), 3277); + expect(full).toHaveLength(6); + expect(full[5]).toMatchObject({ + label: "Playwright E2E", + env: { + E2E_PORT: "3277", + PLAYWRIGHT_BASE_URL: "http://localhost:3277", + E2E_FORCE_FRESH_SERVER: "1", + E2E_RESET_DB: "1", + E2E_FORCE_BUILD: "1", + }, + }); + }); + + it("uses a reusable E2E build only when requested", () => { + const [e2e] = buildLocalCiPlan(parseLocalCiOptions(["--e2e-only", "--reuse-e2e-build"]), 3201); + expect(e2e.env?.E2E_FORCE_BUILD).toBe("0"); + }); + + it("builds a bounded deterministic port search", () => { + expect(buildPortCandidates(3200, 3)).toEqual([3200, 3201, 3202]); + expect(() => buildPortCandidates(0)).toThrow(/Invalid/); + }); + + it("serializes E2E work with a portable directory lock", async () => { + const parent = await mkdtemp(join(tmpdir(), "forge-ci-lock-test-")); + const lockPath = join(parent, "e2e.lock"); + const release = await acquireDirectoryLock(lockPath); + await expect(stat(lockPath)).resolves.toBeDefined(); + await expect( + acquireDirectoryLock(lockPath, { timeoutMs: 5, pollMs: 1, staleMs: 60_000 }), + ).rejects.toThrow(/Timed out/); + await release(); + await expect(stat(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); + + await mkdir(lockPath); + const releaseStale = await acquireDirectoryLock(lockPath, { staleMs: -1 }); + await releaseStale(); + }); +});