From c07d0134eba9751ee65f5db49de805caebf0cc2a Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:25:50 +0000 Subject: [PATCH 1/6] Run updater agents in jobs that reference only model API keys --- .../tests/update-coverage-security.test.mjs | 182 ++++++++++++++---- .github/workflows/update-docs.yml | 157 +++++++++++---- .github/workflows/update-mcp-coverage.yml | 130 ++++++++++--- 3 files changed, 358 insertions(+), 111 deletions(-) diff --git a/.github/tests/update-coverage-security.test.mjs b/.github/tests/update-coverage-security.test.mjs index 707ebd44..625c3ea7 100644 --- a/.github/tests/update-coverage-security.test.mjs +++ b/.github/tests/update-coverage-security.test.mjs @@ -1,22 +1,40 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; -const workflow = readFileSync(new URL('../workflows/update-mcp-coverage.yml', import.meta.url), 'utf8'); -const jobs = Object.fromEntries( - workflow - .slice(workflow.indexOf('\njobs:\n')) +const load = (file) => { + const text = readFileSync(new URL(`../workflows/${file}`, import.meta.url), 'utf8'); + const blocks = text + .slice(text.indexOf('\njobs:\n')) .split(/^ (?=[\w-]+:\n)/m) - .slice(1) - .map((job) => [job.slice(0, job.indexOf(':')), job.split(/(?=^ - )/m).slice(1)]), -); -const steps = Object.values(jobs).flat(); -const step = (job, name) => { - const value = jobs[job].find((entry) => entry.startsWith(` - name: ${name}\n`)); - assert.ok(value, `missing ${job} step: ${name}`); - return value; + .slice(1); + const jobText = Object.fromEntries(blocks.map((job) => [job.slice(0, job.indexOf(':')), job])); + const jobs = Object.fromEntries( + Object.entries(jobText).map(([name, job]) => [name, job.split(/(?=^ - )/m).slice(1)]), + ); + const step = (job, name) => { + const value = jobs[job].find((entry) => entry.startsWith(` - name: ${name}\n`)); + assert.ok(value, `missing ${job} step: ${name}`); + return value; + }; + return { text, jobText, jobs, steps: Object.values(jobs).flat(), step }; }; + +const updaters = [ + { + file: 'update-mcp-coverage.yml', + agentStep: 'Update SDK dependency and MCP coverage', + context: 'mcp-coverage-context', + }, + { file: 'update-docs.yml', agentStep: 'Update docs', context: 'sdk-docs-context' }, +]; + +const mcp = load('update-mcp-coverage.yml'); +const { text: workflow, jobs, step } = mcp; + const disallowedPaths = (paths) => spawnSync('bash', [new URL('../scripts/mcp-update-disallowed-paths.sh', import.meta.url).pathname], { input: paths.map((path) => `${path}\0`).join(''), @@ -25,31 +43,118 @@ const disallowedPaths = (paths) => .stdout.split('\0') .filter(Boolean); -test('actions are pinned and checkouts do not persist credentials', () => { - for (const entry of steps.filter((entry) => entry.includes('uses: '))) { - assert.match(entry, /uses: [\w/-]+@[a-f0-9]{40}(?:\s|$)/); - if (entry.includes('uses: actions/checkout@')) { - assert.match(entry, /persist-credentials: false/); - assert.doesNotMatch(entry, /token:/); +const runScript = (entry) => { + const lines = entry.split('\n'); + const start = lines.findIndex((line) => line === ' run: |'); + return lines + .slice(start + 1) + .map((line) => line.slice(10)) + .join('\n'); +}; + +for (const { file, agentStep, context } of updaters) { + const { jobText, jobs, steps, step } = load(file); + + test(`${file}: actions are pinned and checkouts do not persist credentials`, () => { + for (const entry of steps.filter((entry) => entry.includes('uses: '))) { + assert.match(entry, /uses: [\w/-]+@[a-f0-9]{40}(?:\s|$)/); + if (entry.includes('uses: actions/checkout@')) { + assert.match(entry, /persist-credentials: false/); + assert.doesNotMatch(entry, /token:/); + } } - } -}); + }); -test('API source access is read-only and revoked before generation', () => { - const readToken = step('generate', 'Generate API read token'); - assert.match(readToken, /repositories: kernel\n/); - assert.match(readToken, /permission-contents: read/); - assert.doesNotMatch(readToken, /permission-[\w-]+: write/); - const fetch = step('generate', 'Fetch API spec'); - assert.match(fetch, /GH_TOKEN: \$\{\{ steps\.api-read-token\.outputs\.token \}\}/); - assert.match(fetch, /gh api --method DELETE \/installation\/token\s*$/); - assert.match(fetch, /-f until="\$1"/); - assert.doesNotMatch(fetch, /gh repo clone/); - assert.ok( - jobs.generate.indexOf(fetch) < - jobs.generate.indexOf(step('generate', 'Update SDK dependency and MCP coverage')), - ); -}); + test(`${file}: the job that runs Pi references only the model API keys`, () => { + // A job's secrets stay in runner memory for the whole job, where an agent with + // sudo can read them, so revoking a minted token before Pi starts is not enough. + const agentJobs = Object.keys(jobs).filter((name) => + jobs[name].some((entry) => entry.includes('pi --print')), + ); + assert.deepEqual(agentJobs, ['generate']); + const secrets = [...jobText.generate.matchAll(/\bsecrets\b(?:\.(\w+))?/g)].map((match) => match[1]); + assert.deepEqual([...new Set(secrets)].sort(), ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']); + assert.doesNotMatch(jobText.generate, /GH_TOKEN|github\.token|create-github-app-token|outputs\.token/); + assert.match(step('generate', agentStep), /pi --print --no-session --approve/); + }); + + test(`${file}: GitHub reads happen in an earlier job that hands over only data`, () => { + const readToken = step('context', 'Generate API read token'); + assert.match(readToken, /repositories: kernel\n/); + assert.match(readToken, /permission-contents: read/); + assert.doesNotMatch(readToken, /permission-[\w-]+: write/); + const fetch = step('context', 'Fetch API spec'); + assert.match(fetch, /GH_TOKEN: \$\{\{ steps\.api-read-token\.outputs\.token \}\}/); + assert.match(fetch, /gh api --method DELETE \/installation\/token\s*$/); + assert.match(fetch, /-f until="\$1"/); + assert.doesNotMatch(fetch, /gh repo clone/); + assert.doesNotMatch(jobText.context, /pi --print|secrets\.(ANTHROPIC|OPENAI)_API_KEY/); + assert.match(step('context', 'Upload update context'), new RegExp(`name: ${context}\\n`)); + assert.match(step('context', 'Upload update context'), /retention-days: 1/); + assert.match(step('generate', 'Download update context'), new RegExp(`name: ${context}\\n`)); + assert.match(jobText.generate, /needs: \[sdk-version, context\]\n\s+if: needs\.context\.outputs\.update == 'true'/); + for (const output of ['main_sha', 'update_sha', 'rebased', 'agent_base_tree']) { + assert.match(jobText.generate, new RegExp(`${output}: \\$\\{\\{ needs\\.context\\.outputs\\.${output} \\}\\}`)); + } + const rebuild = step('generate', 'Rebuild update branch'); + assert.ok(jobs.generate.indexOf(rebuild) < jobs.generate.indexOf(step('generate', agentStep))); + assert.match(step('generate', 'Snapshot publishable changes'), /AGENT_BASE: \$\{\{ steps\.prepare\.outputs\.agent_base \}\}/); + }); + + test(`${file}: generate rebuilds exactly the branch the context job prepared`, () => { + const script = runScript(step('generate', 'Rebuild update branch')); + const dir = mkdtempSync(join(tmpdir(), 'rebuild-')); + try { + const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim(); + const commit = (file, content, message) => { + writeFileSync(join(dir, file), content); + git('add', file); + git('-c', 'user.name=t', '-c', 'user.email=t@example.com', 'commit', '-qm', message); + return git('rev-parse', 'HEAD'); + }; + git('init', '-q', '-b', 'main'); + const base = commit('a.mdx', 'a\n', 'base'); + git('switch', '-qc', 'update'); + const update = commit('b.mdx', 'b\n', 'bot update'); + git('switch', '-q', 'main'); + const main = commit('c.mdx', 'c\n', 'main moved'); + git('switch', '-q', '--detach', base); + // What the context job's prepare step leaves behind: the update branch rebased onto main. + git('switch', '-qc', 'expected', update); + git('-c', 'user.name=t', '-c', 'user.email=t@example.com', 'rebase', '-q', main); + const rebasedTree = git('rev-parse', 'HEAD^{tree}'); + + const run = (env) => { + git('switch', '-q', '--detach', base); + spawnSync('git', ['branch', '-q', '-D', 'rebuilt'], { cwd: dir }); + const output = join(dir, '.git', 'output'); + writeFileSync(output, ''); + const result = spawnSync('bash', ['-e', '-c', script], { + cwd: dir, + encoding: 'utf8', + env: { ...process.env, UPDATE_BRANCH: 'rebuilt', GITHUB_OUTPUT: output, ...env }, + }); + return { ...result, output: readFileSync(output, 'utf8') }; + }; + + const rebased = run({ REBASED: 'true', UPDATE_SHA: update, MAIN_SHA: main, AGENT_BASE_TREE: rebasedTree }); + assert.equal(rebased.status, 0, rebased.stderr); + assert.equal(git('rev-parse', 'HEAD^{tree}'), rebasedTree); + assert.equal(rebased.output, `agent_base=${git('rev-parse', 'HEAD')}\n`); + + const fromMain = run({ REBASED: '', UPDATE_SHA: update, MAIN_SHA: main, AGENT_BASE_TREE: git('rev-parse', `${main}^{tree}`) }); + assert.equal(fromMain.status, 0, fromMain.stderr); + assert.equal(git('rev-parse', 'HEAD'), main); + + const moved = run({ REBASED: 'true', UPDATE_SHA: update, MAIN_SHA: base, AGENT_BASE_TREE: rebasedTree }); + assert.notEqual(moved.status, 0); + assert.match(moved.stdout, /differs from the one the context job prepared/); + assert.equal(moved.output, ''); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +} test('changes are restricted to the allowlist before validation', () => { const snapshot = step('generate', 'Snapshot publishable changes'); @@ -61,9 +166,8 @@ test('changes are restricted to the allowlist before validation', () => { assert.ok(jobs.generate.indexOf(snapshot) < jobs.generate.indexOf(verify)); assert.match(snapshot, /mcp-update-disallowed-paths\.sh/); assert.match(snapshot, /git reset -q --hard\n\s+git clean -fdq/); - assert.match(snapshot, /AGENT_BASE: \$\{\{ steps\.prepare\.outputs\.agent_base \}\}/); assert.match(snapshot, /git reset -q --soft "\$AGENT_BASE"\n\s+git add -A/); - const prepare = step('generate', 'Prepare update branch and SDK diff'); + const prepare = step('context', 'Prepare update branch and SDK diff'); assert.match(prepare, /main_sha=\$\(git rev-parse origin\/main\)/); assert.match(prepare, /git rebase origin\/main; then[\s\S]*else\n\s+echo "rebased=true"/); assert.match(prepare, /agent_base=\$\(git rev-parse HEAD\)[\s\S]*agent_base_tree=/); diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 7e44f0b4..0a742146 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -48,13 +48,15 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse "refs/tags/v$version^{commit}")" >> "$GITHUB_OUTPUT" - generate: + # Every secret a job references stays readable in runner memory for the whole + # job, so GitHub reads happen here and generate only gets model keys. + context: needs: sdk-version if: needs.sdk-version.outputs.version != '' runs-on: ubuntu-latest - timeout-minutes: 55 + timeout-minutes: 10 outputs: - changed: ${{ steps.snapshot.outputs.changed }} + update: ${{ steps.prepare.outputs.update }} main_sha: ${{ steps.prepare.outputs.main_sha }} update_sha: ${{ steps.prepare.outputs.update_sha }} rebased: ${{ steps.prepare.outputs.rebased }} @@ -65,11 +67,6 @@ jobs: SINCE_VERSION: ${{ inputs.since_version }} UPDATE_BRANCH: sdk-docs-update steps: - - name: Checkout updater instructions - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - persist-credentials: false - - name: Checkout published SDK uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -78,36 +75,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Checkout Python SDK - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - repository: kernel/kernel-python-sdk - path: python-sdk - fetch-depth: 0 - persist-credentials: false - - - name: Checkout Go SDK - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - repository: kernel/kernel-go-sdk - path: go-sdk - fetch-depth: 0 - persist-credentials: false - - - name: Select matching Python and Go SDK releases - run: | - for sdk in python-sdk go-sdk; do - if git -C "$sdk" rev-parse --verify --quiet "refs/tags/v$SDK_VERSION" >/dev/null; then - git -C "$sdk" switch -q --detach "v$SDK_VERSION" - else - echo "::warning::$sdk has no v$SDK_VERSION tag yet; using its default branch." - fi - done - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: '22' - - name: Checkout docs uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -244,8 +211,117 @@ jobs: fi gh api --method DELETE /installation/token - - name: Install Pi and Mintlify CLI + - name: Upload update context if: steps.prepare.outputs.update == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sdk-docs-context + path: | + ${{ runner.temp }}/sdk-diff.patch + ${{ runner.temp }}/openapi-diff.patch + ${{ runner.temp }}/kernel-api + ${{ runner.temp }}/recent-docs-commits.txt + ${{ runner.temp }}/open-docs-prs.txt + ${{ runner.temp }}/previous-report.md + if-no-files-found: error + retention-days: 1 + + generate: + needs: [sdk-version, context] + if: needs.context.outputs.update == 'true' + runs-on: ubuntu-latest + timeout-minutes: 55 + outputs: + changed: ${{ steps.snapshot.outputs.changed }} + main_sha: ${{ needs.context.outputs.main_sha }} + update_sha: ${{ needs.context.outputs.update_sha }} + rebased: ${{ needs.context.outputs.rebased }} + agent_base_tree: ${{ needs.context.outputs.agent_base_tree }} + env: + SDK_VERSION: ${{ needs.sdk-version.outputs.version }} + SDK_SHA: ${{ needs.sdk-version.outputs.sha }} + UPDATE_BRANCH: sdk-docs-update + MAIN_SHA: ${{ needs.context.outputs.main_sha }} + UPDATE_SHA: ${{ needs.context.outputs.update_sha }} + REBASED: ${{ needs.context.outputs.rebased }} + AGENT_BASE_TREE: ${{ needs.context.outputs.agent_base_tree }} + steps: + - name: Checkout updater instructions + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Checkout published SDK + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ env.SDK_SHA }} + path: sdk + fetch-depth: 0 + persist-credentials: false + + - name: Checkout Python SDK + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + repository: kernel/kernel-python-sdk + path: python-sdk + fetch-depth: 0 + persist-credentials: false + + - name: Checkout Go SDK + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + repository: kernel/kernel-go-sdk + path: go-sdk + fetch-depth: 0 + persist-credentials: false + + - name: Select matching Python and Go SDK releases + run: | + for sdk in python-sdk go-sdk; do + if git -C "$sdk" rev-parse --verify --quiet "refs/tags/v$SDK_VERSION" >/dev/null; then + git -C "$sdk" switch -q --detach "v$SDK_VERSION" + else + echo "::warning::$sdk has no v$SDK_VERSION tag yet; using its default branch." + fi + done + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Checkout docs + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + repository: kernel/docs + path: docs + fetch-depth: 0 + persist-credentials: false + + - name: Download update context + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: sdk-docs-context + path: ${{ runner.temp }} + + - name: Rebuild update branch + id: prepare + working-directory: docs + run: | + git config user.name 'kernel-internal[bot]' + git config user.email '260533166+kernel-internal[bot]@users.noreply.github.com' + if [[ "$REBASED" == true ]]; then + git switch -c "$UPDATE_BRANCH" "$UPDATE_SHA" + git rebase -q "$MAIN_SHA" + else + git switch -c "$UPDATE_BRANCH" "$MAIN_SHA" + fi + if [[ "$(git rev-parse 'HEAD^{tree}')" != "$AGENT_BASE_TREE" ]]; then + echo "::error::The rebuilt update branch differs from the one the context job prepared." + exit 1 + fi + echo "agent_base=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Install Pi and Mintlify CLI env: PI_TELEMETRY: '0' run: | @@ -254,7 +330,6 @@ jobs: pi --version - name: Update docs - if: steps.prepare.outputs.update == 'true' working-directory: docs env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -269,7 +344,6 @@ jobs: @../.github/prompts/update-docs.md - name: Snapshot publishable changes - if: steps.prepare.outputs.update == 'true' id: snapshot working-directory: docs env: @@ -293,7 +367,6 @@ jobs: fi - name: Verify docs changes - if: steps.prepare.outputs.update == 'true' working-directory: docs run: | test -s "$RUNNER_TEMP/docs-update-report.md" diff --git a/.github/workflows/update-mcp-coverage.yml b/.github/workflows/update-mcp-coverage.yml index 11562ebe..bc8fed61 100644 --- a/.github/workflows/update-mcp-coverage.yml +++ b/.github/workflows/update-mcp-coverage.yml @@ -52,13 +52,15 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - generate: + # Every secret a job references stays readable in runner memory for the whole + # job, so the API read token is minted here and generate only gets model keys. + context: needs: sdk-version if: needs.sdk-version.outputs.version != '' runs-on: ubuntu-latest - timeout-minutes: 55 + timeout-minutes: 10 outputs: - changed: ${{ steps.snapshot.outputs.changed }} + update: ${{ steps.prepare.outputs.update }} main_sha: ${{ steps.prepare.outputs.main_sha }} update_sha: ${{ steps.prepare.outputs.update_sha }} rebased: ${{ steps.prepare.outputs.rebased }} @@ -67,22 +69,7 @@ jobs: SDK_VERSION: ${{ needs.sdk-version.outputs.version }} SDK_SHA: ${{ needs.sdk-version.outputs.sha }} UPDATE_BRANCH: mcp-coverage-update - services: - redis: - image: redis:7-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 5s - --health-timeout 3s - --health-retries 20 steps: - - name: Checkout updater instructions - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - persist-credentials: false - - name: Checkout published SDK uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -91,14 +78,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: '22' - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: '1.3.3' - - name: Checkout MCP server uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -204,8 +183,102 @@ jobs: fi gh api --method DELETE /installation/token - - name: Install Pi + - name: Upload update context if: steps.prepare.outputs.update == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mcp-coverage-context + path: | + ${{ runner.temp }}/sdk-diff.patch + ${{ runner.temp }}/openapi-diff.patch + ${{ runner.temp }}/kernel-api + if-no-files-found: error + retention-days: 1 + + generate: + needs: [sdk-version, context] + if: needs.context.outputs.update == 'true' + runs-on: ubuntu-latest + timeout-minutes: 55 + outputs: + changed: ${{ steps.snapshot.outputs.changed }} + main_sha: ${{ needs.context.outputs.main_sha }} + update_sha: ${{ needs.context.outputs.update_sha }} + rebased: ${{ needs.context.outputs.rebased }} + agent_base_tree: ${{ needs.context.outputs.agent_base_tree }} + env: + SDK_VERSION: ${{ needs.sdk-version.outputs.version }} + SDK_SHA: ${{ needs.sdk-version.outputs.sha }} + UPDATE_BRANCH: mcp-coverage-update + MAIN_SHA: ${{ needs.context.outputs.main_sha }} + UPDATE_SHA: ${{ needs.context.outputs.update_sha }} + REBASED: ${{ needs.context.outputs.rebased }} + AGENT_BASE_TREE: ${{ needs.context.outputs.agent_base_tree }} + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 20 + steps: + - name: Checkout updater instructions + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Checkout published SDK + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ env.SDK_SHA }} + path: sdk + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: '1.3.3' + + - name: Checkout MCP server + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + repository: kernel/kernel-mcp-server + path: mcp + fetch-depth: 0 + persist-credentials: false + + - name: Download update context + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: mcp-coverage-context + path: ${{ runner.temp }} + + - name: Rebuild update branch + id: prepare + working-directory: mcp + run: | + git config user.name 'kernel-internal[bot]' + git config user.email '260533166+kernel-internal[bot]@users.noreply.github.com' + if [[ "$REBASED" == true ]]; then + git switch -c "$UPDATE_BRANCH" "$UPDATE_SHA" + git rebase -q "$MAIN_SHA" + else + git switch -c "$UPDATE_BRANCH" "$MAIN_SHA" + fi + if [[ "$(git rev-parse 'HEAD^{tree}')" != "$AGENT_BASE_TREE" ]]; then + echo "::error::The rebuilt update branch differs from the one the context job prepared." + exit 1 + fi + echo "agent_base=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Install Pi env: PI_TELEMETRY: '0' run: | @@ -213,7 +286,6 @@ jobs: pi --version - name: Update SDK dependency and MCP coverage - if: steps.prepare.outputs.update == 'true' working-directory: mcp env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -229,7 +301,6 @@ jobs: @../.github/prompts/update-mcp-coverage.md - name: Snapshot publishable changes - if: steps.prepare.outputs.update == 'true' id: snapshot working-directory: mcp env: @@ -253,7 +324,6 @@ jobs: fi - name: Verify MCP changes - if: steps.prepare.outputs.update == 'true' working-directory: mcp run: | test -s "$RUNNER_TEMP/mcp-coverage-report.md" From c18d3c8f63d1ad3584bb5dd23d441518fe5988c9 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:32:17 +0000 Subject: [PATCH 2/6] Format updater security test --- .../tests/update-coverage-security.test.mjs | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/tests/update-coverage-security.test.mjs b/.github/tests/update-coverage-security.test.mjs index 625c3ea7..28fef58c 100644 --- a/.github/tests/update-coverage-security.test.mjs +++ b/.github/tests/update-coverage-security.test.mjs @@ -92,13 +92,22 @@ for (const { file, agentStep, context } of updaters) { assert.match(step('context', 'Upload update context'), new RegExp(`name: ${context}\\n`)); assert.match(step('context', 'Upload update context'), /retention-days: 1/); assert.match(step('generate', 'Download update context'), new RegExp(`name: ${context}\\n`)); - assert.match(jobText.generate, /needs: \[sdk-version, context\]\n\s+if: needs\.context\.outputs\.update == 'true'/); + assert.match( + jobText.generate, + /needs: \[sdk-version, context\]\n\s+if: needs\.context\.outputs\.update == 'true'/, + ); for (const output of ['main_sha', 'update_sha', 'rebased', 'agent_base_tree']) { - assert.match(jobText.generate, new RegExp(`${output}: \\$\\{\\{ needs\\.context\\.outputs\\.${output} \\}\\}`)); + assert.match( + jobText.generate, + new RegExp(`${output}: \\$\\{\\{ needs\\.context\\.outputs\\.${output} \\}\\}`), + ); } const rebuild = step('generate', 'Rebuild update branch'); assert.ok(jobs.generate.indexOf(rebuild) < jobs.generate.indexOf(step('generate', agentStep))); - assert.match(step('generate', 'Snapshot publishable changes'), /AGENT_BASE: \$\{\{ steps\.prepare\.outputs\.agent_base \}\}/); + assert.match( + step('generate', 'Snapshot publishable changes'), + /AGENT_BASE: \$\{\{ steps\.prepare\.outputs\.agent_base \}\}/, + ); }); test(`${file}: generate rebuilds exactly the branch the context job prepared`, () => { @@ -137,16 +146,31 @@ for (const { file, agentStep, context } of updaters) { return { ...result, output: readFileSync(output, 'utf8') }; }; - const rebased = run({ REBASED: 'true', UPDATE_SHA: update, MAIN_SHA: main, AGENT_BASE_TREE: rebasedTree }); + const rebased = run({ + REBASED: 'true', + UPDATE_SHA: update, + MAIN_SHA: main, + AGENT_BASE_TREE: rebasedTree, + }); assert.equal(rebased.status, 0, rebased.stderr); assert.equal(git('rev-parse', 'HEAD^{tree}'), rebasedTree); assert.equal(rebased.output, `agent_base=${git('rev-parse', 'HEAD')}\n`); - const fromMain = run({ REBASED: '', UPDATE_SHA: update, MAIN_SHA: main, AGENT_BASE_TREE: git('rev-parse', `${main}^{tree}`) }); + const fromMain = run({ + REBASED: '', + UPDATE_SHA: update, + MAIN_SHA: main, + AGENT_BASE_TREE: git('rev-parse', `${main}^{tree}`), + }); assert.equal(fromMain.status, 0, fromMain.stderr); assert.equal(git('rev-parse', 'HEAD'), main); - const moved = run({ REBASED: 'true', UPDATE_SHA: update, MAIN_SHA: base, AGENT_BASE_TREE: rebasedTree }); + const moved = run({ + REBASED: 'true', + UPDATE_SHA: update, + MAIN_SHA: base, + AGENT_BASE_TREE: rebasedTree, + }); assert.notEqual(moved.status, 0); assert.match(moved.stdout, /differs from the one the context job prepared/); assert.equal(moved.output, ''); From c45a1be2bef0846864ae9dde708a44820f3575b6 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:05:28 +0000 Subject: [PATCH 3/6] Encrypt updater context artifacts and delete them after each run --- .../tests/update-coverage-security.test.mjs | 109 +++++++++++++++++- .github/workflows/update-docs.yml | 62 ++++++++-- .github/workflows/update-mcp-coverage.yml | 56 ++++++++- 3 files changed, 213 insertions(+), 14 deletions(-) diff --git a/.github/tests/update-coverage-security.test.mjs b/.github/tests/update-coverage-security.test.mjs index 28fef58c..f658dc94 100644 --- a/.github/tests/update-coverage-security.test.mjs +++ b/.github/tests/update-coverage-security.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -72,8 +72,19 @@ for (const { file, agentStep, context } of updaters) { jobs[name].some((entry) => entry.includes('pi --print')), ); assert.deepEqual(agentJobs, ['generate']); + // UPDATER_CONTEXT_KEY only decrypts the context this job is meant to read. const secrets = [...jobText.generate.matchAll(/\bsecrets\b(?:\.(\w+))?/g)].map((match) => match[1]); - assert.deepEqual([...new Set(secrets)].sort(), ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']); + assert.deepEqual([...new Set(secrets)].sort(), [ + 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', + 'UPDATER_CONTEXT_KEY', + ]); + const keyUses = jobs.generate.filter((entry) => entry.includes('secrets.UPDATER_CONTEXT_KEY')); + assert.deepEqual(keyUses, [step('generate', 'Decrypt update context')]); + assert.doesNotMatch( + jobText.generate.slice(0, jobText.generate.indexOf(' steps:\n')), + /UPDATER_CONTEXT_KEY/, + ); assert.doesNotMatch(jobText.generate, /GH_TOKEN|github\.token|create-github-app-token|outputs\.token/); assert.match(step('generate', agentStep), /pi --print --no-session --approve/); }); @@ -85,7 +96,7 @@ for (const { file, agentStep, context } of updaters) { assert.doesNotMatch(readToken, /permission-[\w-]+: write/); const fetch = step('context', 'Fetch API spec'); assert.match(fetch, /GH_TOKEN: \$\{\{ steps\.api-read-token\.outputs\.token \}\}/); - assert.match(fetch, /gh api --method DELETE \/installation\/token\s*$/); + assert.match(fetch, /gh api --method DELETE \/installation\/token\n(?:\s*#.*\n)*\s*$/); assert.match(fetch, /-f until="\$1"/); assert.doesNotMatch(fetch, /gh repo clone/); assert.doesNotMatch(jobText.context, /pi --print|secrets\.(ANTHROPIC|OPENAI)_API_KEY/); @@ -110,6 +121,98 @@ for (const { file, agentStep, context } of updaters) { ); }); + test(`${file}: private spec content is only uploaded encrypted and deleted after the run`, () => { + // This repository is public, so anyone signed in can download its artifacts. + for (const entry of steps.filter((entry) => entry.includes('actions/upload-artifact@'))) { + assert.doesNotMatch(entry, /kernel-api|openapi|stainless/); + } + const upload = step('context', 'Upload update context'); + assert.match(upload, /path: \$\{\{ runner\.temp \}\}\/context\.tar\.gz\.cms\n/); + assert.match(upload, /retention-days: 1\n/); + const encrypt = step('context', 'Encrypt update context'); + assert.ok(jobs.context.indexOf(encrypt) < jobs.context.indexOf(upload)); + assert.match( + jobText.context, + /context_artifact_id: \$\{\{ steps\.upload-context\.outputs\.artifact-id \}\}/, + ); + assert.match( + jobText.cleanup, + /needs: \[context, generate, publish\]\n\s+if: always\(\) && needs\.context\.outputs\.context_artifact_id != ''/, + ); + assert.match(jobText.cleanup, /permissions:\n\s+actions: write\n\s+steps:/); + assert.match( + step('cleanup', 'Delete update context'), + /ARTIFACT_ID: \$\{\{ needs\.context\.outputs\.context_artifact_id \}\}\n\s+run: gh api --method DELETE "repos\/\$GITHUB_REPOSITORY\/actions\/artifacts\/\$ARTIFACT_ID"/, + ); + assert.doesNotMatch(jobText.cleanup, /secrets\./); + + const dir = mkdtempSync(join(tmpdir(), 'context-')); + try { + const workspace = join(dir, 'workspace'); + const temp = join(dir, 'temp'); + mkdirSync(join(workspace, '.github'), { recursive: true }); + mkdirSync(join(temp, 'kernel-api/packages/api'), { recursive: true }); + const marker = 'PRIVATE-SPEC-MARKER'; + for (const name of [ + 'sdk-diff.patch', + 'openapi-diff.patch', + 'recent-docs-commits.txt', + 'open-docs-prs.txt', + ]) { + writeFileSync(join(temp, name), `${marker} ${name}\n`); + } + writeFileSync(join(temp, 'kernel-api/packages/api/openapi.yaml'), `${marker} spec\n`); + const run = (script, env) => + spawnSync('bash', ['-e', '-c', script], { + cwd: dir, + encoding: 'utf8', + env: { ...process.env, GITHUB_WORKSPACE: workspace, RUNNER_TEMP: temp, ...env }, + }); + + const missing = run(runScript(encrypt)); + assert.notEqual(missing.status, 0); + assert.match(missing.stdout, /::error::Missing .*updater-context\.pem/); + assert.ok(!existsSync(join(temp, 'context.tar.gz.cms'))); + + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-days', + '1', + '-subj', + '/CN=test', + '-keyout', + join(dir, 'key.pem'), + '-out', + join(workspace, '.github/updater-context.pem'), + ], + { stdio: 'ignore' }, + ); + const encrypted = run(runScript(encrypt)); + assert.equal(encrypted.status, 0, encrypted.stderr); + const ciphertext = readFileSync(join(temp, 'context.tar.gz.cms')); + assert.ok(!ciphertext.includes(marker)); + assert.ok(!existsSync(join(temp, 'context.tar.gz'))); + + rmSync(join(temp, 'kernel-api'), { recursive: true }); + const key = readFileSync(join(dir, 'key.pem')).toString('base64'); + const decrypted = run(runScript(step('generate', 'Decrypt update context')), { CONTEXT_KEY: key }); + assert.equal(decrypted.status, 0, decrypted.stderr); + assert.equal( + readFileSync(join(temp, 'kernel-api/packages/api/openapi.yaml'), 'utf8'), + `${marker} spec\n`, + ); + assert.ok(!existsSync(join(temp, 'context.tar.gz.cms'))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test(`${file}: generate rebuilds exactly the branch the context job prepared`, () => { const script = runScript(step('generate', 'Rebuild update branch')); const dir = mkdtempSync(join(tmpdir(), 'rebuild-')); diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 0a742146..74d4c4b3 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -61,12 +61,19 @@ jobs: update_sha: ${{ steps.prepare.outputs.update_sha }} rebased: ${{ steps.prepare.outputs.rebased }} agent_base_tree: ${{ steps.prepare.outputs.agent_base_tree }} + context_artifact_id: ${{ steps.upload-context.outputs.artifact-id }} env: SDK_VERSION: ${{ needs.sdk-version.outputs.version }} SDK_SHA: ${{ needs.sdk-version.outputs.sha }} SINCE_VERSION: ${{ inputs.since_version }} UPDATE_BRANCH: sdk-docs-update steps: + - name: Checkout updater instructions + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + sparse-checkout: .github + persist-credentials: false + - name: Checkout published SDK uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -211,18 +218,33 @@ jobs: fi gh api --method DELETE /installation/token + # The spec comes from a private repository and this repository's artifacts are + # public, so the context is only uploaded encrypted to the committed certificate. + - name: Encrypt update context + if: steps.prepare.outputs.update == 'true' + run: | + cert="$GITHUB_WORKSPACE/.github/updater-context.pem" + if [[ ! -s "$cert" ]]; then + echo "::error::Missing $cert, the certificate for the UPDATER_CONTEXT_KEY secret." + exit 1 + fi + cd "$RUNNER_TEMP" + files=(sdk-diff.patch openapi-diff.patch kernel-api recent-docs-commits.txt open-docs-prs.txt) + if [[ -f previous-report.md ]]; then + files+=(previous-report.md) + fi + tar -czf context.tar.gz "${files[@]}" + openssl cms -encrypt -binary -aes-256-gcm -outform DER \ + -in context.tar.gz -out context.tar.gz.cms "$cert" + rm context.tar.gz + - name: Upload update context + id: upload-context if: steps.prepare.outputs.update == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sdk-docs-context - path: | - ${{ runner.temp }}/sdk-diff.patch - ${{ runner.temp }}/openapi-diff.patch - ${{ runner.temp }}/kernel-api - ${{ runner.temp }}/recent-docs-commits.txt - ${{ runner.temp }}/open-docs-prs.txt - ${{ runner.temp }}/previous-report.md + path: ${{ runner.temp }}/context.tar.gz.cms if-no-files-found: error retention-days: 1 @@ -303,6 +325,17 @@ jobs: name: sdk-docs-context path: ${{ runner.temp }} + # The key is stored base64-encoded on one line so log masking covers all of it. + - name: Decrypt update context + env: + CONTEXT_KEY: ${{ secrets.UPDATER_CONTEXT_KEY }} + run: | + set -o pipefail + cd "$RUNNER_TEMP" + openssl cms -decrypt -binary -inform DER -in context.tar.gz.cms \ + -inkey <(base64 -d <<< "$CONTEXT_KEY") | tar -xzf - + rm context.tar.gz.cms + - name: Rebuild update branch id: prepare working-directory: docs @@ -487,3 +520,18 @@ jobs: gh pr create --repo kernel/docs --head "$UPDATE_BRANCH" --base main \ --title "$title" --body-file "$body" fi + + # Runs whether or not publish does, so the context never outlives the run. + cleanup: + needs: [context, generate, publish] + if: always() && needs.context.outputs.context_artifact_id != '' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + steps: + - name: Delete update context + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ needs.context.outputs.context_artifact_id }} + run: gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID" diff --git a/.github/workflows/update-mcp-coverage.yml b/.github/workflows/update-mcp-coverage.yml index bc8fed61..dd831db4 100644 --- a/.github/workflows/update-mcp-coverage.yml +++ b/.github/workflows/update-mcp-coverage.yml @@ -65,11 +65,18 @@ jobs: update_sha: ${{ steps.prepare.outputs.update_sha }} rebased: ${{ steps.prepare.outputs.rebased }} agent_base_tree: ${{ steps.prepare.outputs.agent_base_tree }} + context_artifact_id: ${{ steps.upload-context.outputs.artifact-id }} env: SDK_VERSION: ${{ needs.sdk-version.outputs.version }} SDK_SHA: ${{ needs.sdk-version.outputs.sha }} UPDATE_BRANCH: mcp-coverage-update steps: + - name: Checkout updater instructions + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + sparse-checkout: .github + persist-credentials: false + - name: Checkout published SDK uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -183,15 +190,30 @@ jobs: fi gh api --method DELETE /installation/token + # The spec comes from a private repository and this repository's artifacts are + # public, so the context is only uploaded encrypted to the committed certificate. + - name: Encrypt update context + if: steps.prepare.outputs.update == 'true' + run: | + cert="$GITHUB_WORKSPACE/.github/updater-context.pem" + if [[ ! -s "$cert" ]]; then + echo "::error::Missing $cert, the certificate for the UPDATER_CONTEXT_KEY secret." + exit 1 + fi + cd "$RUNNER_TEMP" + files=(sdk-diff.patch openapi-diff.patch kernel-api) + tar -czf context.tar.gz "${files[@]}" + openssl cms -encrypt -binary -aes-256-gcm -outform DER \ + -in context.tar.gz -out context.tar.gz.cms "$cert" + rm context.tar.gz + - name: Upload update context + id: upload-context if: steps.prepare.outputs.update == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: mcp-coverage-context - path: | - ${{ runner.temp }}/sdk-diff.patch - ${{ runner.temp }}/openapi-diff.patch - ${{ runner.temp }}/kernel-api + path: ${{ runner.temp }}/context.tar.gz.cms if-no-files-found: error retention-days: 1 @@ -260,6 +282,17 @@ jobs: name: mcp-coverage-context path: ${{ runner.temp }} + # The key is stored base64-encoded on one line so log masking covers all of it. + - name: Decrypt update context + env: + CONTEXT_KEY: ${{ secrets.UPDATER_CONTEXT_KEY }} + run: | + set -o pipefail + cd "$RUNNER_TEMP" + openssl cms -decrypt -binary -inform DER -in context.tar.gz.cms \ + -inkey <(base64 -d <<< "$CONTEXT_KEY") | tar -xzf - + rm context.tar.gz.cms + - name: Rebuild update branch id: prepare working-directory: mcp @@ -449,3 +482,18 @@ jobs: gh pr create --repo kernel/kernel-mcp-server --head "$UPDATE_BRANCH" --base main \ --title "$title" --body-file "$body" fi + + # Runs whether or not publish does, so the context never outlives the run. + cleanup: + needs: [context, generate, publish] + if: always() && needs.context.outputs.context_artifact_id != '' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + steps: + - name: Delete update context + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ needs.context.outputs.context_artifact_id }} + run: gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID" From 67b8d57b96016d53d284d0d2c286ff758eb9d3c2 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:48:24 +0000 Subject: [PATCH 4/6] Keep update context for retries and diff against the recorded main commit --- .github/prompts/update-docs.md | 9 +- .github/prompts/update-mcp-coverage.md | 10 +- .../tests/update-coverage-security.test.mjs | 126 +++++++++++++++++- .github/workflows/update-docs.yml | 16 ++- .github/workflows/update-mcp-coverage.yml | 16 ++- 5 files changed, 156 insertions(+), 21 deletions(-) diff --git a/.github/prompts/update-docs.md b/.github/prompts/update-docs.md index 4f4d7efb..fc38ddb0 100644 --- a/.github/prompts/update-docs.md +++ b/.github/prompts/update-docs.md @@ -82,12 +82,13 @@ If you changed anything, run `mint broken-links` from the docs checkout and fix broken links your changes introduce, and run `jq empty docs.json` if you edited it. Do not run `mint dev`. -Read the complete diff against origin/main before finishing. It includes changes from -earlier releases still on this branch. Remove filler, unrelated changes, and anything -that does not follow AGENTS.md. +Read the complete diff against `$MAIN_SHA` before finishing. That is the main commit this +branch was rebuilt on; `origin/main` may have moved since, so do not compare against it. +The diff includes changes from earlier releases still on this branch. Remove filler, +unrelated changes, and anything that does not follow AGENTS.md. Write `$RUNNER_TEMP/docs-update-report.md`. It replaces the PR description, so it -must describe every change in the diff against origin/main, not only this run's. +must describe every change in the diff against `$MAIN_SHA`, not only this run's. Carry forward entries from `$RUNNER_TEMP/previous-report.md` that still match the diff. Use concise sections: diff --git a/.github/prompts/update-mcp-coverage.md b/.github/prompts/update-mcp-coverage.md index 99ac9046..cca9b22d 100644 --- a/.github/prompts/update-mcp-coverage.md +++ b/.github/prompts/update-mcp-coverage.md @@ -9,7 +9,7 @@ Do not downgrade or change that version. 1. Read `$RUNNER_TEMP/sdk-diff.patch`, `../sdk/api.md`, and the parameter types in `../sdk/src/resources`. The diff covers every release since the SDK version on - origin/main, so earlier runs may already have covered part of it on this branch. + `$MAIN_SHA`, so earlier runs may already have covered part of it on this branch. Enumerate SDK methods and their parameters, including nested options, new enum values, pagination, streaming, and changed signatures. 2. Read `src/lib/mcp/register.ts`, `src/lib/mcp/tools/`, and shared MCP schemas. @@ -33,7 +33,7 @@ Do not downgrade or change that version. The Kernel API spec as of this SDK release is at `$RUNNER_TEMP/kernel-api/packages/api/openapi.yaml`, and `$RUNNER_TEMP/openapi-diff.patch` -shows spec changes between origin/main's SDK release and this one. The SDK is generated +shows spec changes between `$MAIN_SHA`'s SDK release and this one. The SDK is generated from this spec, so its endpoint, parameter, and schema descriptions are the source of truth for what the API does. @@ -72,11 +72,13 @@ Redis is available on localhost:6379, matching MCP CI. Do not disable tests or w checks to make this update pass. Fix failures caused by your changes. Do not edit unrelated code, workflows, generated App bundles, or authentication configuration. -Read the complete diff against origin/main before finishing. Remove unnecessary +Read the complete diff against `$MAIN_SHA` before finishing. That is the main commit this +branch was rebuilt on; `origin/main` may have moved since, so do not compare against it. +Remove unnecessary comments, type casts, defensive wrappers, and unrelated changes. Write `$RUNNER_TEMP/mcp-coverage-report.md`. It replaces the PR description, so it -must describe every change in the diff against origin/main, including changes earlier +must describe every change in the diff against `$MAIN_SHA`, including changes earlier runs left on this branch, not only this run's. Use concise sections describing: - Added or updated MCP tools, actions, and fields, with their SDK counterparts. diff --git a/.github/tests/update-coverage-security.test.mjs b/.github/tests/update-coverage-security.test.mjs index f658dc94..af43cb13 100644 --- a/.github/tests/update-coverage-security.test.mjs +++ b/.github/tests/update-coverage-security.test.mjs @@ -28,8 +28,14 @@ const updaters = [ file: 'update-mcp-coverage.yml', agentStep: 'Update SDK dependency and MCP coverage', context: 'mcp-coverage-context', + prompt: 'update-mcp-coverage.md', + }, + { + file: 'update-docs.yml', + agentStep: 'Update docs', + context: 'sdk-docs-context', + prompt: 'update-docs.md', }, - { file: 'update-docs.yml', agentStep: 'Update docs', context: 'sdk-docs-context' }, ]; const mcp = load('update-mcp-coverage.yml'); @@ -52,7 +58,22 @@ const runScript = (entry) => { .join('\n'); }; -for (const { file, agentStep, context } of updaters) { +// Evaluates the job `if:` expressions these workflows use: always(), needs results +// and outputs, string literals, ==, != and &&. +const evaluate = (expression, needs) => { + const js = expression + .replace(/\balways\(\)/g, 'true') + .replace(/needs\.([\w-]+)\.result/g, (_, job) => JSON.stringify(needs[job].result)) + .replace(/needs\.([\w-]+)\.outputs\.([\w-]+)/g, (_, job, name) => + JSON.stringify(needs[job].outputs?.[name] ?? ''), + ) + .replace(/'([^']*)'/g, (_, value) => JSON.stringify(value)) + .replace(/([!=])=/g, '$1=='); + assert.match(js, /^(?:\s|"[^"]*"|true|===|!==|&&)+$/, `unsupported expression: ${expression}`); + return Function(`return (${js});`)(); +}; + +for (const { file, agentStep, context, prompt } of updaters) { const { jobText, jobs, steps, step } = load(file); test(`${file}: actions are pinned and checkouts do not persist credentials`, () => { @@ -135,14 +156,11 @@ for (const { file, agentStep, context } of updaters) { jobText.context, /context_artifact_id: \$\{\{ steps\.upload-context\.outputs\.artifact-id \}\}/, ); - assert.match( - jobText.cleanup, - /needs: \[context, generate, publish\]\n\s+if: always\(\) && needs\.context\.outputs\.context_artifact_id != ''/, - ); + assert.match(jobText.cleanup, /needs: \[context, generate, publish\]\n/); assert.match(jobText.cleanup, /permissions:\n\s+actions: write\n\s+steps:/); assert.match( step('cleanup', 'Delete update context'), - /ARTIFACT_ID: \$\{\{ needs\.context\.outputs\.context_artifact_id \}\}\n\s+run: gh api --method DELETE "repos\/\$GITHUB_REPOSITORY\/actions\/artifacts\/\$ARTIFACT_ID"/, + /ARTIFACT_ID: \$\{\{ needs\.context\.outputs\.context_artifact_id \}\}\n[\s\S]*gh api --method DELETE "repos\/\$GITHUB_REPOSITORY\/actions\/artifacts\/\$ARTIFACT_ID"/, ); assert.doesNotMatch(jobText.cleanup, /secrets\./); @@ -213,6 +231,95 @@ for (const { file, agentStep, context } of updaters) { } }); + test(`${file}: retries keep the context until generate succeeds and tolerate a deleted context`, () => { + // "Re-run failed jobs" does not rerun a successful context job, so generate + // needs the context from the first attempt. Publish never downloads it. + assert.doesNotMatch(jobText.publish, new RegExp(context)); + const condition = jobText.cleanup.match(/^ if: (.*)$/m)[1]; + const script = runScript(step('cleanup', 'Delete update context')); + const dir = mkdtempSync(join(tmpdir(), 'cleanup-')); + try { + const store = join(dir, 'artifacts'); + mkdirSync(join(dir, 'bin')); + writeFileSync( + join(dir, 'bin/gh'), + `#!/usr/bin/env bash +id="\${@: -1}" +id="\${id##*/}" +if grep -qx "$id" "$STORE"; then + grep -vx "$id" "$STORE" > "$STORE.new" || true + mv "$STORE.new" "$STORE" + exit 0 +fi +if [[ -n "$SERVER_ERROR" ]]; then + echo "gh: Server Error (HTTP 500)" >&2 + exit 1 +fi +echo '{"message":"Not Found","status":"404"}' +echo "gh: Not Found (HTTP 404)" >&2 +exit 1 +`, + { mode: 0o755 }, + ); + const stored = () => readFileSync(store, 'utf8').split('\n').includes('42'); + const attempt = (generate, publish, env = {}, artifactId = '42') => { + const needs = { + context: { result: 'success', outputs: { context_artifact_id: artifactId } }, + generate: { result: generate }, + publish: { result: publish }, + }; + if (!evaluate(condition, needs)) return null; + return spawnSync('bash', ['-e', '-c', script], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${join(dir, 'bin')}:${process.env.PATH}`, + STORE: store, + ARTIFACT_ID: artifactId, + GITHUB_REPOSITORY: 'kernel/example', + ...env, + }, + }); + }; + + // Generation fails or is cancelled: the context stays for the retry, which then deletes it. + writeFileSync(store, '42\n'); + assert.equal(attempt('failure', 'skipped'), null); + assert.equal(attempt('cancelled', 'skipped'), null); + assert.ok(stored()); + let run = attempt('success', 'success'); + assert.equal(run.status, 0, run.stderr); + assert.ok(!stored()); + + // Publish fails after generate used the context: it is deleted, and the retry tolerates that. + writeFileSync(store, '42\n'); + run = attempt('success', 'failure'); + assert.equal(run.status, 0, run.stderr); + assert.ok(!stored()); + run = attempt('success', 'success'); + assert.equal(run.status, 0, run.stderr); + assert.match(run.stdout, /already deleted/); + + run = attempt('success', 'success', { SERVER_ERROR: '1' }); + assert.notEqual(run.status, 0); + assert.match(run.stderr, /HTTP 500/); + assert.equal(attempt('success', 'skipped', {}, ''), null); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test(`${file}: the agent compares against the main commit the branch was rebuilt on`, () => { + const text = readFileSync(new URL(`../prompts/${prompt}`, import.meta.url), 'utf8'); + assert.match( + step('generate', agentStep), + new RegExp(`@\\.\\./\\.github/prompts/${prompt.replace('.', '\\.')}`), + ); + assert.match(text, /diff against `\$MAIN_SHA`/); + assert.doesNotMatch(text.replaceAll('`origin/main` may have moved since', ''), /origin\/main/); + assert.match(jobText.generate, /\n MAIN_SHA: \$\{\{ needs\.context\.outputs\.main_sha \}\}\n/); + }); + test(`${file}: generate rebuilds exactly the branch the context job prepared`, () => { const script = runScript(step('generate', 'Rebuild update branch')); const dir = mkdtempSync(join(tmpdir(), 'rebuild-')); @@ -259,6 +366,9 @@ for (const { file, agentStep, context } of updaters) { assert.equal(git('rev-parse', 'HEAD^{tree}'), rebasedTree); assert.equal(rebased.output, `agent_base=${git('rev-parse', 'HEAD')}\n`); + // origin/main can move after the context job; the agent diffs against MAIN_SHA. + git('switch', '-q', '--detach', main); + git('update-ref', 'refs/remotes/origin/main', commit('d.mdx', 'd\n', 'main moved again')); const fromMain = run({ REBASED: '', UPDATE_SHA: update, @@ -267,6 +377,8 @@ for (const { file, agentStep, context } of updaters) { }); assert.equal(fromMain.status, 0, fromMain.stderr); assert.equal(git('rev-parse', 'HEAD'), main); + assert.equal(git('diff', '--name-only', main), ''); + assert.equal(git('diff', '--name-only', 'origin/main'), 'd.mdx'); const moved = run({ REBASED: 'true', diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 74d4c4b3..55fbad4c 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -521,10 +521,12 @@ jobs: --title "$title" --body-file "$body" fi - # Runs whether or not publish does, so the context never outlives the run. + # Deletes the context once generate has used it, whether or not publish runs. + # After a failed or cancelled generate the context is kept until it expires, so + # re-running failed jobs can still download it. cleanup: needs: [context, generate, publish] - if: always() && needs.context.outputs.context_artifact_id != '' + if: always() && needs.generate.result == 'success' && needs.context.outputs.context_artifact_id != '' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -534,4 +536,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} ARTIFACT_ID: ${{ needs.context.outputs.context_artifact_id }} - run: gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID" + run: | + # A re-run after a failed publish finds the context already deleted. + if ! error=$(gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID" 2>&1); then + if [[ "$error" != *"(HTTP 404)"* ]]; then + echo "$error" >&2 + exit 1 + fi + echo "The update context was already deleted." + fi diff --git a/.github/workflows/update-mcp-coverage.yml b/.github/workflows/update-mcp-coverage.yml index dd831db4..78baeb70 100644 --- a/.github/workflows/update-mcp-coverage.yml +++ b/.github/workflows/update-mcp-coverage.yml @@ -483,10 +483,12 @@ jobs: --title "$title" --body-file "$body" fi - # Runs whether or not publish does, so the context never outlives the run. + # Deletes the context once generate has used it, whether or not publish runs. + # After a failed or cancelled generate the context is kept until it expires, so + # re-running failed jobs can still download it. cleanup: needs: [context, generate, publish] - if: always() && needs.context.outputs.context_artifact_id != '' + if: always() && needs.generate.result == 'success' && needs.context.outputs.context_artifact_id != '' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -496,4 +498,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} ARTIFACT_ID: ${{ needs.context.outputs.context_artifact_id }} - run: gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID" + run: | + # A re-run after a failed publish finds the context already deleted. + if ! error=$(gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID" 2>&1); then + if [[ "$error" != *"(HTTP 404)"* ]]; then + echo "$error" >&2 + exit 1 + fi + echo "The update context was already deleted." + fi From c45491be04e76ff1f32b18a75a1c11ec5c7e2e18 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:55:29 +0000 Subject: [PATCH 5/6] Replace the update context when a run is re-run in full --- .github/tests/update-coverage-security.test.mjs | 2 ++ .github/workflows/update-docs.yml | 2 ++ .github/workflows/update-mcp-coverage.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/tests/update-coverage-security.test.mjs b/.github/tests/update-coverage-security.test.mjs index af43cb13..c48d7079 100644 --- a/.github/tests/update-coverage-security.test.mjs +++ b/.github/tests/update-coverage-security.test.mjs @@ -150,6 +150,8 @@ for (const { file, agentStep, context, prompt } of updaters) { const upload = step('context', 'Upload update context'); assert.match(upload, /path: \$\{\{ runner\.temp \}\}\/context\.tar\.gz\.cms\n/); assert.match(upload, /retention-days: 1\n/); + // Kept after a failed generate, so "Re-run all jobs" must replace it. + assert.match(upload, /overwrite: true\n/); const encrypt = step('context', 'Encrypt update context'); assert.ok(jobs.context.indexOf(encrypt) < jobs.context.indexOf(upload)); assert.match( diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 55fbad4c..567b8054 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -247,6 +247,8 @@ jobs: path: ${{ runner.temp }}/context.tar.gz.cms if-no-files-found: error retention-days: 1 + # "Re-run all jobs" uploads again in the same run, where a failed generate left the context. + overwrite: true generate: needs: [sdk-version, context] diff --git a/.github/workflows/update-mcp-coverage.yml b/.github/workflows/update-mcp-coverage.yml index 78baeb70..ce1404f2 100644 --- a/.github/workflows/update-mcp-coverage.yml +++ b/.github/workflows/update-mcp-coverage.yml @@ -216,6 +216,8 @@ jobs: path: ${{ runner.temp }}/context.tar.gz.cms if-no-files-found: error retention-days: 1 + # "Re-run all jobs" uploads again in the same run, where a failed generate left the context. + overwrite: true generate: needs: [sdk-version, context] From cb823d0e5b4170051111231b5c4fe98c144b1b11 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 6 Nov 2026 09:49:43 -0500 Subject: [PATCH 6/6] Add updater context encryption certificate --- .github/updater-context.pem | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/updater-context.pem diff --git a/.github/updater-context.pem b/.github/updater-context.pem new file mode 100644 index 00000000..5e9e406d --- /dev/null +++ b/.github/updater-context.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFNzCCAx+gAwIBAgIUb5NFkKn2CqnjtL91tB4ue0bjqpMwDQYJKoZIhvcNAQEL +BQAwKjEoMCYGA1UEAwwfa2VybmVsLW5vZGUtc2RrIHVwZGF0ZXIgY29udGV4dDAg +Fw0yNjExMDYxNDQ5NDNaGA8yMTI2MTAxMzE0NDk0M1owKjEoMCYGA1UEAwwfa2Vy +bmVsLW5vZGUtc2RrIHVwZGF0ZXIgY29udGV4dDCCAiIwDQYJKoZIhvcNAQEBBQAD +ggIPADCCAgoCggIBAL3KIwNPUnG08THvJGRFjz/84cxeXZP+XhmDdxBUNsWSpyXR +mBDmvCYgv6jyM3iiTSHjQ3RzYt3BCK8iVL54ZZkKLtwNv4AQn3ipXo9pSPe5T123 +9vnt2oYeWDVYFjq+JAEy4UYQ7G7RsmLdo5cLZ7LeGC5ReL255L34OY//Xk+YVWH+ +HUU35SX9/VzZE71gl6F0b6CCP8sQ/KTUOiwyfuG/Dz86s/Qr6dPUrUIaiLahcj17 +xDClRH0A3pCHGz+T9Z/H5yo4DWrSwCKDzgiCPxhBcJrGLguMGrsI/O1FUz8PlHqx +pkVbDyGBA2Rupeax19E1YwWVsVRzrsTAe7fF3dc1rhisXwdOPGgc7AVuQaQFOdDu +P37wQNZJp+YZuMRkqmG99mDhKV+2Ny0iIgMhEVtiGE3GCxGBeulNcmsdpmNn3ukH +tTe/NZfOxuLTRS42W4a5nDu9iypvNG5PJf+hsHSowvgRx2hqcFMHDdmf8IwIQSLy +uYXo8bKw8N5icDidghQGjovZKxKJl8IPHfblEEI02oUih6Cm4E0wxP0Mo0wQNxBo +SOSDsGxmcw0kJht+ZVP9YkeOCnlQ+JsdBDdYJe46S+1ofaLbw14DpeRhsTVM6cb3 +XhqmPcAT4x1WAAMzEgnacNY/VhfgbxfVqwnC7CX9c4+eAOoWZ9QvAS+nn79RAgMB +AAGjUzBRMB0GA1UdDgQWBBQKVsrfwD0E+xtuGrFzrguTF0D04DAfBgNVHSMEGDAW +gBQKVsrfwD0E+xtuGrFzrguTF0D04DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4ICAQAJCbtV9YgIOIdas/PRpnOytYiNMQn6AhQPSqZEoOnM82Am45kg +6oubUM6XBN0ZTSGXcqBhabSEmcM2fTdBOcxlzxIbLibdWePwu/i3Jhm6NIMvSMz+ +aLtHeHNXl4uQWLBveJi1Lk4jc8qirnutYJB/tkIfBpMNZyrdkN6cykXKbXOzGyPZ +d+aNhtmdBPoS7+09UworGqr4aNH1AjS7BmIEsKqN21i7jbB7bp+5RYkEL3szKHQV +eZtYpYrnPcqsLMjot1p9AwsMxncp98RRKXgUsnjQuwSjbzXnkodiln+/08SGdY8p +SoVr7lw1sUVf/jXpkEYRJzQJG3qmvyNT4LRYR+MT+13NrYlYSAI6p4tOA7/E1Rhw +2+9/UzyX6EX1fc/LKVqMuh2u3K0oP2/EdW4BdNbIRHBKBH+CJ0Rbmx2SX6tCBmc6 +pYU2yYH0gWZ+IU6Xn50AWE1PWladr62ZiTWgpHf1ETmNwMTV3SEZq1tbH0RNVKAH +kIbfnzOw4GQG0OMHozH74t/i/HRTVjb9SFyA0/EZ3lL64bAnXw2TUnRAwVFVyIJR +9wZsy45ucy+IOQjVqdDB+5bgWu8tJjff+EopwRDzjuv+noitZmmax2ZcfMFnXoKw +U8pH0cO6v89F2zgU5mKDZrx8/LRjnBcVjjZ9obQu/vQrPy5QWzLWNqSXxQ== +-----END CERTIFICATE-----