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
4 changes: 4 additions & 0 deletions .debt-scan.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
"packages/mcp/src/index.d.ts",
"packages/mcp/src/index.js",
"packages/mcp/src/registry.js",
"packages/mcp/src/router-core.d.ts",
"packages/mcp/src/router-core.js",
"packages/mcp/src/tool-names.d.ts",
"packages/mcp/src/tool-names.js",
"packages/registry-client/src/index.js",
"packages/runner/src/index.js",
"packages/runner/src/secrets.js",
Expand Down
246 changes: 246 additions & 0 deletions .github/workflows/publish-mcp-npm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
name: Publish @learnrudi/mcp

on:
workflow_dispatch:
inputs:
version:
description: Exact @learnrudi/mcp version to publish from main
required: true
type: string

permissions:
contents: read

concurrency:
group: publish-mcp-npm
cancel-in-progress: false

jobs:
verify:
name: verify
if: github.ref == 'refs/heads/main'
permissions:
contents: read
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Check out accepted source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false

- name: Enable Corepack
run: corepack enable

- name: Verify trusted-publishing runtime
run: |
NPM_VERSION="$(npm --version)"
node scripts/validate-publish-runtime.mjs "$NPM_VERSION"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Verify requested package version is releasable
env:
EXPECTED_VERSION: ${{ inputs.version }}
run: |
node --input-type=module <<'NODE'
import fs from 'node:fs';

const expectedName = '@learnrudi/mcp';
const expectedVersion = process.env.EXPECTED_VERSION;
const packageJson = JSON.parse(fs.readFileSync('packages/mcp/package.json', 'utf8'));
if (packageJson.name !== expectedName) {
throw new Error(`Expected ${expectedName}, found ${packageJson.name}`);
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(expectedVersion)) {
throw new Error(`Invalid release version: ${expectedVersion}`);
}
if (packageJson.version !== expectedVersion) {
throw new Error(`Requested version ${expectedVersion} does not match package version ${packageJson.version}`);
}

const encodedName = encodeURIComponent(expectedName);
const response = await fetch(`https://registry.npmjs.org/${encodedName}`, {
headers: { accept: 'application/json' },
});
if (response.status !== 404 && !response.ok) {
throw new Error(`npm registry metadata request failed with HTTP ${response.status}`);
}
const versions = response.status === 404
? {}
: (await response.json()).versions ?? {};
if (Object.hasOwn(versions, expectedVersion)) {
throw new Error(`${expectedName}@${expectedVersion} already exists`);
}
NODE

- name: Test workspace package
run: pnpm --filter @learnrudi/mcp test

- name: Audit production dependencies
run: pnpm audit --prod --audit-level=moderate

- name: Verify packed workspace package
working-directory: packages/mcp
env:
EXPECTED_VERSION: ${{ inputs.version }}
run: |
npm pack --json --pack-destination "$RUNNER_TEMP" --ignore-scripts > "$RUNNER_TEMP/npm-pack-mcp-verify.json"
node --input-type=module -e '
import fs from "node:fs";
const expectedFiles = [
"package.json",
"src/agents.js",
"src/index.d.ts",
"src/index.js",
"src/registry.js",
"src/router-core.d.ts",
"src/router-core.js",
"src/tool-names.d.ts",
"src/tool-names.js",
];
const [packed] = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + "/npm-pack-mcp-verify.json", "utf8"));
const actualFiles = packed.files.map(({ path }) => path).sort();
if (packed.name !== "@learnrudi/mcp") {
throw new Error(`Packed unexpected package ${packed.name}`);
}
if (packed.version !== process.env.EXPECTED_VERSION) {
throw new Error(`Packed version ${packed.version} does not match ${process.env.EXPECTED_VERSION}`);
}
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error(`Unexpected package files: ${actualFiles.join(", ")}`);
}
'

publish:
name: publish
needs: verify
if: github.ref == 'refs/heads/main'
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Check out the same accepted source without credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false

- name: Verify fresh source identity
run: |
ACTUAL_SHA="$(git rev-parse HEAD)"
if [ "$ACTUAL_SHA" != "$GITHUB_SHA" ]; then
echo "Checked out $ACTUAL_SHA instead of $GITHUB_SHA" >&2
exit 1
fi
if [ -n "$(git status --porcelain=v1)" ]; then
echo "Publish checkout is not clean" >&2
exit 1
fi

- name: Verify trusted-publishing runtime without repository code
run: |
NPM_VERSION="$(npm --version)"
NPM_VERSION="$NPM_VERSION" node --input-type=module -e '
const version = process.env.NPM_VERSION;
const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
if (!match) throw new Error(`Unsupported npm version: ${version}`);
const minimum = [11, 5, 1];
const actual = match.slice(1, 4).map(Number);
for (let index = 0; index < minimum.length; index += 1) {
if (actual[index] > minimum[index]) process.exit(0);
if (actual[index] < minimum[index]) throw new Error(`npm ${version} does not support trusted publishing; require >=11.5.1`);
}
'

- name: Recheck exact version and registry immutability
env:
EXPECTED_VERSION: ${{ inputs.version }}
run: |
node --input-type=module <<'NODE'
import fs from 'node:fs';

const expectedName = '@learnrudi/mcp';
const expectedVersion = process.env.EXPECTED_VERSION;
const packageJson = JSON.parse(fs.readFileSync('packages/mcp/package.json', 'utf8'));
if (packageJson.name !== expectedName || packageJson.version !== expectedVersion) {
throw new Error(`Expected ${expectedName}@${expectedVersion}, found ${packageJson.name}@${packageJson.version}`);
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(expectedVersion)) {
throw new Error(`Invalid release version: ${expectedVersion}`);
}

const encodedName = encodeURIComponent(expectedName);
const response = await fetch(`https://registry.npmjs.org/${encodedName}`, {
headers: { accept: 'application/json' },
});
if (response.status !== 404 && !response.ok) {
throw new Error(`npm registry metadata request failed with HTTP ${response.status}`);
}
const versions = response.status === 404
? {}
: (await response.json()).versions ?? {};
if (Object.hasOwn(versions, expectedVersion)) {
throw new Error(`${expectedName}@${expectedVersion} already exists`);
}
NODE

- name: Pack fresh verified workspace package without lifecycle scripts
id: pack
working-directory: packages/mcp
env:
EXPECTED_VERSION: ${{ inputs.version }}
run: |
npm pack --json --pack-destination "$RUNNER_TEMP" --ignore-scripts > "$RUNNER_TEMP/npm-pack-mcp-publish.json"
PACKAGE_TARBALL="$(node --input-type=module -e '
import fs from "node:fs";
const expectedFiles = [
"package.json",
"src/agents.js",
"src/index.d.ts",
"src/index.js",
"src/registry.js",
"src/router-core.d.ts",
"src/router-core.js",
"src/tool-names.d.ts",
"src/tool-names.js",
];
const [packed] = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + "/npm-pack-mcp-publish.json", "utf8"));
const actualFiles = packed.files.map(({ path }) => path).sort();
if (packed.name !== "@learnrudi/mcp") {
throw new Error(`Packed unexpected package ${packed.name}`);
}
if (packed.version !== process.env.EXPECTED_VERSION) {
throw new Error(`Packed version ${packed.version} does not match ${process.env.EXPECTED_VERSION}`);
}
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error(`Unexpected package files: ${actualFiles.join(", ")}`);
}
process.stdout.write(packed.filename);
')"
echo "filename=$PACKAGE_TARBALL" >> "$GITHUB_OUTPUT"

- name: Publish through npm trusted publishing
env:
PACKAGE_TARBALL: ${{ steps.pack.outputs.filename }}
run: npm publish "$RUNNER_TEMP/$PACKAGE_TARBALL" --access public --ignore-scripts --registry=https://registry.npmjs.org
Loading