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 707ebd44..c48d7079 100644 --- a/.github/tests/update-coverage-security.test.mjs +++ b/.github/tests/update-coverage-security.test.mjs @@ -1,22 +1,46 @@ 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 { existsSync, mkdirSync, 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', + prompt: 'update-mcp-coverage.md', + }, + { + file: 'update-docs.yml', + agentStep: 'Update docs', + context: 'sdk-docs-context', + prompt: 'update-docs.md', + }, +]; + +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 +49,353 @@ 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'); +}; + +// 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`, () => { + 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']); + // 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', + '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/); + }); + + 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\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/); + 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}: 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/); + // 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( + jobText.context, + /context_artifact_id: \$\{\{ steps\.upload-context\.outputs\.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\S]*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}: 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-')); + 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`); + + // 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, + 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); + assert.equal(git('diff', '--name-only', main), ''); + assert.equal(git('diff', '--name-only', 'origin/main'), 'd.mdx'); + + 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 +407,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/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----- diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 7e44f0b4..567b8054 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -48,17 +48,20 @@ 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 }} 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 }} @@ -68,6 +71,7 @@ jobs: - name: Checkout updater instructions uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: + sparse-checkout: .github persist-credentials: false - name: Checkout published SDK @@ -78,36 +82,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 +218,145 @@ jobs: fi gh api --method DELETE /installation/token - - name: Install Pi and Mintlify CLI + # 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 }}/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] + 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 }} + + # 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 + 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 +365,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 +379,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 +402,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" @@ -414,3 +522,28 @@ jobs: gh pr create --repo kernel/docs --head "$UPDATE_BRANCH" --base main \ --title "$title" --body-file "$body" fi + + # 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.generate.result == 'success' && 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: | + # 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 11562ebe..ce1404f2 100644 --- a/.github/workflows/update-mcp-coverage.yml +++ b/.github/workflows/update-mcp-coverage.yml @@ -52,35 +52,29 @@ 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 }} 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 - 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: + sparse-checkout: .github persist-credentials: false - name: Checkout published SDK @@ -91,14 +85,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 +190,130 @@ jobs: fi gh api --method DELETE /installation/token - - name: Install Pi + # 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 }}/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] + 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 }} + + # 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 + 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 +321,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 +336,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 +359,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" @@ -379,3 +484,28 @@ jobs: gh pr create --repo kernel/kernel-mcp-server --head "$UPDATE_BRANCH" --base main \ --title "$title" --body-file "$body" fi + + # 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.generate.result == 'success' && 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: | + # 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