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
20 changes: 19 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,36 @@ 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
env:
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
9 changes: 8 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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 "<details><summary>Tarball files</summary>"
echo
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
157 changes: 157 additions & 0 deletions scripts/export-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading