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
74 changes: 74 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Release process

This package ships to npm as `prd-taskmaster`. Releases are gated so that an
**auth failure is caught in one second, before any expensive tag/build/publish work** —
not after the build is half-done and `npm publish` hangs on an interactive web-auth wall.

## Release gate order

Run the gates in this exact order. Each one fails closed (non-zero exit) and stops the release:

1. **`release:preflight` (npm auth)** — `npm run release:preflight`
- Confirms a usable npm token exists **before** anything else.
- Runs `scripts/check-npm-auth.mjs`. If no token is found it exits `1` immediately,
*before any network call*, so it can never stall on a browser login.
2. **version-sync** — `npm run version:check`
- `scripts/check-version-sync.js` — every version source-of-truth must agree
(`package.json`, `.claude-plugin/plugin.json`, `prd_taskmaster/__init__.py`).
3. **tag** — `git tag vX.Y.Z && git push --tags`
4. **publish** — `npm publish`

### Automatic enforcement via `prepublishOnly`

`npm publish` runs the package's `prepublishOnly` script first. It now runs **auth, then
version-sync**:

```json
"prepublishOnly": "node scripts/check-npm-auth.mjs && node scripts/check-version-sync.js"
```

So even if you forget to run `release:preflight` by hand, `npm publish` will refuse to
proceed when npm auth is missing/expired — auth is checked **before** the publish does any
real work. This is the fix for the recurring "publish stalls on a browser web-auth timeout"
failure: you now fail fast at the gate instead of mid-publish.

## Auth: token path vs web-auth path

`scripts/check-npm-auth.mjs` looks for a token in two places (pure, offline detection):

### (a) Token path — preferred (non-interactive, no browser)

Either of:

- Set an environment variable:
```bash
export NPM_TOKEN=<token>
```
(The operator keeps an npm token in **Bitwarden** — retrieve it from there.)
- Or add a line to `~/.npmrc`:
```
//registry.npmjs.org/:_authToken=<token>
```

Then re-run `npm run release:preflight` to confirm.

If a token is present, the preflight also runs `npm whoami` as a **soft** confirmation —
a network failure there is only a warning (the token may still be valid), and it never
blocks. The hard fail-closed behaviour only triggers when **no token at all** is found.

### (b) Web-auth path — only if no token is available

Run `npm login` in the operator's **real desktop browser** (already logged in, on their
own residential IP). Do **not** drive the login URL in an automated/headless browser — that
advertises automation and trips Cloudflare/Turnstile "Just a moment" challenges. Approve the
sign-in in one click, then re-run `npm run release:preflight`.

## One-shot release checklist

```bash
npm run release:preflight # 1. auth — fails closed if no token
npm run version:check # 2. versions agree
pytest tests/ # (sanity — full suite stays green)
git tag vX.Y.Z && git push --tags # 3. tag
npm publish # 4. publish (re-runs auth + version-sync via prepublishOnly)
```
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
}
},
"scripts": {
"prepublishOnly": "node scripts/check-version-sync.js",
"prepublishOnly": "node scripts/check-npm-auth.mjs && node scripts/check-version-sync.js",
"release:preflight": "node scripts/check-npm-auth.mjs",
"version:check": "node scripts/check-version-sync.js",
"postinstall": "pip install -r mcp-server/requirements.txt 2>/dev/null || echo 'WARN: Python MCP deps not installed. MCP tools will not start. Run: pip install -r node_modules/prd-taskmaster/mcp-server/requirements.txt'",
"test": "pytest tests/",
Expand Down
160 changes: 160 additions & 0 deletions scripts/check-npm-auth.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Release-auth preflight — FAILS CLOSED before any expensive tag/build/publish work.
*
* The recurring failure this guards: `npm publish` stalls on a browser web-auth
* timeout because nobody checked that npm auth was actually valid FIRST. Wired ahead
* of version-sync in `prepublishOnly`, this refuses to start a publish when no usable
* npm token is present — so the operator finds out in one second, not after the tag/
* build is half-done and the publish hangs on an interactive login.
*
* Design: the detection logic (`resolveNpmAuth`) is a PURE function with no network
* and no filesystem access, so it is unit-testable offline. The CLI wrapper reads the
* real environment + `~/.npmrc`, and only AFTER confirming a token exists does it
* optionally probe the network via `npm whoami` (soft — a network failure is a warning,
* never a crash, and never blocks the no-token fail-closed exit).
*/

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";

const REGISTRY_AUTH_LINE = "//registry.npmjs.org/:_authToken=";

/**
* Pure auth-detection. No network, no filesystem — everything is passed in.
*
* @param {{ env?: Record<string,string|undefined>, npmrc?: string }} input
* env — a process.env-like object; NPM_TOKEN (or NODE_AUTH_TOKEN) means a token.
* npmrc — the raw text of an .npmrc file (may be empty/undefined).
* @returns {{ ok: boolean, method: ("NPM_TOKEN"|"npmrc-token"|null), why: string }}
*/
export function resolveNpmAuth({ env = {}, npmrc = "" } = {}) {
// 1) Environment token wins — this is how CI and the Bitwarden-token path inject auth.
const envToken = env.NPM_TOKEN || env.NODE_AUTH_TOKEN;
if (typeof envToken === "string" && envToken.trim() !== "") {
return {
ok: true,
method: "NPM_TOKEN",
why: "NPM_TOKEN (or NODE_AUTH_TOKEN) is set in the environment.",
};
}

// 2) An //registry.npmjs.org/:_authToken=... line in the npmrc string.
if (typeof npmrc === "string" && npmrc.length > 0) {
const hasAuthToken = npmrc
.split(/\r?\n/)
.map((line) => line.trim())
.some(
(line) =>
!line.startsWith("#") &&
!line.startsWith(";") &&
// anchor on the line start (already trimmed) so a crafted host segment
// like //evil.com//registry.npmjs.org/:_authToken= can't false-pass.
line.startsWith(REGISTRY_AUTH_LINE) &&
// require a non-empty value after the '='
line.slice(REGISTRY_AUTH_LINE.length).trim() !== ""
);
if (hasAuthToken) {
return {
ok: true,
method: "npmrc-token",
why: `Found '${REGISTRY_AUTH_LINE}...' in the provided .npmrc.`,
};
}
}

// 3) Nothing usable.
return {
ok: false,
method: null,
why: "No NPM_TOKEN in the environment and no //registry.npmjs.org/:_authToken line in ~/.npmrc.",
};
}

/** Read a file as text, returning "" if it does not exist / is unreadable. */
function readNpmrc(npmrcPath) {
try {
return fs.readFileSync(npmrcPath, "utf8");
} catch {
return "";
}
}

/** The FAIL banner — documents both remediation paths. Returns the string for testability. */
function failMessage() {
return [
"✖ FAIL: npm release-auth preflight — no usable npm auth found.",
" Refusing to start publish work (tag/build/version-sync) with no valid token.",
"",
" Two ways to fix this BEFORE retrying the release:",
"",
" (a) Token path (preferred — non-interactive, no browser):",
" • export NPM_TOKEN=<token> (the operator keeps an npm token in Bitwarden),",
" OR",
" • add this line to ~/.npmrc:",
` ${REGISTRY_AUTH_LINE}<token>`,
" then re-run: npm run release:preflight",
"",
" (b) Web-auth path (only if no token is available):",
" • run `npm login` in the operator's REAL browser (their own desktop browser,",
" already logged in on their residential IP — not an automated/headless one,",
" which trips Cloudflare/Turnstile). Complete the one-click approval, then",
" re-run: npm run release:preflight",
].join("\n");
}

function main() {
const env = process.env;
const npmrcPath = path.join(os.homedir(), ".npmrc");
const npmrc = readNpmrc(npmrcPath);

const result = resolveNpmAuth({ env, npmrc });

if (!result.ok) {
// FAIL CLOSED — exit BEFORE any network call (no `npm whoami`), so this can never hang.
console.error(failMessage());
console.error(`\n detail: ${result.why}`);
process.exit(1);
}

// A token exists. Report HOW we found it.
const where =
result.method === "NPM_TOKEN"
? "environment (NPM_TOKEN / NODE_AUTH_TOKEN)"
: `npmrc (${npmrcPath})`;
console.log(`✓ npm token present via ${result.method} — ${where}.`);

// OPTIONAL network confirmation. Soft: a failure here is a warning, never a crash,
// because the token may be valid even when whoami can't reach the registry (offline,
// proxy, registry blip). The fail-closed guarantee above does not depend on this.
try {
const who = execFileSync("npm", ["whoami"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 15000,
}).trim();
console.log(`✓ PASS: authenticated to npm registry as '${who}'.`);
} catch (err) {
const reason = (err && (err.stderr || err.message) ? String(err.stderr || err.message) : "")
.trim()
.split(/\r?\n/)[0];
console.warn(
"⚠ WARN: token present but `npm whoami` could not confirm it" +
(reason ? ` (${reason})` : "") +
".\n Treating as a soft warning — proceeding. If the publish 401s, the token may be expired/invalid."
);
console.log("✓ PASS (soft): token present; whoami unverified.");
}
}

// Only run main() when executed directly, so tests can import resolveNpmAuth without
// triggering the CLI side effects (reading ~/.npmrc, process.exit, network).
// Use pathToFileURL so paths with spaces or symlinked invocations still match —
// a naive `file://${argv[1]}` compare silently skips main() (and thus the whole
// auth check) when the install path contains a space or is a symlink.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
119 changes: 119 additions & 0 deletions scripts/check-npm-auth.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Run with: node --test scripts/check-npm-auth.test.mjs
//
// Covers the pure resolveNpmAuth detection (offline, no network) and the CLI
// fail-closed guarantee: the no-token path must exit non-zero WITHOUT touching
// the network (no `npm whoami`), so it can never hang on a publish web-auth wall.

import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { resolveNpmAuth } from "./check-npm-auth.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCRIPT = path.join(__dirname, "check-npm-auth.mjs");

test("resolveNpmAuth: NPM_TOKEN present → ok, method NPM_TOKEN", () => {
const r = resolveNpmAuth({ env: { NPM_TOKEN: "npm_abc123" }, npmrc: "" });
assert.equal(r.ok, true);
assert.equal(r.method, "NPM_TOKEN");
});

test("resolveNpmAuth: NODE_AUTH_TOKEN present → ok, method NPM_TOKEN", () => {
const r = resolveNpmAuth({ env: { NODE_AUTH_TOKEN: "npm_ci_token" }, npmrc: "" });
assert.equal(r.ok, true);
assert.equal(r.method, "NPM_TOKEN");
});

test("resolveNpmAuth: npmrc with _authToken → ok, method npmrc-token", () => {
const npmrc = "//registry.npmjs.org/:_authToken=npm_fromfile\n";
const r = resolveNpmAuth({ env: {}, npmrc });
assert.equal(r.ok, true);
assert.equal(r.method, "npmrc-token");
});

test("resolveNpmAuth: neither → not ok, method null", () => {
const r = resolveNpmAuth({ env: {}, npmrc: "" });
assert.equal(r.ok, false);
assert.equal(r.method, null);
});

test("resolveNpmAuth: empty NPM_TOKEN string is not a token", () => {
const r = resolveNpmAuth({ env: { NPM_TOKEN: " " }, npmrc: "" });
assert.equal(r.ok, false);
assert.equal(r.method, null);
});

test("resolveNpmAuth: commented-out _authToken line does not count", () => {
const npmrc = "# //registry.npmjs.org/:_authToken=npm_disabled\n";
const r = resolveNpmAuth({ env: {}, npmrc });
assert.equal(r.ok, false);
});

test("resolveNpmAuth: _authToken line with empty value does not count", () => {
const npmrc = "//registry.npmjs.org/:_authToken=\n";
const r = resolveNpmAuth({ env: {}, npmrc });
assert.equal(r.ok, false);
});

test("resolveNpmAuth: env token wins even when npmrc has none", () => {
const r = resolveNpmAuth({ env: { NPM_TOKEN: "npm_env" }, npmrc: "registry=https://x\n" });
assert.equal(r.ok, true);
assert.equal(r.method, "NPM_TOKEN");
});

test("CLI: no-token path exits non-zero with a FAIL message and does not hang", () => {
// Temp HOME with an EMPTY .npmrc, env scrubbed of any npm token.
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "npmauth-test-"));
fs.writeFileSync(path.join(tmpHome, ".npmrc"), "", "utf8");

const env = { ...process.env };
delete env.NPM_TOKEN;
delete env.NODE_AUTH_TOKEN;
env.HOME = tmpHome;
env.USERPROFILE = tmpHome; // Windows-safe, harmless on POSIX

const res = spawnSync(process.execPath, [SCRIPT], {
env,
encoding: "utf8",
timeout: 20000, // if it ever hangs on network, the test fails loudly instead
});

fs.rmSync(tmpHome, { recursive: true, force: true });

assert.equal(res.error, undefined, `spawn must not error/timeout: ${res.error}`);
assert.equal(res.status, 1, "no-token CLI must exit 1 (fail closed)");
const out = `${res.stdout}\n${res.stderr}`;
assert.match(out, /FAIL/, "must print a FAIL message");
// Prove it exited BEFORE any whoami network probe.
assert.doesNotMatch(out, /authenticated to npm registry/, "must not have reached whoami");
});

test("CLI: still runs main() (fail-closed) when the script path contains spaces (I1 regression)", () => {
// A naive `file://${argv[1]}` entry guard silently SKIPS main() when the path
// has spaces (percent-encoding mismatch) — which would let a publish proceed
// with NO auth check. pathToFileURL() fixes it. Prove main() still fires here.
const spacesDir = fs.mkdtempSync(path.join(os.tmpdir(), "npm auth spaces-"));
const copied = path.join(spacesDir, "check-npm-auth.mjs");
fs.copyFileSync(SCRIPT, copied);
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "npmauth-home-"));
fs.writeFileSync(path.join(tmpHome, ".npmrc"), "", "utf8");

const env = { ...process.env };
delete env.NPM_TOKEN;
delete env.NODE_AUTH_TOKEN;
env.HOME = tmpHome;
env.USERPROFILE = tmpHome;

const res = spawnSync(process.execPath, [copied], { env, encoding: "utf8", timeout: 20000 });

fs.rmSync(spacesDir, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true });

assert.equal(res.status, 1, "main() must run (exit 1) even from a path containing spaces");
assert.match(`${res.stdout}\n${res.stderr}`, /FAIL/);
});
Loading