11import assert from 'node:assert/strict' ;
2- import { spawnSync } from 'node:child_process' ;
3- import { readFileSync } from 'node:fs' ;
2+ import { execFileSync , spawnSync } from 'node:child_process' ;
3+ import { mkdtempSync , readFileSync , rmSync , writeFileSync } from 'node:fs' ;
4+ import { tmpdir } from 'node:os' ;
5+ import { join } from 'node:path' ;
46import test from 'node:test' ;
57
6- const workflow = readFileSync ( new URL ( '../workflows/update-mcp-coverage.yml' , import . meta . url ) , 'utf8' ) ;
7- const jobs = Object . fromEntries (
8- workflow
9- . slice ( workflow . indexOf ( '\njobs:\n' ) )
8+ const load = ( file ) => {
9+ const text = readFileSync ( new URL ( `../workflows/ ${ file } ` , import . meta . url ) , 'utf8' ) ;
10+ const blocks = text
11+ . slice ( text . indexOf ( '\njobs:\n' ) )
1012 . split ( / ^ (? = [ \w - ] + : \n ) / m)
11- . slice ( 1 )
12- . map ( ( job ) => [ job . slice ( 0 , job . indexOf ( ':' ) ) , job . split ( / (? = ^ - ) / m) . slice ( 1 ) ] ) ,
13- ) ;
14- const steps = Object . values ( jobs ) . flat ( ) ;
15- const step = ( job , name ) => {
16- const value = jobs [ job ] . find ( ( entry ) => entry . startsWith ( ` - name: ${ name } \n` ) ) ;
17- assert . ok ( value , `missing ${ job } step: ${ name } ` ) ;
18- return value ;
13+ . slice ( 1 ) ;
14+ const jobText = Object . fromEntries ( blocks . map ( ( job ) => [ job . slice ( 0 , job . indexOf ( ':' ) ) , job ] ) ) ;
15+ const jobs = Object . fromEntries (
16+ Object . entries ( jobText ) . map ( ( [ name , job ] ) => [ name , job . split ( / (? = ^ - ) / m) . slice ( 1 ) ] ) ,
17+ ) ;
18+ const step = ( job , name ) => {
19+ const value = jobs [ job ] . find ( ( entry ) => entry . startsWith ( ` - name: ${ name } \n` ) ) ;
20+ assert . ok ( value , `missing ${ job } step: ${ name } ` ) ;
21+ return value ;
22+ } ;
23+ return { text, jobText, jobs, steps : Object . values ( jobs ) . flat ( ) , step } ;
1924} ;
25+
26+ const updaters = [
27+ {
28+ file : 'update-mcp-coverage.yml' ,
29+ agentStep : 'Update SDK dependency and MCP coverage' ,
30+ context : 'mcp-coverage-context' ,
31+ } ,
32+ { file : 'update-docs.yml' , agentStep : 'Update docs' , context : 'sdk-docs-context' } ,
33+ ] ;
34+
35+ const mcp = load ( 'update-mcp-coverage.yml' ) ;
36+ const { text : workflow , jobs, step } = mcp ;
37+
2038const disallowedPaths = ( paths ) =>
2139 spawnSync ( 'bash' , [ new URL ( '../scripts/mcp-update-disallowed-paths.sh' , import . meta. url ) . pathname ] , {
2240 input : paths . map ( ( path ) => `${ path } \0` ) . join ( '' ) ,
@@ -25,31 +43,118 @@ const disallowedPaths = (paths) =>
2543 . stdout . split ( '\0' )
2644 . filter ( Boolean ) ;
2745
28- test ( 'actions are pinned and checkouts do not persist credentials' , ( ) => {
29- for ( const entry of steps . filter ( ( entry ) => entry . includes ( 'uses: ' ) ) ) {
30- assert . match ( entry , / u s e s : [ \w / - ] + @ [ a - f 0 - 9 ] { 40 } (?: \s | $ ) / ) ;
31- if ( entry . includes ( 'uses: actions/checkout@' ) ) {
32- assert . match ( entry , / p e r s i s t - c r e d e n t i a l s : f a l s e / ) ;
33- assert . doesNotMatch ( entry , / t o k e n : / ) ;
46+ const runScript = ( entry ) => {
47+ const lines = entry . split ( '\n' ) ;
48+ const start = lines . findIndex ( ( line ) => line === ' run: |' ) ;
49+ return lines
50+ . slice ( start + 1 )
51+ . map ( ( line ) => line . slice ( 10 ) )
52+ . join ( '\n' ) ;
53+ } ;
54+
55+ for ( const { file, agentStep, context } of updaters ) {
56+ const { jobText, jobs, steps, step } = load ( file ) ;
57+
58+ test ( `${ file } : actions are pinned and checkouts do not persist credentials` , ( ) => {
59+ for ( const entry of steps . filter ( ( entry ) => entry . includes ( 'uses: ' ) ) ) {
60+ assert . match ( entry , / u s e s : [ \w / - ] + @ [ a - f 0 - 9 ] { 40 } (?: \s | $ ) / ) ;
61+ if ( entry . includes ( 'uses: actions/checkout@' ) ) {
62+ assert . match ( entry , / p e r s i s t - c r e d e n t i a l s : f a l s e / ) ;
63+ assert . doesNotMatch ( entry , / t o k e n : / ) ;
64+ }
3465 }
35- }
36- } ) ;
66+ } ) ;
3767
38- test ( 'API source access is read-only and revoked before generation' , ( ) => {
39- const readToken = step ( 'generate' , 'Generate API read token' ) ;
40- assert . match ( readToken , / r e p o s i t o r i e s : k e r n e l \n / ) ;
41- assert . match ( readToken , / p e r m i s s i o n - c o n t e n t s : r e a d / ) ;
42- assert . doesNotMatch ( readToken , / p e r m i s s i o n - [ \w - ] + : w r i t e / ) ;
43- const fetch = step ( 'generate' , 'Fetch API spec' ) ;
44- assert . match ( fetch , / G H _ T O K E N : \$ \{ \{ s t e p s \. a p i - r e a d - t o k e n \. o u t p u t s \. t o k e n \} \} / ) ;
45- assert . match ( fetch , / g h a p i - - m e t h o d D E L E T E \/ i n s t a l l a t i o n \/ t o k e n \s * $ / ) ;
46- assert . match ( fetch , / - f u n t i l = " \$ 1 " / ) ;
47- assert . doesNotMatch ( fetch , / g h r e p o c l o n e / ) ;
48- assert . ok (
49- jobs . generate . indexOf ( fetch ) <
50- jobs . generate . indexOf ( step ( 'generate' , 'Update SDK dependency and MCP coverage' ) ) ,
51- ) ;
52- } ) ;
68+ test ( `${ file } : the job that runs Pi references only the model API keys` , ( ) => {
69+ // A job's secrets stay in runner memory for the whole job, where an agent with
70+ // sudo can read them, so revoking a minted token before Pi starts is not enough.
71+ const agentJobs = Object . keys ( jobs ) . filter ( ( name ) =>
72+ jobs [ name ] . some ( ( entry ) => entry . includes ( 'pi --print' ) ) ,
73+ ) ;
74+ assert . deepEqual ( agentJobs , [ 'generate' ] ) ;
75+ const secrets = [ ...jobText . generate . matchAll ( / \b s e c r e t s \b (?: \. ( \w + ) ) ? / g) ] . map ( ( match ) => match [ 1 ] ) ;
76+ assert . deepEqual ( [ ...new Set ( secrets ) ] . sort ( ) , [ 'ANTHROPIC_API_KEY' , 'OPENAI_API_KEY' ] ) ;
77+ assert . doesNotMatch ( jobText . generate , / G H _ T O K E N | g i t h u b \. t o k e n | c r e a t e - g i t h u b - a p p - t o k e n | o u t p u t s \. t o k e n / ) ;
78+ assert . match ( step ( 'generate' , agentStep ) , / p i - - p r i n t - - n o - s e s s i o n - - a p p r o v e / ) ;
79+ } ) ;
80+
81+ test ( `${ file } : GitHub reads happen in an earlier job that hands over only data` , ( ) => {
82+ const readToken = step ( 'context' , 'Generate API read token' ) ;
83+ assert . match ( readToken , / r e p o s i t o r i e s : k e r n e l \n / ) ;
84+ assert . match ( readToken , / p e r m i s s i o n - c o n t e n t s : r e a d / ) ;
85+ assert . doesNotMatch ( readToken , / p e r m i s s i o n - [ \w - ] + : w r i t e / ) ;
86+ const fetch = step ( 'context' , 'Fetch API spec' ) ;
87+ assert . match ( fetch , / G H _ T O K E N : \$ \{ \{ s t e p s \. a p i - r e a d - t o k e n \. o u t p u t s \. t o k e n \} \} / ) ;
88+ assert . match ( fetch , / g h a p i - - m e t h o d D E L E T E \/ i n s t a l l a t i o n \/ t o k e n \s * $ / ) ;
89+ assert . match ( fetch , / - f u n t i l = " \$ 1 " / ) ;
90+ assert . doesNotMatch ( fetch , / g h r e p o c l o n e / ) ;
91+ assert . doesNotMatch ( jobText . context , / p i - - p r i n t | s e c r e t s \. ( A N T H R O P I C | O P E N A I ) _ A P I _ K E Y / ) ;
92+ assert . match ( step ( 'context' , 'Upload update context' ) , new RegExp ( `name: ${ context } \\n` ) ) ;
93+ assert . match ( step ( 'context' , 'Upload update context' ) , / r e t e n t i o n - d a y s : 1 / ) ;
94+ assert . match ( step ( 'generate' , 'Download update context' ) , new RegExp ( `name: ${ context } \\n` ) ) ;
95+ assert . match ( jobText . generate , / n e e d s : \[ s d k - v e r s i o n , c o n t e x t \] \n \s + i f : n e e d s \. c o n t e x t \. o u t p u t s \. u p d a t e = = ' t r u e ' / ) ;
96+ for ( const output of [ 'main_sha' , 'update_sha' , 'rebased' , 'agent_base_tree' ] ) {
97+ assert . match ( jobText . generate , new RegExp ( `${ output } : \\$\\{\\{ needs\\.context\\.outputs\\.${ output } \\}\\}` ) ) ;
98+ }
99+ const rebuild = step ( 'generate' , 'Rebuild update branch' ) ;
100+ assert . ok ( jobs . generate . indexOf ( rebuild ) < jobs . generate . indexOf ( step ( 'generate' , agentStep ) ) ) ;
101+ assert . match ( step ( 'generate' , 'Snapshot publishable changes' ) , / A G E N T _ B A S E : \$ \{ \{ s t e p s \. p r e p a r e \. o u t p u t s \. a g e n t _ b a s e \} \} / ) ;
102+ } ) ;
103+
104+ test ( `${ file } : generate rebuilds exactly the branch the context job prepared` , ( ) => {
105+ const script = runScript ( step ( 'generate' , 'Rebuild update branch' ) ) ;
106+ const dir = mkdtempSync ( join ( tmpdir ( ) , 'rebuild-' ) ) ;
107+ try {
108+ const git = ( ...args ) => execFileSync ( 'git' , args , { cwd : dir , encoding : 'utf8' } ) . trim ( ) ;
109+ const commit = ( file , content , message ) => {
110+ writeFileSync ( join ( dir , file ) , content ) ;
111+ git ( 'add' , file ) ;
112+ git ( '-c' , 'user.name=t' , '-c' , 'user.email=t@example.com' , 'commit' , '-qm' , message ) ;
113+ return git ( 'rev-parse' , 'HEAD' ) ;
114+ } ;
115+ git ( 'init' , '-q' , '-b' , 'main' ) ;
116+ const base = commit ( 'a.mdx' , 'a\n' , 'base' ) ;
117+ git ( 'switch' , '-qc' , 'update' ) ;
118+ const update = commit ( 'b.mdx' , 'b\n' , 'bot update' ) ;
119+ git ( 'switch' , '-q' , 'main' ) ;
120+ const main = commit ( 'c.mdx' , 'c\n' , 'main moved' ) ;
121+ git ( 'switch' , '-q' , '--detach' , base ) ;
122+ // What the context job's prepare step leaves behind: the update branch rebased onto main.
123+ git ( 'switch' , '-qc' , 'expected' , update ) ;
124+ git ( '-c' , 'user.name=t' , '-c' , 'user.email=t@example.com' , 'rebase' , '-q' , main ) ;
125+ const rebasedTree = git ( 'rev-parse' , 'HEAD^{tree}' ) ;
126+
127+ const run = ( env ) => {
128+ git ( 'switch' , '-q' , '--detach' , base ) ;
129+ spawnSync ( 'git' , [ 'branch' , '-q' , '-D' , 'rebuilt' ] , { cwd : dir } ) ;
130+ const output = join ( dir , '.git' , 'output' ) ;
131+ writeFileSync ( output , '' ) ;
132+ const result = spawnSync ( 'bash' , [ '-e' , '-c' , script ] , {
133+ cwd : dir ,
134+ encoding : 'utf8' ,
135+ env : { ...process . env , UPDATE_BRANCH : 'rebuilt' , GITHUB_OUTPUT : output , ...env } ,
136+ } ) ;
137+ return { ...result , output : readFileSync ( output , 'utf8' ) } ;
138+ } ;
139+
140+ const rebased = run ( { REBASED : 'true' , UPDATE_SHA : update , MAIN_SHA : main , AGENT_BASE_TREE : rebasedTree } ) ;
141+ assert . equal ( rebased . status , 0 , rebased . stderr ) ;
142+ assert . equal ( git ( 'rev-parse' , 'HEAD^{tree}' ) , rebasedTree ) ;
143+ assert . equal ( rebased . output , `agent_base=${ git ( 'rev-parse' , 'HEAD' ) } \n` ) ;
144+
145+ const fromMain = run ( { REBASED : '' , UPDATE_SHA : update , MAIN_SHA : main , AGENT_BASE_TREE : git ( 'rev-parse' , `${ main } ^{tree}` ) } ) ;
146+ assert . equal ( fromMain . status , 0 , fromMain . stderr ) ;
147+ assert . equal ( git ( 'rev-parse' , 'HEAD' ) , main ) ;
148+
149+ const moved = run ( { REBASED : 'true' , UPDATE_SHA : update , MAIN_SHA : base , AGENT_BASE_TREE : rebasedTree } ) ;
150+ assert . notEqual ( moved . status , 0 ) ;
151+ assert . match ( moved . stdout , / d i f f e r s f r o m t h e o n e t h e c o n t e x t j o b p r e p a r e d / ) ;
152+ assert . equal ( moved . output , '' ) ;
153+ } finally {
154+ rmSync ( dir , { recursive : true , force : true } ) ;
155+ }
156+ } ) ;
157+ }
53158
54159test ( 'changes are restricted to the allowlist before validation' , ( ) => {
55160 const snapshot = step ( 'generate' , 'Snapshot publishable changes' ) ;
@@ -61,9 +166,8 @@ test('changes are restricted to the allowlist before validation', () => {
61166 assert . ok ( jobs . generate . indexOf ( snapshot ) < jobs . generate . indexOf ( verify ) ) ;
62167 assert . match ( snapshot , / m c p - u p d a t e - d i s a l l o w e d - p a t h s \. s h / ) ;
63168 assert . match ( snapshot , / g i t r e s e t - q - - h a r d \n \s + g i t c l e a n - f d q / ) ;
64- assert . match ( snapshot , / A G E N T _ B A S E : \$ \{ \{ s t e p s \. p r e p a r e \. o u t p u t s \. a g e n t _ b a s e \} \} / ) ;
65169 assert . match ( snapshot , / g i t r e s e t - q - - s o f t " \$ A G E N T _ B A S E " \n \s + g i t a d d - A / ) ;
66- const prepare = step ( 'generate ' , 'Prepare update branch and SDK diff' ) ;
170+ const prepare = step ( 'context ' , 'Prepare update branch and SDK diff' ) ;
67171 assert . match ( prepare , / m a i n _ s h a = \$ \( g i t r e v - p a r s e o r i g i n \/ m a i n \) / ) ;
68172 assert . match ( prepare , / g i t r e b a s e o r i g i n \/ m a i n ; t h e n [ \s \S ] * e l s e \n \s + e c h o " r e b a s e d = t r u e " / ) ;
69173 assert . match ( prepare , / a g e n t _ b a s e = \$ \( g i t r e v - p a r s e H E A D \) [ \s \S ] * a g e n t _ b a s e _ t r e e = / ) ;
0 commit comments