feat(HAI-108): remove binary builds and configure npm publishing

- Remove binary build steps from CI and delete release/test-release workflows
- Replace release workflow with npm publish via version.yml and changesets
- Configure all packages (cli, core, dashboard, engine) for npm publishing
- Add package-config tests to verify publishConfig and package metadata
- Update README and documentation to reflect npm-based distribution
This commit is contained in:
Dustin Byrne
2026-03-26 21:33:55 -04:00
parent d616273a73
commit a7a9e985e2
12 changed files with 181 additions and 552 deletions

View File

@@ -5,6 +5,10 @@
"bin": {
"hai": "./dist/bin.js"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"dev": "tsx src/bin.ts",
"build": "tsc",

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync, accessSync, constants } from "node:fs";
import { readFileSync, accessSync, constants, existsSync } from "node:fs";
import { join } from "node:path";
import { parse } from "yaml";
@@ -42,8 +42,12 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
expect(content).toContain("pnpm build");
});
it("includes pnpm build:exe step", () => {
expect(content).toContain("pnpm build:exe");
it("does not include binary build step", () => {
expect(content).not.toContain("pnpm build:exe");
});
it("does not include Bun setup", () => {
expect(content).not.toContain("oven-sh/setup-bun");
});
it("includes pnpm test step", () => {
@@ -51,12 +55,12 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
});
});
describe("Release workflow (.github/workflows/release.yml)", () => {
describe("Version & Release workflow (.github/workflows/version.yml)", () => {
let workflow: any;
let content: string;
beforeAll(() => {
const result = loadWorkflow("release.yml");
const result = loadWorkflow("version.yml");
workflow = result.parsed;
content = result.content;
});
@@ -66,18 +70,8 @@ describe("Release workflow (.github/workflows/release.yml)", () => {
expect(typeof workflow).toBe("object");
});
it("has tag-based trigger matching v*", () => {
expect(workflow.on.push.tags).toBeDefined();
const tags = workflow.on.push.tags;
expect(tags.some((t: string) => t.includes("v"))).toBe(true);
});
it("includes softprops/action-gh-release action", () => {
expect(content).toContain("softprops/action-gh-release");
});
it("includes permissions contents write", () => {
expect(workflow.permissions?.contents).toBe("write");
it("has push trigger on main", () => {
expect(workflow.on.push.branches).toContain("main");
});
it("includes pnpm install step", () => {
@@ -88,189 +82,39 @@ describe("Release workflow (.github/workflows/release.yml)", () => {
expect(content).toContain("pnpm build");
});
it("includes build:exe step", () => {
expect(content).toContain("build:exe");
it("uses changesets/action", () => {
expect(content).toContain("changesets/action");
});
it("generates SHA256 checksums", () => {
expect(content).toContain("sha256sum");
it("has publish command for npm", () => {
expect(content).toContain("pnpm -r publish");
});
describe("matrix build strategy", () => {
it("has a build job with strategy.matrix including at least 4 entries", () => {
const buildJob = workflow.jobs.build;
expect(buildJob).toBeDefined();
expect(buildJob.strategy?.matrix?.include?.length).toBeGreaterThanOrEqual(4);
});
it("references NPM_TOKEN secret", () => {
expect(content).toContain("secrets.NPM_TOKEN");
});
it("includes all required OS runners", () => {
const runners = workflow.jobs.build.strategy.matrix.include.map((e: any) => e.os);
expect(runners).toContain("ubuntu-latest");
expect(runners).toContain("macos-latest");
expect(runners).toContain("macos-13");
expect(runners).toContain("windows-latest");
});
it("has required permissions", () => {
expect(workflow.permissions.contents).toBe("write");
expect(workflow.permissions["pull-requests"]).toBe("write");
});
it("includes all required Bun targets", () => {
const targets = workflow.jobs.build.strategy.matrix.include.map((e: any) => e.target);
expect(targets).toContain("bun-linux-x64");
expect(targets).toContain("bun-darwin-arm64");
expect(targets).toContain("bun-darwin-x64");
expect(targets).toContain("bun-windows-x64");
});
it("has a release job that needs the build job", () => {
const releaseJob = workflow.jobs.release;
expect(releaseJob).toBeDefined();
const needs = Array.isArray(releaseJob.needs) ? releaseJob.needs : [releaseJob.needs];
expect(needs).toContain("build");
});
it("generates checksums on all platforms", () => {
expect(content).toContain("sha256sum");
expect(content).toContain("shasum -a 256");
expect(content).toContain("Get-FileHash");
});
it("configures npm registry-url", () => {
const steps = workflow.jobs.release.steps;
const nodeStep = steps.find((s: any) => s.uses?.includes("actions/setup-node"));
expect(nodeStep?.with?.["registry-url"]).toBe("https://registry.npmjs.org");
});
});
describe("Test Release workflow (.github/workflows/test-release.yml)", () => {
let workflow: any;
let content: string;
beforeAll(() => {
const result = loadWorkflow("test-release.yml");
workflow = result.parsed;
content = result.content;
describe("Deleted binary workflows", () => {
it("release.yml no longer exists", () => {
const path = join(workspaceRoot, ".github", "workflows", "release.yml");
expect(existsSync(path)).toBe(false);
});
it("is valid YAML", () => {
expect(workflow).toBeDefined();
expect(typeof workflow).toBe("object");
});
it("has workflow_dispatch trigger", () => {
expect(workflow.on).toHaveProperty("workflow_dispatch");
});
it("includes pnpm install step", () => {
expect(content).toContain("pnpm install");
});
it("includes pnpm build step", () => {
expect(content).toContain("pnpm build");
});
it("includes build:exe step", () => {
expect(content).toContain("build:exe");
});
it("includes smoke test with --help", () => {
expect(content).toContain("--help");
});
it("uploads artifact", () => {
expect(content).toContain("actions/upload-artifact");
});
describe("matrix build strategy", () => {
it("has a build job with strategy.matrix including at least 4 entries", () => {
const buildJob = workflow.jobs.build;
expect(buildJob).toBeDefined();
expect(buildJob.strategy?.matrix?.include?.length).toBeGreaterThanOrEqual(4);
});
it("includes all required OS runners", () => {
const runners = workflow.jobs.build.strategy.matrix.include.map((e: any) => e.os);
expect(runners).toContain("ubuntu-latest");
expect(runners).toContain("macos-latest");
expect(runners).toContain("macos-13");
expect(runners).toContain("windows-latest");
});
it("includes all required Bun targets", () => {
const targets = workflow.jobs.build.strategy.matrix.include.map((e: any) => e.target);
expect(targets).toContain("bun-linux-x64");
expect(targets).toContain("bun-darwin-arm64");
expect(targets).toContain("bun-darwin-x64");
expect(targets).toContain("bun-windows-x64");
});
it("generates checksums on all platforms", () => {
expect(content).toContain("sha256sum");
expect(content).toContain("shasum -a 256");
expect(content).toContain("Get-FileHash");
});
});
});
describe("Code signing — Release workflow", () => {
let content: string;
beforeAll(() => {
const result = loadWorkflow("release.yml");
content = result.content;
});
it("contains macOS signing step referencing sign-macos.sh", () => {
expect(content).toContain("sign-macos.sh");
});
it("contains Windows signing step referencing sign-windows.ps1", () => {
expect(content).toContain("sign-windows.ps1");
});
it("macOS signing step is conditioned on runner.os", () => {
expect(content).toMatch(/if:.*runner\.os\s*==\s*'macOS'/);
});
it("Windows signing step is conditioned on runner.os", () => {
expect(content).toMatch(/if:.*runner\.os\s*==\s*'Windows'/);
});
it("references all required Apple secrets", () => {
const requiredSecrets = [
"APPLE_CERTIFICATE_BASE64",
"APPLE_CERTIFICATE_PASSWORD",
"APPLE_ID",
"APPLE_TEAM_ID",
"APPLE_APP_PASSWORD",
];
for (const secret of requiredSecrets) {
expect(content).toContain(`secrets.${secret}`);
}
});
it("references Windows signing secrets", () => {
expect(content).toContain("secrets.WINDOWS_CERTIFICATE_BASE64");
expect(content).toContain("secrets.WINDOWS_CERTIFICATE_PASSWORD");
});
it("checksums step comes after signing steps", () => {
const signMacosIndex = content.indexOf("sign-macos.sh");
const signWindowsIndex = content.indexOf("sign-windows.ps1");
const checksumIndex = content.indexOf("Generate checksum");
expect(signMacosIndex).toBeLessThan(checksumIndex);
expect(signWindowsIndex).toBeLessThan(checksumIndex);
});
});
describe("Code signing — Test-release workflow", () => {
let content: string;
beforeAll(() => {
const result = loadWorkflow("test-release.yml");
content = result.content;
});
it("has macOS signing step with secret-availability guard", () => {
expect(content).toContain("sign-macos.sh");
expect(content).toMatch(/if:.*APPLE_CERTIFICATE_BASE64\s*!=\s*''/);
});
it("has Windows signing step with secret-availability guard", () => {
expect(content).toContain("sign-windows.ps1");
expect(content).toMatch(/if:.*WINDOWS_CERTIFICATE_BASE64\s*!=\s*''/);
it("test-release.yml no longer exists", () => {
const path = join(workspaceRoot, ".github", "workflows", "test-release.yml");
expect(existsSync(path)).toBe(false);
});
});

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parse } from "yaml";
const workspaceRoot = join(__dirname, "..", "..", "..", "..");
function loadPackageJson(packageDir: string): any {
const path = join(workspaceRoot, "packages", packageDir, "package.json");
return JSON.parse(readFileSync(path, "utf-8"));
}
function loadWorkflowYaml(name: string): any {
const path = join(workspaceRoot, ".github", "workflows", name);
const content = readFileSync(path, "utf-8");
return parse(content);
}
describe("CLI package.json publishing config", () => {
const pkg = loadPackageJson("cli");
it('has "bin" field with hai pointing to ./dist/bin.js', () => {
expect(pkg.bin).toBeDefined();
expect(pkg.bin.hai).toBe("./dist/bin.js");
});
it('has "files" array that includes "dist"', () => {
expect(pkg.files).toBeDefined();
expect(Array.isArray(pkg.files)).toBe(true);
expect(pkg.files).toContain("dist");
});
it("is not private", () => {
expect(pkg.private).not.toBe(true);
});
});
describe("Scoped @hai/* packages publishing config", () => {
const scopedPackages = ["core", "engine", "dashboard"];
for (const name of scopedPackages) {
describe(`@hai/${name}`, () => {
const pkg = loadPackageJson(name);
it('has publishConfig with access "public"', () => {
expect(pkg.publishConfig).toBeDefined();
expect(pkg.publishConfig.access).toBe("public");
});
it('has "files" array', () => {
expect(pkg.files).toBeDefined();
expect(Array.isArray(pkg.files)).toBe(true);
expect(pkg.files).toContain("dist");
});
it("exports point to compiled dist output", () => {
const exports = pkg.exports?.["."];
expect(exports).toBeDefined();
if (typeof exports === "object") {
expect(exports.import).toMatch(/^\.\/dist\//);
} else {
expect(exports).toMatch(/^\.\/dist\//);
}
});
});
}
});
describe("Workflow YAML validity", () => {
it("ci.yml is valid YAML", () => {
const parsed = loadWorkflowYaml("ci.yml");
expect(parsed).toBeDefined();
expect(parsed.name).toBe("CI");
});
it("version.yml is valid YAML", () => {
const parsed = loadWorkflowYaml("version.yml");
expect(parsed).toBeDefined();
expect(parsed.name).toBe("Version & Release");
});
});