-
-
Notifications
You must be signed in to change notification settings - Fork 90
ci(release): add MCP release-candidate dry-run workflow and checks #379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
JSONbored
merged 3 commits into
JSONbored:main
from
Lobster-0429:feat/mcp-release-candidate-dry-run
Jun 4, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| 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(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, "\\$&"); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.