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
50 changes: 50 additions & 0 deletions .github/workflows/mcp-release-candidate.yml
Original file line number Diff line number Diff line change
@@ -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<package version>)"
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
123 changes: 123 additions & 0 deletions scripts/check-mcp-release-candidate.mjs
Original file line number Diff line number Diff line change
@@ -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;
}

Comment thread
JSONbored marked this conversation as resolved.
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<version>");

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();
35 changes: 35 additions & 0 deletions scripts/mcp-release-candidate-core.d.mts
Original file line number Diff line number Diff line change
@@ -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<string, string> }): TarballCheckResult;
export function checkTokenlessPublish(workflowYaml: string | null | undefined): TokenlessCheckResult;
export function buildReleaseCandidateReport(checks: Record<string, (CheckResult & { tag?: string }) | undefined>): ReleaseCandidateReport;
export function redactSensitive(text: string | null | undefined): string;
169 changes: 169 additions & 0 deletions scripts/mcp-release-candidate-core.mjs
Original file line number Diff line number Diff line change
@@ -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<major>.<minor>.<patch> (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} - <date>" 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<major>.<minor>.<patch> 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<version> 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<version> 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, "\\$&");
}
Loading