feat: add npm publish readiness (task 0020) #20
@@ -13,7 +13,24 @@ This tarball-and-global-install check is the applicable real-integration surface
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] The packed tarball contains the built CLI, the bin entry, and the Agent Skill markdown, and nothing declares a postinstall script
|
- [x] The packed tarball contains the built CLI, the bin entry, and the Agent Skill markdown, and nothing declares a postinstall script
|
||||||
- [ ] A global install from the tarball puts a working `gitea-axi` on the PATH (dashboard header, `--help`, and `setup` all function)
|
- [x] A global install from the tarball puts a working `gitea-axi` on the PATH (dashboard header, `--help`, and `setup` all function)
|
||||||
- [ ] Package metadata is complete: unscoped name, description, repository URL, license, Node 20+ engines, ESM module type
|
- [x] Package metadata is complete: unscoped name, description, repository URL, license, Node 20+ engines, ESM module type
|
||||||
- [ ] The publish flow (registry target, access, prepack build) is documented or scripted so publishing is a single command
|
- [x] The publish flow (registry target, access, prepack build) is documented or scripted so publishing is a single command
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Most package metadata (unscoped name, description, `type: "module"`, MIT license, `engines.node >=20`, the `gitea-axi` bin, and the `files` allowlist shipping `dist` + `skills`) already existed from earlier tasks; this slice added the missing `repository` (plus conventional `homepage`/`bugs`) and the publish wiring.
|
||||||
|
|
||||||
|
`prepublishOnly` was replaced with `prepack: "npm run build"`.
|
||||||
|
`prepack` fires on both `npm pack` and `npm publish`, so the tarball always carries a freshly built `dist/` — which the packaging smoke test's `npm pack` relies on — whereas `prepublishOnly` only ran on publish.
|
||||||
|
`publishConfig` pins `access: "public"` (so the unscoped package publishes without `--access public`) and `registry: "https://registry.npmjs.org/"` (so a machine with a different default registry still publishes to the right place), making `npm publish` a genuine single command.
|
||||||
|
The flow is also written down in `PUBLISHING.md`.
|
||||||
|
|
||||||
|
The packaging smoke test is its own tier: `vitest.packaging.config.ts` + the `test:pack` script, excluded from the fast `npm test` run (it packs, installs globally, and fetches runtime deps from the registry, so it is slow).
|
||||||
|
Distribution touches no Gitea API, so — exactly as the task frames it — this tarball-and-global-install check stands in for the absent live-Gitea e2e tier rather than being one.
|
||||||
|
|
||||||
|
Deviations / follow-ups:
|
||||||
|
|
||||||
|
- **Process deviation (TDD sequencing):** the test-writer sub-agent ran concurrently with the `package.json`/`PUBLISHING.md` edits, so by its final run it observed GREEN and did not report a clean RED for the metadata/publish facets (it mis-attributed the fields to the prior commit). The pre-edit tree was genuinely RED for those facets (no `repository`, `prepack`, `publishConfig`, or `PUBLISHING.md`); the file-presence and installed-binary facets were already GREEN because the CLI, bin, and bundled skill shipped in task 0018.
|
||||||
|
- **Test robustness fixes the sub-agent made:** the `npm pack --json` output shape differs across npm majors (array vs. object), so the test tolerates both; and the dashboard facet uses a promisified `execFile` rather than `execFileSync` so the in-process fixture Gitea server can answer the CLI's HTTP calls (a synchronous spawn deadlocks the shared event loop).
|
||||||
|
|||||||
38
PUBLISHING.md
Normal file
38
PUBLISHING.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Publishing gitea-axi
|
||||||
|
|
||||||
|
`gitea-axi` is an unscoped, public npm package.
|
||||||
|
Publishing it is a single command.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole flow.
|
||||||
|
Everything the release needs is wired into `package.json`, so no extra flags are required:
|
||||||
|
|
||||||
|
- The `prepack` script runs `npm run build`, so the tarball always carries a freshly compiled `dist/` rather than whatever happened to be on disk.
|
||||||
|
- `publishConfig.access` is `public`, so the unscoped package publishes publicly without `--access public`.
|
||||||
|
- `publishConfig.registry` targets the public npm registry (`https://registry.npmjs.org/`), so a machine whose default registry is set elsewhere still publishes to the right place.
|
||||||
|
- The `files` allowlist ships only `dist/` and `skills/`, so the built CLI and the bundled Agent Skill go out and nothing else does.
|
||||||
|
|
||||||
|
There is deliberately no `postinstall` script.
|
||||||
|
Installing the package delivers the `gitea-axi` binary only; installing the Agent Skill and the session hooks stays an explicit user action behind `gitea-axi setup` and `gitea-axi setup hooks`.
|
||||||
|
|
||||||
|
## Before publishing
|
||||||
|
|
||||||
|
You need to be authenticated to the npm registry (`npm whoami` to check, `npm login` if not) with publish rights to the `gitea-axi` name.
|
||||||
|
|
||||||
|
Bump the version first with `npm version <patch|minor|major>`, which updates `package.json` and creates the release commit and tag.
|
||||||
|
|
||||||
|
## Verifying the packed artifact
|
||||||
|
|
||||||
|
The packaging smoke test packs the real tarball, installs it globally into a throwaway prefix, and drives the installed binary — `--help`, the dashboard header, and `setup` finding the bundled skill:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:pack
|
||||||
|
```
|
||||||
|
|
||||||
|
It builds, packs, and fetches the runtime dependencies from the registry, so it is slower than the unit and integration tiers and is not part of the default `npm test` run.
|
||||||
|
Distribution touches no Gitea API, so this smoke test is the distribution analogue of the live-Gitea end-to-end tier.
|
||||||
17
package.json
17
package.json
@@ -10,18 +10,31 @@
|
|||||||
"bin": {
|
"bin": {
|
||||||
"gitea-axi": "dist/main.js"
|
"gitea-axi": "dist/main.js"
|
||||||
},
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://git.alexion.dev/alexion/gitea-axi.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.alexion.dev/alexion/gitea-axi",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://git.alexion.dev/alexion/gitea-axi/issues"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"skills"
|
"skills"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"prepublishOnly": "npm run build",
|
"prepack": "npm run build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "vitest run --config vitest.e2e.config.ts"
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||||
|
"test:pack": "vitest run --config vitest.packaging.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@toon-format/toon": "^2.3.0",
|
"@toon-format/toon": "^2.3.0",
|
||||||
|
|||||||
172
test/packaging/packaging.test.ts
Normal file
172
test/packaging/packaging.test.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { startFixtureServer } from "../fixture-server.js";
|
||||||
|
|
||||||
|
// The one artifact under test: the packed npm tarball, installed globally into a
|
||||||
|
// throwaway prefix. Packing plus a global install is expensive and identical for
|
||||||
|
// every facet, so it is built once here and the four facets assert against the
|
||||||
|
// shared result.
|
||||||
|
|
||||||
|
const projectRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||||
|
|
||||||
|
let workDir: string;
|
||||||
|
let extractDir: string;
|
||||||
|
let binPath: string;
|
||||||
|
/** The manifest as it ships inside the packed tarball. */
|
||||||
|
let packedManifest: Record<string, unknown>;
|
||||||
|
|
||||||
|
// Unlike the in-process CLI-seam harness (test/harness.ts), which keeps the
|
||||||
|
// environment fully explicit so nothing leaks in, this tier spawns a real
|
||||||
|
// globally-installed binary as a subprocess. That subprocess genuinely needs the
|
||||||
|
// parent env (PATH to resolve node/npm/tar, npm config, etc.), so both helpers
|
||||||
|
// inherit `process.env` on purpose and layer the per-call `env` on top.
|
||||||
|
|
||||||
|
/** Run the globally installed binary, returning its stdout. */
|
||||||
|
function run(args: string[], env: Record<string, string> = {}): string {
|
||||||
|
return execFileSync(binPath, args, { encoding: "utf8", env: { ...process.env, ...env } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the installed binary without blocking this process's event loop, so an
|
||||||
|
* in-process fixture server can answer the CLI's HTTP calls while it runs. (A
|
||||||
|
* synchronous `execFileSync` would freeze the loop the fixture server lives on,
|
||||||
|
* deadlocking the request/response.)
|
||||||
|
*/
|
||||||
|
async function runAsync(args: string[], env: Record<string, string> = {}): Promise<string> {
|
||||||
|
const { stdout } = await execFileAsync(binPath, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
});
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-pack-"));
|
||||||
|
|
||||||
|
// Pack the real tarball. `npm pack --json` reports the tarball filename; the
|
||||||
|
// shape shifted across npm majors (an array of entries on npm <12, an object
|
||||||
|
// keyed by package name on npm 12+), so accept either and pull the one
|
||||||
|
// filename out.
|
||||||
|
const packOutput = execFileSync(
|
||||||
|
"npm",
|
||||||
|
["pack", "--json", "--pack-destination", workDir],
|
||||||
|
{ cwd: projectRoot, encoding: "utf8" },
|
||||||
|
);
|
||||||
|
const packResult = JSON.parse(packOutput) as unknown;
|
||||||
|
const packEntries = Array.isArray(packResult)
|
||||||
|
? (packResult as Array<{ filename: string }>)
|
||||||
|
: Object.values(packResult as Record<string, { filename: string }>);
|
||||||
|
const tarball = join(workDir, packEntries[0]!.filename);
|
||||||
|
|
||||||
|
// Extract to inspect the packed manifest and confirm bundled files. npm nests
|
||||||
|
// everything under `package/`.
|
||||||
|
extractDir = join(workDir, "extract");
|
||||||
|
mkdirSync(extractDir);
|
||||||
|
execFileSync("tar", ["-xzf", tarball, "-C", extractDir]);
|
||||||
|
packedManifest = JSON.parse(
|
||||||
|
readFileSync(join(extractDir, "package", "package.json"), "utf8"),
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Install globally into a throwaway prefix so the binary lands on a PATH-like
|
||||||
|
// bin dir we control.
|
||||||
|
const prefix = join(workDir, "prefix");
|
||||||
|
execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
binPath = join(prefix, "bin", "gitea-axi");
|
||||||
|
}, 300_000);
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (workDir) {
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("npm distribution artifact", () => {
|
||||||
|
it("bundles the built CLI, the bin entry, and the Agent Skill, and declares no postinstall", () => {
|
||||||
|
expect(existsSync(join(extractDir, "package", "dist", "main.js"))).toBe(true);
|
||||||
|
expect(existsSync(join(extractDir, "package", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
|
||||||
|
|
||||||
|
const bin = packedManifest.bin as Record<string, string> | undefined;
|
||||||
|
expect(bin?.["gitea-axi"]).toBe("dist/main.js");
|
||||||
|
|
||||||
|
const scripts = packedManifest.scripts as Record<string, string> | undefined;
|
||||||
|
expect(scripts?.postinstall).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares complete metadata: unscoped name, description, repo, license, engines, ESM type", () => {
|
||||||
|
const name = packedManifest.name as string;
|
||||||
|
expect(name).toBe("gitea-axi");
|
||||||
|
expect(name).not.toContain("@");
|
||||||
|
expect(name).not.toContain("/");
|
||||||
|
|
||||||
|
const description = packedManifest.description as string;
|
||||||
|
expect(typeof description).toBe("string");
|
||||||
|
expect(description.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const repository = packedManifest.repository as string | { url?: string } | undefined;
|
||||||
|
const repositoryUrl = typeof repository === "string" ? repository : repository?.url;
|
||||||
|
expect(repositoryUrl).toContain("gitea-axi");
|
||||||
|
|
||||||
|
expect(packedManifest.license).toBe("MIT");
|
||||||
|
|
||||||
|
const engines = packedManifest.engines as Record<string, string> | undefined;
|
||||||
|
expect(engines?.node).toMatch(/20/);
|
||||||
|
|
||||||
|
expect(packedManifest.type).toBe("module");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scripts and documents the publish flow so publishing is a single command", () => {
|
||||||
|
const scripts = packedManifest.scripts as Record<string, string> | undefined;
|
||||||
|
expect(scripts?.prepack).toBe("npm run build");
|
||||||
|
|
||||||
|
const publishConfig = packedManifest.publishConfig as Record<string, string> | undefined;
|
||||||
|
expect(publishConfig?.access).toBe("public");
|
||||||
|
|
||||||
|
const publishingDoc = join(projectRoot, "PUBLISHING.md");
|
||||||
|
expect(existsSync(publishingDoc)).toBe(true);
|
||||||
|
expect(readFileSync(publishingDoc, "utf8")).toMatch(/npm publish/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a working gitea-axi on the PATH: dashboard header, --help, and setup all function", async () => {
|
||||||
|
expect(existsSync(binPath)).toBe(true);
|
||||||
|
|
||||||
|
expect(run(["--help"])).toContain("usage: gitea-axi");
|
||||||
|
|
||||||
|
// Dashboard header renders against a stubbed Gitea.
|
||||||
|
const server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: "/api/v1/repos/o/r/pulls", body: [] },
|
||||||
|
{ method: "GET", path: "/api/v1/repos/o/r/issues", body: [] },
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
const dashboard = await runAsync([], {
|
||||||
|
GITEA_AXI_API_URL: server.url,
|
||||||
|
GITEA_AXI_REPO: "o/r",
|
||||||
|
GITEA_AXI_TOKEN: "x",
|
||||||
|
});
|
||||||
|
expect(dashboard).toContain("bin:");
|
||||||
|
expect(dashboard).toContain("description: Agent-ergonomic CLI for Gitea");
|
||||||
|
expect(dashboard).toContain("repo: o/r");
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// `setup` installs the Agent Skill into HOME/.claude.
|
||||||
|
const home = mkdtempSync(join(tmpdir(), "gitea-axi-home-"));
|
||||||
|
try {
|
||||||
|
expect(run(["setup"], { HOME: home })).toContain("status: installed");
|
||||||
|
expect(existsSync(join(home, ".claude", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
// Unit + integration tiers: fast, no external dependencies. The end-to-end
|
// Unit + integration tiers: fast, no external dependencies. The end-to-end
|
||||||
// tier under test/e2e needs a live Gitea instance and runs via `test:e2e`.
|
// tier under test/e2e needs a live Gitea instance and runs via `test:e2e`.
|
||||||
include: ["test/**/*.test.ts"],
|
include: ["test/**/*.test.ts"],
|
||||||
exclude: ["test/e2e/**"],
|
exclude: ["test/e2e/**", "test/packaging/**"],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text", "html", "lcov"],
|
reporter: ["text", "html", "lcov"],
|
||||||
|
|||||||
19
vitest.packaging.config.ts
Normal file
19
vitest.packaging.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// The packaging tier: pack the real tarball, install it globally into a
|
||||||
|
// throwaway prefix, then drive the installed binary. It builds, packs, and
|
||||||
|
// fetches runtime deps from the registry, so it is far slower than the fast
|
||||||
|
// tiers and runs on its own via `test:pack`. Distribution touches no Gitea
|
||||||
|
// API, so this smoke test is the distribution analogue of the live-Gitea
|
||||||
|
// e2e tier rather than a member of it.
|
||||||
|
include: ["test/packaging/**/*.test.ts"],
|
||||||
|
testTimeout: 180_000,
|
||||||
|
hookTimeout: 300_000,
|
||||||
|
// Keep the pack/install work in one process: the tarball is built once in a
|
||||||
|
// shared setup and reused across the assertions.
|
||||||
|
fileParallelism: false,
|
||||||
|
passWithNoTests: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user