Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 36 additions & 9 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ fix/<issue>-* ← 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

Expand All @@ -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 `<owner>/<issue>-* → main`. CI (`.github/workflows/ci.yml`: lint · typecheck · unit ·
Expand Down
4 changes: 4 additions & 0 deletions docs/engineering/work-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 10 additions & 3 deletions docs/guide/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
120 changes: 120 additions & 0 deletions scripts/ci-local.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<void> {
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;
});
8 changes: 7 additions & 1 deletion scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading