diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e97fc7..c065366 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,10 +75,26 @@ jobs: - run: pnpm exec playwright install --with-deps chromium - run: pnpm test:e2e + export-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + with: + version: 12.4.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Analyze, export, build, and smoke test Node and React fixtures + run: pnpm test:export-smoke + quality-gate: name: Quality gate if: always() - needs: [verify, package-smoke, e2e] + needs: [verify, package-smoke, e2e, export-smoke] runs-on: ubuntu-latest steps: - name: Require every CI job to pass @@ -86,7 +102,9 @@ jobs: VERIFY_RESULT: ${{ needs.verify.result }} PACKAGE_RESULT: ${{ needs.package-smoke.result }} E2E_RESULT: ${{ needs.e2e.result }} + EXPORT_SMOKE_RESULT: ${{ needs.export-smoke.result }} run: | test "$VERIFY_RESULT" = success test "$PACKAGE_RESULT" = success test "$E2E_RESULT" = success + test "$EXPORT_SMOKE_RESULT" = success diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed5375b..52905f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,6 +81,13 @@ jobs: wait "$studio_pid" 2>/dev/null || true trap - EXIT + - name: Build and smoke test exported Node and React packages + shell: bash + run: | + tarball=$(find "$RUNNER_TEMP" -maxdepth 1 -type f -name 'codelift-cli-*.tgz' -print -quit) + test -n "$tarball" + pnpm test:export-smoke "$tarball" + - name: Write the release preflight summary shell: bash run: | @@ -95,7 +102,7 @@ jobs: echo "- Tarball size: \`$(stat -c%s "$tarball") bytes\`" echo "- Publish command: \`npm publish --tag ${{ steps.release.outputs.npm_tag }} --access public\`" echo - echo "All lint, typecheck, test, build, Playwright, production audit, package install, CLI, core API, and Studio smoke checks passed." + echo "All lint, typecheck, test, build, Playwright, production audit, package install, CLI, core API, Studio, and exported Node/React build and smoke checks passed." echo echo "
Tarball files" echo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d71c55..bbe0edd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,10 @@ pnpm test:e2e pnpm audit --prod ``` +For changes to analysis, plans, export, or verification, also run `pnpm build` followed by +`pnpm test:export-smoke`. This packs the CLI and builds exported Node and React fixtures in isolated +temporary directories. + For packaging changes, also run: ```bash diff --git a/README.md b/README.md index 3f420bb..aa23487 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,17 @@ pnpm build pnpm test:e2e ``` +To test the complete extraction workflow using the packed CLI, run: + +```bash +pnpm build +pnpm test:export-smoke +``` + +This analyzes the Node and React fixtures, exports both packages into temporary directories, +installs their dependencies, builds them, runs smoke checks, and verifies that the source fixtures +did not change. + Convenience commands: ```bash @@ -355,8 +366,9 @@ npm pack --dry-run ``` CI runs lint, type checking, unit/integration tests, builds, browser tests, and package smoke tests on -Node 24 across Ubuntu, macOS, and Windows. See [docs/releasing.md](docs/releasing.md) for the guarded -npm provenance workflow. The project is licensed under [MIT](LICENSE). +Node 24 across Ubuntu, macOS, and Windows. It also builds and smoke-tests both exported fixture +packages on Ubuntu. See [docs/releasing.md](docs/releasing.md) for the guarded npm provenance +workflow. The project is licensed under [MIT](LICENSE). ## Release status diff --git a/docs/releasing.md b/docs/releasing.md index 9068eb4..614f7e9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -38,7 +38,9 @@ cd packages/cli && npm pack --dry-run The release preflight verifies that the root, core, CLI, Studio, and server versions match and that the package has no lifecycle scripts. The packed tarball must also be installed into an empty npm project and checked through `codelift --version`, `codelift doctor`, `codelift inspect`, -`codelift-cli/core`, and the packaged Studio HTTP endpoint. +`codelift-cli/core`, and the packaged Studio HTTP endpoint. The same tarball must complete Node and +React analyze → plan → export → install → build → smoke workflows, with unchanged source fixture +hashes. ## GitHub dry run diff --git a/package.json b/package.json index cab921e..d422256 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "test:export-smoke": "node scripts/export-smoke.mjs", "ci": "pnpm lint && pnpm typecheck && pnpm test && pnpm build", "release:preflight": "node scripts/release-preflight.mjs", "codelift": "node packages/cli/dist/bin.js", diff --git a/scripts/export-smoke.mjs b/scripts/export-smoke.mjs new file mode 100644 index 0000000..87b37dc --- /dev/null +++ b/scripts/export-smoke.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const workspaceRoot = fileURLToPath(new URL("../", import.meta.url)); +const suppliedTarball = process.argv[2]; +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codelift-export-smoke-")); +process.env.npm_config_cache = path.join(temporaryRoot, "npm-cache"); +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const cases = [ + { fixture: "node-library", entrypoint: "src/index.ts", packageName: "codelift-smoke-node" }, + { fixture: "react-library", entrypoint: "src/Card.tsx", packageName: "codelift-smoke-react" }, +]; + +function digestDirectory(root) { + const hash = createHash("sha256"); + const visit = (directory) => { + for (const entry of fs + .readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name))) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + visit(absolute); + } else if (entry.isFile()) { + hash.update(path.relative(root, absolute)); + hash.update("\0"); + hash.update(fs.readFileSync(absolute)); + hash.update("\0"); + } else { + throw new Error(`Unsupported fixture entry: ${absolute}`); + } + } + }; + visit(root); + return hash.digest("hex"); +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + timeout: 5 * 60 * 1000, + ...(process.platform === "win32" && command === npm ? { shell: true } : {}), + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with exit ${result.status}:\n${result.stdout}\n${result.stderr}`, + ); + } + return result.stdout; +} + +function parseJsonOutput(output) { + const start = output.indexOf("{"); + assert.ok(start >= 0, `Expected JSON output, received:\n${output}`); + return JSON.parse(output.slice(start)); +} + +try { + const tarball = suppliedTarball + ? path.resolve(suppliedTarball) + : path.join( + temporaryRoot, + run( + npm, + ["pack", "--silent", "--pack-destination", temporaryRoot], + path.join(workspaceRoot, "packages", "cli"), + ).trim(), + ); + assert.ok(fs.existsSync(tarball), `CLI tarball does not exist: ${tarball}`); + const toolRoot = path.join(temporaryRoot, "tool"); + fs.mkdirSync(toolRoot); + fs.writeFileSync(path.join(toolRoot, "package.json"), '{"private":true,"type":"module"}\n'); + run(npm, ["install", tarball, "--ignore-scripts", "--no-audit", "--no-fund"], toolRoot); + const cli = path.join(toolRoot, "node_modules", "codelift-cli", "dist", "bin.js"); + assert.ok(fs.existsSync(cli), "The packed CLI did not install its executable."); + + for (const item of cases) { + const fixtureRoot = path.join(workspaceRoot, "fixtures", item.fixture); + const before = digestDirectory(fixtureRoot); + const caseRoot = path.join(temporaryRoot, item.fixture); + const destination = path.join(caseRoot, item.packageName); + const planPath = path.join(caseRoot, "plan.json"); + fs.mkdirSync(caseRoot); + + const analysis = parseJsonOutput( + run( + process.execPath, + [cli, "inspect", item.entrypoint, "--project", fixtureRoot, "--format", "json"], + caseRoot, + ), + ); + assert.equal(analysis.entrypoint, item.entrypoint); + assert.equal(analysis.issues.filter((issue) => issue.blocking).length, 0); + + run( + process.execPath, + [ + cli, + "plan", + item.entrypoint, + "--project", + fixtureRoot, + "--name", + item.packageName, + "--out", + destination, + "--save", + planPath, + "--accept-warnings", + ], + caseRoot, + ); + const plan = JSON.parse(fs.readFileSync(planPath, "utf8")); + assert.equal(plan.status, "ready"); + run(process.execPath, [cli, "extract", "--plan", planPath], caseRoot); + assert.ok(fs.existsSync(path.join(destination, "codelift-report.json"))); + + const verification = parseJsonOutput( + run( + process.execPath, + [cli, "verify", destination, "--install", "--format", "json"], + caseRoot, + ), + ); + assert.equal( + verification.status, + "passed", + `${item.fixture} verification failed: ${JSON.stringify(verification.checks, null, 2)}`, + ); + for (const id of [ + "manifest", + "source-boundary", + "file-manifest", + "imports", + "install", + "build", + "smoke", + ]) { + assert.equal( + verification.checks.find((check) => check.id === id)?.status, + "passed", + `${item.fixture}: ${id} did not pass`, + ); + } + assert.equal(digestDirectory(fixtureRoot), before, `${item.fixture} source fixture changed`); + process.stdout.write(`${item.fixture}: analyze, plan, export, install, build, smoke passed\n`); + } +} finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +}