diff --git a/.github/workflows/publish-manifests.yml b/.github/workflows/publish-manifests.yml new file mode 100644 index 00000000..868aa6aa --- /dev/null +++ b/.github/workflows/publish-manifests.yml @@ -0,0 +1,97 @@ +name: Publish Manifests + +permissions: + contents: read + +on: + release: + types: [published] + workflow_dispatch: + inputs: + ref: + description: "Override ref/tag to publish (defaults to release tag)" + required: false + type: string + +jobs: + publish: + runs-on: ubuntu-latest + env: + WEBSITE_REPO: getsentry/xcodebuildmcp.com + WEBSITE_BRANCH: main + TARGET_FILE: app/docs/_data/generated/manifests.json + steps: + - name: Checkout source repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.event.release.tag_name || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Install dependencies + run: npm install --ignore-scripts + + - name: Fail if deploy key is missing + env: + DEPLOY_KEY: ${{ secrets.XCODEBUILDMCP_WEBSITE_DEPLOY_KEY }} + run: | + set -euo pipefail + if [ -z "$DEPLOY_KEY" ]; then + echo "XCODEBUILDMCP_WEBSITE_DEPLOY_KEY is required to publish manifests." >&2 + exit 1 + fi + + - name: Configure SSH for website repository + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.XCODEBUILDMCP_WEBSITE_DEPLOY_KEY }} + + - name: Clone website repository + run: | + set -euo pipefail + git clone "git@github.com:${WEBSITE_REPO}.git" website-repo + cd website-repo + git checkout "$WEBSITE_BRANCH" + git pull --ff-only origin "$WEBSITE_BRANCH" + + - name: Resolve ref + id: ref + env: + INPUT_REF: ${{ inputs.ref }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + REF="${INPUT_REF:-$RELEASE_TAG}" + if [ -z "$REF" ]; then + echo "No ref available (workflow_dispatch without input on a non-release event)." >&2 + exit 1 + fi + echo "value=$REF" >> "$GITHUB_OUTPUT" + + - name: Generate manifests.json into website repo + env: + REF: ${{ steps.ref.outputs.value }} + run: | + set -euo pipefail + node scripts/build-website-manifest.mjs \ + --out="website-repo/${TARGET_FILE}" \ + --ref="$REF" + + - name: Commit and push website update + env: + REF: ${{ steps.ref.outputs.value }} + run: | + set -euo pipefail + cd website-repo + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "$TARGET_FILE" + if git diff --cached --quiet; then + echo "Manifest publish target already up to date." + exit 0 + fi + git commit -m "Publish manifests from ${GITHUB_REPOSITORY}@${REF}" + git push origin "$WEBSITE_BRANCH" diff --git a/package.json b/package.json index a9d3c96b..bae3ac2b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "repro:mcp-lifecycle-leak": "npm run build && npx tsx scripts/repro-mcp-lifecycle-leak.ts", "repro:sentry-mcp-teardown": "cd repros/sentry-mcp-teardown && npm run harness", "bundle:axe": "scripts/bundle-axe.sh", + "build:website-manifest": "node scripts/build-website-manifest.mjs", "package:macos": "scripts/package-macos-portable.sh", "package:macos:universal": "scripts/package-macos-portable.sh --universal", "verify:portable": "scripts/verify-portable-install.sh", diff --git a/scripts/build-website-manifest.mjs b/scripts/build-website-manifest.mjs new file mode 100755 index 00000000..2db824b0 --- /dev/null +++ b/scripts/build-website-manifest.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * Build the manifests.json snapshot consumed by getsentry/xcodebuildmcp.com. + * + * Reads manifests/workflows/*.yaml, manifests/tools/*.yaml, and package.json + * from this repo, normalises them into the shape the website expects, and + * writes JSON to the path passed via --out=. + * + * Usage: + * node scripts/build-website-manifest.mjs --out= [--ref=] + * + * The output shape mirrors scripts/sync-xcodebuildmcp-manifests.mjs in the + * website repo so the publish path can be flipped from pull (Monday cron PR) + * to push (release-time direct commit) without changing consumers. + */ + +import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); + +function parseArgs(argv) { + const args = { out: undefined, ref: undefined }; + for (const a of argv) { + if (a.startsWith("--out=")) args.out = a.slice("--out=".length); + else if (a.startsWith("--ref=")) args.ref = a.slice("--ref=".length); + else if (a === "--help" || a === "-h") args.help = true; + else { + console.error(`Unknown argument: ${a}`); + process.exit(2); + } + } + return args; +} + +function usage() { + console.error( + "Usage: build-website-manifest.mjs --out= [--ref=]\n" + + " --out Output JSON file path (required).\n" + + " --ref Ref/tag to record in the snapshot. Defaults to v.", + ); +} + +async function loadYamlDir(dir) { + const entries = (await readdir(dir)).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")); + return Promise.all( + entries.map(async (f) => parseYaml(await readFile(path.join(dir, f), "utf8"))), + ); +} + +function normalizeTools(raw) { + return raw + .map((t) => ({ + id: t.id, + mcpName: t.names?.mcp ?? t.id, + cliName: t.names?.cli ?? null, + description: t.description ?? "", + title: t.annotations?.title ?? null, + readOnly: Boolean(t.annotations?.readOnlyHint), + destructive: Boolean(t.annotations?.destructiveHint), + openWorld: Boolean(t.annotations?.openWorldHint), + module: t.module ?? null, + predicates: Array.isArray(t.predicates) ? t.predicates : [], + })) + .sort((a, b) => a.mcpName.localeCompare(b.mcpName)); +} + +function normalizeWorkflows(raw) { + return raw + .map((w) => ({ + id: w.id, + title: w.title ?? w.id, + description: w.description ?? "", + defaultEnabled: Boolean(w.selection?.mcp?.defaultEnabled), + tools: Array.isArray(w.tools) ? w.tools : [], + })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + usage(); + return; + } + if (!args.out) { + usage(); + process.exit(2); + } + + const [workflows, tools, pkg] = await Promise.all([ + loadYamlDir(path.join(repoRoot, "manifests", "workflows")), + loadYamlDir(path.join(repoRoot, "manifests", "tools")), + readFile(path.join(repoRoot, "package.json"), "utf8").then(JSON.parse), + ]); + + const ref = args.ref ?? `v${pkg.version}`; + const snapshot = { + source: `github:getsentry/XcodeBuildMCP@${ref}`, + ref, + syncedAt: new Date().toISOString(), + version: pkg.version, + workflows: normalizeWorkflows(workflows), + tools: normalizeTools(tools), + }; + + const outPath = path.resolve(args.out); + await mkdir(path.dirname(outPath), { recursive: true }); + await writeFile(outPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8"); + + console.log( + `Wrote ${outPath}\n ref: ${ref}\n version: ${snapshot.version}\n workflows: ${snapshot.workflows.length}\n tools: ${snapshot.tools.length}`, + ); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +});