From b3622a500b3fd3101abe08b304594d751ac98863 Mon Sep 17 00:00:00 2001 From: web-dev0521 Date: Thu, 4 Jun 2026 04:58:21 -0600 Subject: [PATCH] ci(release): add MCP release-candidate dry-run workflow and checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single, non-publishing preflight maintainers can run before tagging an MCP release, so release mistakes are caught before the public package is touched. - scripts/mcp-release-candidate-core.mjs: pure, deterministic checks — tag format + version match, changelog target-version section, tarball allowlist + secret scan, and tokenless trusted-publishing verification (id-token + provenance, no npm token). redactSensitive() scrubs tokens, npm credentials, GitHub auth, and absolute local paths from any output. - scripts/check-mcp-release-candidate.mjs (npm run mcp:release-candidate): reads the package version, derives or accepts --tag, runs npm pack to gate tarball contents, runs a packed-tarball CLI smoke (install + gittensory-mcp --help), verifies npm-publish.yml stays tokenless, and prints redacted PASS/FAIL plus next steps. Exits non-zero on any failure and never publishes. - .github/workflows/mcp-release-candidate.yml: manual workflow_dispatch dry-run with an optional tag input, contents:read only. - test/unit/mcp-release-candidate.test.ts: success fixture plus failure fixtures for missing changelog section, unexpected/secret-bearing tarball files, and tag/package mismatch; tokenless-config and log-redaction/public-language invariants. Global coverage unchanged and above the 97% gate (branches 97.02%, lines 99.65%, statements 99.07%, functions 98.40%); no src behavior changes. --- .github/workflows/mcp-release-candidate.yml | 50 +++++ package.json | 1 + scripts/check-mcp-release-candidate.mjs | 123 ++++++++++++ scripts/mcp-release-candidate-core.d.mts | 35 ++++ scripts/mcp-release-candidate-core.mjs | 169 +++++++++++++++++ test/unit/mcp-release-candidate.test.ts | 195 ++++++++++++++++++++ 6 files changed, 573 insertions(+) create mode 100644 .github/workflows/mcp-release-candidate.yml create mode 100644 scripts/check-mcp-release-candidate.mjs create mode 100644 scripts/mcp-release-candidate-core.d.mts create mode 100644 scripts/mcp-release-candidate-core.mjs create mode 100644 test/unit/mcp-release-candidate.test.ts diff --git a/.github/workflows/mcp-release-candidate.yml b/.github/workflows/mcp-release-candidate.yml new file mode 100644 index 0000000000..97c455b288 --- /dev/null +++ b/.github/workflows/mcp-release-candidate.yml @@ -0,0 +1,50 @@ +name: MCP Release Candidate Dry-Run + +# Manual, non-publishing preflight: validates package version, tag format, +# changelog section, tarball allowlist + secret scan, packed CLI smoke, and the +# tokenless trusted-publishing config before a maintainer pushes a release tag. + +on: + workflow_dispatch: + inputs: + tag: + description: "Intended release tag (defaults to mcp-v)" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: mcp-release-candidate-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + dry-run: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Release-candidate dry-run (no publish) + env: + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [ -n "${INPUT_TAG}" ]; then + npm run mcp:release-candidate -- --tag "${INPUT_TAG}" + else + npm run mcp:release-candidate + fi diff --git a/package.json b/package.json index 447cd809c7..5895666cbc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "changelog:check:root": "node scripts/check-changelog.mjs --root", "changelog:check:mcp": "node scripts/check-changelog.mjs --mcp", "mcp:release-due": "node scripts/check-mcp-release-due.mjs --json", + "mcp:release-candidate": "node scripts/check-mcp-release-candidate.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", "test:unit": "vitest run test/unit", diff --git a/scripts/check-mcp-release-candidate.mjs b/scripts/check-mcp-release-candidate.mjs new file mode 100644 index 0000000000..36cb5b83bc --- /dev/null +++ b/scripts/check-mcp-release-candidate.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { + buildReleaseCandidateReport, + checkChangelog, + checkTag, + checkTarball, + checkTokenlessPublish, + expectedReleaseTag, + redactSensitive, +} from "./mcp-release-candidate-core.mjs"; + +const PACKAGE_DIR = "packages/gittensory-mcp"; +const WORKSPACE = "@jsonbored/gittensory-mcp"; +const PUBLISH_WORKFLOW = ".github/workflows/npm-publish.yml"; +const onWindows = process.platform === "win32"; + +function arg(name) { + const flag = `--${name}`; + const index = process.argv.indexOf(flag); + if (index !== -1 && index + 1 < process.argv.length) return process.argv[index + 1]; + return null; +} + +const wantsJson = process.argv.includes("--json"); + +function run(command, args, options = {}) { + // shell:true on Windows so `npm`/`npx` (.cmd shims) resolve; output is captured, never streamed raw. + return spawnSync(command, args, { encoding: "utf8", shell: onWindows, ...options }); +} + +function readMaybe(path) { + return existsSync(path) ? readFileSync(path, "utf8") : null; +} + +function packageVersion() { + try { + return JSON.parse(readFileSync(join(PACKAGE_DIR, "package.json"), "utf8")).version ?? null; + } catch { + return null; + } +} + +function tarballFileCheck() { + const result = run("npm", ["pack", "--workspace", WORKSPACE, "--dry-run", "--json"]); + if (result.status !== 0 || !result.stdout) { + return { check: { ok: false, code: "tarball_unsafe", message: "Could not compute the package file list via npm pack --dry-run." } }; + } + const files = JSON.parse(result.stdout)[0].files.map((file) => file.path); + const contentsByFile = {}; + for (const file of files) { + const full = join(PACKAGE_DIR, file); + if (existsSync(full)) contentsByFile[file] = readFileSync(full, "utf8"); + } + return { check: checkTarball({ files, contentsByFile }) }; +} + +function packedCliSmoke() { + const build = run("npm", ["run", "build:mcp"]); + if (build.status !== 0) { + return { ok: false, code: "cli_smoke_failed", message: "npm run build:mcp failed before the packed CLI smoke." }; + } + const pack = run("npm", ["pack", "--workspace", WORKSPACE, "--json"]); + if (pack.status !== 0 || !pack.stdout) { + return { ok: false, code: "cli_smoke_failed", message: "npm pack failed while preparing the packed CLI smoke." }; + } + const filename = JSON.parse(pack.stdout)[0].filename; + const tarball = join(process.cwd(), filename); + let temp = null; + try { + temp = mkdtempSync(join(tmpdir(), "mcp-rc-")); + if (run("npm", ["--prefix", temp, "init", "-y"]).status !== 0) { + return { ok: false, code: "cli_smoke_failed", message: "Could not initialize a temp project for the packed CLI smoke." }; + } + if (run("npm", ["--prefix", temp, "install", tarball]).status !== 0) { + return { ok: false, code: "cli_smoke_failed", message: "Installing the packed tarball into a temp project failed." }; + } + const binName = onWindows ? "gittensory-mcp.cmd" : "gittensory-mcp"; + const bin = join(temp, "node_modules", ".bin", binName); + const smoke = run(bin, ["--help"]); + if (smoke.status !== 0) { + return { ok: false, code: "cli_smoke_failed", message: "Packed gittensory-mcp --help did not exit cleanly." }; + } + return { ok: true, code: "cli_smoke_ok", message: "Packed gittensory-mcp --help runs cleanly from the installed tarball." }; + } finally { + if (temp) rmSync(temp, { recursive: true, force: true }); + rmSync(tarball, { force: true }); + } +} + +function emit(line) { + process.stdout.write(`${redactSensitive(line)}\n`); +} + +function main() { + const version = packageVersion(); + const tag = arg("tag") ?? (version ? expectedReleaseTag(version) : "mcp-v"); + + const tagCheck = { ...checkTag({ tag, packageVersion: version }), tag }; + const changelogCheck = checkChangelog({ changelog: readMaybe(join(PACKAGE_DIR, "CHANGELOG.md")), version: version ?? "" }); + const { check: tarball } = tarballFileCheck(); + const tokenless = checkTokenlessPublish(readMaybe(PUBLISH_WORKFLOW)); + const cliSmoke = packedCliSmoke(); + + const report = buildReleaseCandidateReport({ tag: tagCheck, changelog: changelogCheck, tarball, cliSmoke, tokenless }); + + if (wantsJson) { + process.stdout.write(`${redactSensitive(JSON.stringify(report, null, 2))}\n`); + } else { + emit(`MCP release-candidate dry-run for ${tag} (no publish attempted)`); + for (const check of report.checks) emit(` ${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.message}`); + emit("Next steps:"); + for (const step of report.nextSteps) emit(` - ${step}`); + emit(report.ok ? "Release candidate is SAFE to tag." : "Release candidate is NOT safe to tag yet."); + } + + process.exit(report.ok ? 0 : 1); +} + +main(); diff --git a/scripts/mcp-release-candidate-core.d.mts b/scripts/mcp-release-candidate-core.d.mts new file mode 100644 index 0000000000..2d354e364d --- /dev/null +++ b/scripts/mcp-release-candidate-core.d.mts @@ -0,0 +1,35 @@ +export const RELEASE_TAG_PATTERN: RegExp; + +export type CheckResult = { + ok: boolean; + code: string; + message: string; +}; + +export type TarballCheckResult = CheckResult & { + unexpected: string[]; + secretFiles: string[]; +}; + +export type TokenlessCheckResult = CheckResult & { + issues: string[]; +}; + +export type ReleaseCandidateReport = { + ok: boolean; + checks: Array<{ name: string; ok: boolean; code: string; message: string }>; + failures: Array<{ name: string; ok: boolean; code: string; message: string }>; + nextSteps: string[]; +}; + +export function parseReleaseTag(tag: string | null | undefined): { valid: boolean; version: string | null }; +export function expectedReleaseTag(version: string): string; +export function checkTag(input: { tag: string | null | undefined; packageVersion: string | null | undefined }): CheckResult; +export function changelogHasVersionSection(changelog: string | null | undefined, version: string | null | undefined): boolean; +export function checkChangelog(input: { changelog: string | null | undefined; version: string }): CheckResult; +export function unexpectedTarballFiles(files: string[] | null | undefined): string[]; +export function fileLooksLikeSecret(content: string | null | undefined): boolean; +export function checkTarball(input: { files: string[] | null | undefined; contentsByFile?: Record }): TarballCheckResult; +export function checkTokenlessPublish(workflowYaml: string | null | undefined): TokenlessCheckResult; +export function buildReleaseCandidateReport(checks: Record): ReleaseCandidateReport; +export function redactSensitive(text: string | null | undefined): string; diff --git a/scripts/mcp-release-candidate-core.mjs b/scripts/mcp-release-candidate-core.mjs new file mode 100644 index 0000000000..497d7db38c --- /dev/null +++ b/scripts/mcp-release-candidate-core.mjs @@ -0,0 +1,169 @@ +import { normalizeNewlines } from "./mcp-release-core.mjs"; + +/** + * Pure, deterministic checks for the MCP release-candidate dry-run. + * + * Every function here is side-effect free so it can be unit tested with fixtures + * and reused by the CLI runner. None of these functions read tokens, npm + * credentials, GitHub auth, environment dumps, or absolute local paths; the + * {@link redactSensitive} helper scrubs any such content before it is printed. + * + * The release tag format mirrors the publish workflow trigger (`mcp-v*.*.*`) and + * the changelog section format produced by {@link renderReleaseSection}. + */ + +export const RELEASE_TAG_PATTERN = /^mcp-v(\d+)\.(\d+)\.(\d+)$/; + +// Kept in sync with scripts/check-mcp-package.mjs and the publish workflow tarball gate. +const ALLOWED_FILE_PATTERNS = [ + /^bin\/gittensory-mcp\.js$/, + /^lib\/local-branch\.js$/, + /^scripts\/gittensor-score-preview\.(mjs|py)$/, + /^package\.json$/, + /^README\.md$/, + /^CHANGELOG\.md$/, + /^LICENSE$/, +]; +const FORBIDDEN_PATH_PATTERN = /(^|\/)(\.dev\.vars|\.env|\.npmrc|.*\.pem|.*private.*key.*|.*secret.*)$/i; +const SECRET_CONTENT_PATTERN = /(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|gts_[0-9a-f]{64}|[A-Z0-9_]*(TOKEN|SECRET|PRIVATE_KEY)=)/; +const NPM_TOKEN_PATTERN = /(NODE_AUTH_TOKEN|NPM_TOKEN|secrets\.NPM[A-Z_]*|_authToken|npm_[A-Za-z0-9]{20,})/; + +/** Parse a release tag, returning whether it is well-formed and its semver. */ +export function parseReleaseTag(tag) { + const match = RELEASE_TAG_PATTERN.exec(String(tag ?? "").trim()); + if (!match) return { valid: false, version: null }; + return { valid: true, version: `${match[1]}.${match[2]}.${match[3]}` }; +} + +/** The canonical tag for a package version. */ +export function expectedReleaseTag(version) { + return `mcp-v${version}`; +} + +/** Verify the intended tag is well-formed and matches the package version. */ +export function checkTag({ tag, packageVersion }) { + const parsed = parseReleaseTag(tag); + if (!parsed.valid) { + return { ok: false, code: "tag_format_invalid", message: `Release tag "${tag}" must be mcp-v.. (for example mcp-v${packageVersion ?? "0.0.0"}).` }; + } + if (!packageVersion) { + return { ok: false, code: "package_version_missing", message: "Could not read the MCP package version to compare against the tag." }; + } + if (parsed.version !== packageVersion) { + return { ok: false, code: "tag_version_mismatch", message: `Release tag ${tag} (${parsed.version}) does not match packages/gittensory-mcp/package.json version ${packageVersion}.` }; + } + return { ok: true, code: "tag_ok", message: `Release tag ${tag} matches package version ${packageVersion}.` }; +} + +/** Whether the changelog contains a real, dated section for the target version. */ +export function changelogHasVersionSection(changelog, version) { + if (!changelog || !version) return false; + const pattern = new RegExp(`^## mcp-v${escapeRegExp(version)} - \\S`, "m"); + return pattern.test(normalizeNewlines(changelog)); +} + +/** Verify the MCP changelog has a target-version section. */ +export function checkChangelog({ changelog, version }) { + if (changelogHasVersionSection(changelog, version)) { + return { ok: true, code: "changelog_ok", message: `MCP changelog has a dated section for mcp-v${version}.` }; + } + return { ok: false, code: "changelog_section_missing", message: `MCP changelog is missing a "## mcp-v${version} - " section.` }; +} + +/** Files that fall outside the publish allowlist (unexpected or forbidden). */ +export function unexpectedTarballFiles(files) { + return (files ?? []) + .map((file) => String(file)) + .filter((file) => FORBIDDEN_PATH_PATTERN.test(file) || !ALLOWED_FILE_PATTERNS.some((pattern) => pattern.test(file))); +} + +/** Whether a file's content carries secret-like material. */ +export function fileLooksLikeSecret(content) { + return SECRET_CONTENT_PATTERN.test(String(content ?? "")); +} + +/** Verify the packed tarball only contains allowlisted files with no secret-like content. */ +export function checkTarball({ files, contentsByFile }) { + const unexpected = unexpectedTarballFiles(files); + const secretFiles = Object.entries(contentsByFile ?? {}) + .filter(([, content]) => fileLooksLikeSecret(content)) + .map(([file]) => file) + .sort(); + const ok = unexpected.length === 0 && secretFiles.length === 0; + const problems = []; + if (unexpected.length > 0) problems.push(`unexpected file(s): ${unexpected.join(", ")}`); + if (secretFiles.length > 0) problems.push(`secret-like content in: ${secretFiles.join(", ")}`); + return { + ok, + code: ok ? "tarball_ok" : "tarball_unsafe", + message: ok + ? `Tarball contents are within the publish allowlist with no secret-like content (${(files ?? []).length} file(s)).` + : `Tarball is unsafe to publish — ${problems.join("; ")}.`, + unexpected, + secretFiles, + }; +} + +/** Verify the publish workflow uses tokenless trusted publishing (OIDC + provenance, no npm token). */ +export function checkTokenlessPublish(workflowYaml) { + const yaml = String(workflowYaml ?? ""); + const issues = []; + if (!/id-token:\s*write/.test(yaml)) issues.push("publish job is missing 'id-token: write' for trusted publishing"); + if (!/--provenance\b/.test(yaml)) issues.push("publish step is missing '--provenance'"); + if (NPM_TOKEN_PATTERN.test(yaml)) issues.push("publish workflow references an npm auth token — trusted publishing must stay tokenless"); + const ok = issues.length === 0; + return { + ok, + code: ok ? "publish_tokenless" : "publish_token_risk", + issues, + message: ok + ? "Publish workflow uses tokenless trusted publishing (id-token + provenance, no npm token)." + : `Publish workflow provenance/tokenless config needs attention — ${issues.join("; ")}.`, + }; +} + +const REMEDIATION = { + tag_format_invalid: "Use an mcp-v.. tag that matches the package version.", + package_version_missing: "Restore a valid version in packages/gittensory-mcp/package.json.", + tag_version_mismatch: "Align the tag with packages/gittensory-mcp/package.json (and the CLI packageVersion) before tagging.", + changelog_section_missing: "Run npm run changelog:mcp and commit the generated mcp-v changelog section.", + tarball_unsafe: "Remove unexpected or secret-bearing files from the package and rerun the dry-run.", + cli_smoke_failed: "Fix the packed CLI so `gittensory-mcp --help` exits cleanly before tagging.", + publish_token_risk: "Restore tokenless trusted publishing (id-token: write + --provenance, no npm token) in npm-publish.yml.", +}; + +/** Aggregate individual check results into a pass/fail report with next steps. */ +export function buildReleaseCandidateReport(checks) { + const entries = Object.entries(checks) + .filter(([, result]) => result && typeof result === "object") + .map(([name, result]) => ({ name, ok: Boolean(result.ok), code: result.code, message: result.message })); + const failures = entries.filter((entry) => !entry.ok); + const ok = failures.length === 0; + const nextSteps = ok + ? [ + "Release candidate looks safe to tag.", + `Create and push ${checks.tag?.tag ?? "the mcp-v tag"} to start the tokenless publish workflow.`, + "No publish was attempted by this dry-run.", + ] + : [ + ...failures.map((failure) => REMEDIATION[failure.code] ?? `Resolve: ${failure.message}`), + "Re-run the release-candidate dry-run; do not tag until it passes.", + ]; + return { ok, checks: entries, failures, nextSteps }; +} + +/** Scrub tokens, npm credentials, GitHub auth, and absolute local paths from any log line. */ +export function redactSensitive(text) { + return String(text ?? "") + .replace(/gh[pousr]_[A-Za-z0-9_]+/g, "[redacted-token]") + .replace(/github_pat_[A-Za-z0-9_]+/g, "[redacted-token]") + .replace(/gts_[0-9a-f]{64}/g, "[redacted-token]") + .replace(/npm_[A-Za-z0-9]{20,}/g, "[redacted-token]") + .replace(/\/\/registry\.npmjs\.org\/:_authToken=\S+/g, "//registry.npmjs.org/:_authToken=[redacted]") + .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY))=\S+/g, "$1=[redacted]") + .replace(/(?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\)[^\s"';]*/g, "[local-path]"); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/test/unit/mcp-release-candidate.test.ts b/test/unit/mcp-release-candidate.test.ts new file mode 100644 index 0000000000..2cc37a005c --- /dev/null +++ b/test/unit/mcp-release-candidate.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { + buildReleaseCandidateReport, + changelogHasVersionSection, + checkChangelog, + checkTag, + checkTarball, + checkTokenlessPublish, + expectedReleaseTag, + fileLooksLikeSecret, + parseReleaseTag, + redactSensitive, + unexpectedTarballFiles, +} from "../../scripts/mcp-release-candidate-core.mjs"; + +const FORBIDDEN_PUBLIC_LANGUAGE = /\b(wallet|hotkey|coldkey|raw trust|trust score|payout|reward estimate|farming|private reviewability|public score estimate)\b/i; + +// Mirrors the allowlisted shape of the published tarball. +const ALLOWED_FILES = ["bin/gittensory-mcp.js", "lib/local-branch.js", "scripts/gittensor-score-preview.mjs", "package.json", "README.md", "CHANGELOG.md", "LICENSE"]; +const CHANGELOG = "# Changelog\n\n## mcp-v0.4.0 - 2026-06-02\n\n### Features\n- Add a thing\n"; + +// A tokenless trusted-publishing workflow fixture (same shape as npm-publish.yml). +const TOKENLESS_WORKFLOW = [ + "permissions:", + " contents: read", + " id-token: write", + " - name: Publish with npm trusted publishing", + " run: npx -y npm@11.15.0 publish --workspace @jsonbored/gittensory-mcp --access public --provenance", +].join("\n"); + +describe("parseReleaseTag / checkTag", () => { + it("accepts a well-formed tag and extracts the version", () => { + expect(parseReleaseTag("mcp-v0.4.0")).toEqual({ valid: true, version: "0.4.0" }); + expect(expectedReleaseTag("0.4.0")).toBe("mcp-v0.4.0"); + }); + + it("rejects malformed tag/version assumptions", () => { + for (const bad of ["v0.4.0", "mcp-0.4.0", "mcp-v0.4", "mcp-v0.4.0-rc.1", "release-1", "", null]) { + expect(parseReleaseTag(bad).valid).toBe(false); + } + expect(checkTag({ tag: "v0.4.0", packageVersion: "0.4.0" })).toMatchObject({ ok: false, code: "tag_format_invalid" }); + }); + + it("passes when the tag matches the package version", () => { + expect(checkTag({ tag: "mcp-v0.4.0", packageVersion: "0.4.0" })).toMatchObject({ ok: true, code: "tag_ok" }); + }); + + it("fails on a tag/package version mismatch", () => { + const result = checkTag({ tag: "mcp-v0.5.0", packageVersion: "0.4.0" }); + expect(result).toMatchObject({ ok: false, code: "tag_version_mismatch" }); + expect(result.message).toMatch(/0\.5\.0.*0\.4\.0/); + }); + + it("fails when the package version cannot be read", () => { + expect(checkTag({ tag: "mcp-v0.4.0", packageVersion: null })).toMatchObject({ ok: false, code: "package_version_missing" }); + }); +}); + +describe("checkChangelog", () => { + it("passes when a dated target-version section exists (success fixture)", () => { + expect(changelogHasVersionSection(CHANGELOG, "0.4.0")).toBe(true); + expect(checkChangelog({ changelog: CHANGELOG, version: "0.4.0" })).toMatchObject({ ok: true, code: "changelog_ok" }); + }); + + it("fails when the target-version section is missing (failure fixture)", () => { + expect(checkChangelog({ changelog: CHANGELOG, version: "0.5.0" })).toMatchObject({ ok: false, code: "changelog_section_missing" }); + // A header without a date does not count as a real section. + expect(changelogHasVersionSection("# Changelog\n\n## mcp-v0.5.0\n", "0.5.0")).toBe(false); + expect(checkChangelog({ changelog: "", version: "0.4.0" })).toMatchObject({ ok: false }); + }); +}); + +describe("checkTarball", () => { + it("passes for an allowlisted file set with no secret-like content (success fixture)", () => { + const result = checkTarball({ files: ALLOWED_FILES, contentsByFile: { "README.md": "# gittensory-mcp", "package.json": "{}" } }); + expect(result).toMatchObject({ ok: true, code: "tarball_ok" }); + expect(result.unexpected).toEqual([]); + }); + + it("fails when the tarball includes an unexpected file (failure fixture)", () => { + expect(unexpectedTarballFiles([...ALLOWED_FILES, ".npmrc"])).toEqual([".npmrc"]); + const result = checkTarball({ files: [...ALLOWED_FILES, "src/secret-notes.ts"] }); + expect(result).toMatchObject({ ok: false, code: "tarball_unsafe" }); + expect(result.unexpected).toContain("src/secret-notes.ts"); + }); + + it("fails when a packaged file carries secret-like content (failure fixture)", () => { + expect(fileLooksLikeSecret("NPM_TOKEN=abc123")).toBe(true); + expect(fileLooksLikeSecret("just docs")).toBe(false); + const result = checkTarball({ files: ALLOWED_FILES, contentsByFile: { "README.md": "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }); + expect(result).toMatchObject({ ok: false, code: "tarball_unsafe" }); + expect(result.secretFiles).toEqual(["README.md"]); + }); +}); + +describe("checkTokenlessPublish", () => { + it("passes for a tokenless trusted-publishing workflow", () => { + expect(checkTokenlessPublish(TOKENLESS_WORKFLOW)).toMatchObject({ ok: true, code: "publish_tokenless", issues: [] }); + }); + + it("fails when an npm auth token is referenced", () => { + const withToken = `${TOKENLESS_WORKFLOW}\n env:\n NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}`; + const result = checkTokenlessPublish(withToken); + expect(result.ok).toBe(false); + expect(result.issues.join(" ")).toMatch(/token/i); + }); + + it("fails when id-token or provenance is missing", () => { + expect(checkTokenlessPublish("permissions:\n contents: read\n run: npm publish --provenance").issues.join(" ")).toMatch(/id-token/); + expect(checkTokenlessPublish("permissions:\n id-token: write\n run: npm publish").issues.join(" ")).toMatch(/provenance/); + }); +}); + +describe("buildReleaseCandidateReport", () => { + const passing = { + tag: { ok: true, code: "tag_ok", message: "ok", tag: "mcp-v0.4.0" }, + changelog: { ok: true, code: "changelog_ok", message: "ok" }, + tarball: { ok: true, code: "tarball_ok", message: "ok" }, + cliSmoke: { ok: true, code: "cli_smoke_ok", message: "ok" }, + tokenless: { ok: true, code: "publish_tokenless", message: "ok" }, + }; + + it("reports safe-to-tag with non-publishing next steps when every check passes", () => { + const report = buildReleaseCandidateReport(passing); + expect(report.ok).toBe(true); + expect(report.failures).toEqual([]); + expect(report.nextSteps.join(" ")).toMatch(/mcp-v0\.4\.0/); + expect(report.nextSteps.join(" ")).toMatch(/no publish was attempted/i); + }); + + it("aggregates failures with remediation and never tells the maintainer to publish", () => { + const report = buildReleaseCandidateReport({ + ...passing, + changelog: { ok: false, code: "changelog_section_missing", message: "missing" }, + tarball: { ok: false, code: "tarball_unsafe", message: "unsafe" }, + }); + expect(report.ok).toBe(false); + expect(report.failures.map((f) => f.code)).toEqual(expect.arrayContaining(["changelog_section_missing", "tarball_unsafe"])); + expect(report.nextSteps.join(" ")).toMatch(/changelog:mcp/); + expect(report.nextSteps.join(" ")).toMatch(/do not tag until it passes/i); + expect(JSON.stringify(report)).not.toMatch(/\bnpm publish\b/); + }); +}); + +describe("redactSensitive (dry-run log safety)", () => { + it("scrubs tokens, npm credentials, GitHub auth, and absolute local paths", () => { + const dirty = [ + "token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + "github_pat_11AAAAAAA0bbbbbbbbbb_cccccccc", + "NODE_AUTH_TOKEN=npm_abcdefghijklmnopqrstuvwxyz0123", + "//registry.npmjs.org/:_authToken=npm_secretvalue000000000000", + "path /Users/maintainer/.npmrc and C:\\Users\\maintainer\\secret", + ].join("\n"); + const clean = redactSensitive(dirty); + expect(clean).not.toMatch(/ghp_[A-Z0-9]/i); + expect(clean).not.toMatch(/github_pat_/); + expect(clean).not.toMatch(/npm_[A-Za-z0-9]{20,}/); + expect(clean).not.toMatch(/_authToken=npm_/); + expect(clean).not.toMatch(/\/Users\/maintainer|C:\\Users\\maintainer/); + expect(clean).toContain("[redacted-token]"); + expect(clean).toContain("[local-path]"); + }); + + it("keeps redaction idempotent and leaves safe text intact", () => { + const safe = "MCP release-candidate dry-run for mcp-v0.4.0 (no publish attempted)"; + expect(redactSensitive(safe)).toBe(safe); + expect(redactSensitive(redactSensitive("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"))).toBe("[redacted-token]"); + }); +}); + +describe("public-output safety", () => { + it("never emits forbidden private/compensation language in any check message or next step", () => { + const reports = [ + buildReleaseCandidateReport({ + tag: checkTag({ tag: "mcp-v0.4.0", packageVersion: "0.4.0" }), + changelog: checkChangelog({ changelog: CHANGELOG, version: "0.4.0" }), + tarball: checkTarball({ files: ALLOWED_FILES }), + cliSmoke: { ok: true, code: "cli_smoke_ok", message: "ok" }, + tokenless: checkTokenlessPublish(TOKENLESS_WORKFLOW), + }), + buildReleaseCandidateReport({ + tag: checkTag({ tag: "bad", packageVersion: "0.4.0" }), + changelog: checkChangelog({ changelog: "", version: "0.4.0" }), + tarball: checkTarball({ files: [...ALLOWED_FILES, ".env"], contentsByFile: { ".env": "API_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } }), + cliSmoke: { ok: false, code: "cli_smoke_failed", message: "failed" }, + tokenless: checkTokenlessPublish("run: npm publish"), + }), + ]; + for (const report of reports) { + const blob = redactSensitive(JSON.stringify(report)); + expect(blob).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + expect(blob).not.toMatch(/ghp_[A-Za-z0-9]{20,}/); + } + }); +});