@@ -28,8 +28,14 @@ const updaters = [
2828 file : 'update-mcp-coverage.yml' ,
2929 agentStep : 'Update SDK dependency and MCP coverage' ,
3030 context : 'mcp-coverage-context' ,
31+ prompt : 'update-mcp-coverage.md' ,
32+ } ,
33+ {
34+ file : 'update-docs.yml' ,
35+ agentStep : 'Update docs' ,
36+ context : 'sdk-docs-context' ,
37+ prompt : 'update-docs.md' ,
3138 } ,
32- { file : 'update-docs.yml' , agentStep : 'Update docs' , context : 'sdk-docs-context' } ,
3339] ;
3440
3541const mcp = load ( 'update-mcp-coverage.yml' ) ;
@@ -52,7 +58,22 @@ const runScript = (entry) => {
5258 . join ( '\n' ) ;
5359} ;
5460
55- for ( const { file, agentStep, context } of updaters ) {
61+ // Evaluates the job `if:` expressions these workflows use: always(), needs results
62+ // and outputs, string literals, ==, != and &&.
63+ const evaluate = ( expression , needs ) => {
64+ const js = expression
65+ . replace ( / \b a l w a y s \( \) / g, 'true' )
66+ . replace ( / n e e d s \. ( [ \w - ] + ) \. r e s u l t / g, ( _ , job ) => JSON . stringify ( needs [ job ] . result ) )
67+ . replace ( / n e e d s \. ( [ \w - ] + ) \. o u t p u t s \. ( [ \w - ] + ) / g, ( _ , job , name ) =>
68+ JSON . stringify ( needs [ job ] . outputs ?. [ name ] ?? '' ) ,
69+ )
70+ . replace ( / ' ( [ ^ ' ] * ) ' / g, ( _ , value ) => JSON . stringify ( value ) )
71+ . replace ( / ( [ ! = ] ) = / g, '$1==' ) ;
72+ assert . match ( js , / ^ (?: \s | " [ ^ " ] * " | t r u e | = = = | ! = = | & & ) + $ / , `unsupported expression: ${ expression } ` ) ;
73+ return Function ( `return (${ js } );` ) ( ) ;
74+ } ;
75+
76+ for ( const { file, agentStep, context, prompt } of updaters ) {
5677 const { jobText, jobs, steps, step } = load ( file ) ;
5778
5879 test ( `${ file } : actions are pinned and checkouts do not persist credentials` , ( ) => {
@@ -135,14 +156,11 @@ for (const { file, agentStep, context } of updaters) {
135156 jobText . context ,
136157 / c o n t e x t _ a r t i f a c t _ i d : \$ \{ \{ s t e p s \. u p l o a d - c o n t e x t \. o u t p u t s \. a r t i f a c t - i d \} \} / ,
137158 ) ;
138- assert . match (
139- jobText . cleanup ,
140- / n e e d s : \[ c o n t e x t , g e n e r a t e , p u b l i s h \] \n \s + i f : a l w a y s \( \) & & n e e d s \. c o n t e x t \. o u t p u t s \. c o n t e x t _ a r t i f a c t _ i d ! = ' ' / ,
141- ) ;
159+ assert . match ( jobText . cleanup , / n e e d s : \[ c o n t e x t , g e n e r a t e , p u b l i s h \] \n / ) ;
142160 assert . match ( jobText . cleanup , / p e r m i s s i o n s : \n \s + a c t i o n s : w r i t e \n \s + s t e p s : / ) ;
143161 assert . match (
144162 step ( 'cleanup' , 'Delete update context' ) ,
145- / A R T I F A C T _ I D : \$ \{ \{ n e e d s \. c o n t e x t \. o u t p u t s \. c o n t e x t _ a r t i f a c t _ i d \} \} \n \s + r u n : g h a p i - - m e t h o d D E L E T E " r e p o s \/ \$ G I T H U B _ R E P O S I T O R Y \/ a c t i o n s \/ a r t i f a c t s \/ \$ A R T I F A C T _ I D " / ,
163+ / A R T I F A C T _ I D : \$ \{ \{ n e e d s \. c o n t e x t \. o u t p u t s \. c o n t e x t _ a r t i f a c t _ i d \} \} \n [ \s \S ] * g h a p i - - m e t h o d D E L E T E " r e p o s \/ \$ G I T H U B _ R E P O S I T O R Y \/ a c t i o n s \/ a r t i f a c t s \/ \$ A R T I F A C T _ I D " / ,
146164 ) ;
147165 assert . doesNotMatch ( jobText . cleanup , / s e c r e t s \. / ) ;
148166
@@ -213,6 +231,95 @@ for (const { file, agentStep, context } of updaters) {
213231 }
214232 } ) ;
215233
234+ test ( `${ file } : retries keep the context until generate succeeds and tolerate a deleted context` , ( ) => {
235+ // "Re-run failed jobs" does not rerun a successful context job, so generate
236+ // needs the context from the first attempt. Publish never downloads it.
237+ assert . doesNotMatch ( jobText . publish , new RegExp ( context ) ) ;
238+ const condition = jobText . cleanup . match ( / ^ i f : ( .* ) $ / m) [ 1 ] ;
239+ const script = runScript ( step ( 'cleanup' , 'Delete update context' ) ) ;
240+ const dir = mkdtempSync ( join ( tmpdir ( ) , 'cleanup-' ) ) ;
241+ try {
242+ const store = join ( dir , 'artifacts' ) ;
243+ mkdirSync ( join ( dir , 'bin' ) ) ;
244+ writeFileSync (
245+ join ( dir , 'bin/gh' ) ,
246+ `#!/usr/bin/env bash
247+ id="\${@: -1}"
248+ id="\${id##*/}"
249+ if grep -qx "$id" "$STORE"; then
250+ grep -vx "$id" "$STORE" > "$STORE.new" || true
251+ mv "$STORE.new" "$STORE"
252+ exit 0
253+ fi
254+ if [[ -n "$SERVER_ERROR" ]]; then
255+ echo "gh: Server Error (HTTP 500)" >&2
256+ exit 1
257+ fi
258+ echo '{"message":"Not Found","status":"404"}'
259+ echo "gh: Not Found (HTTP 404)" >&2
260+ exit 1
261+ ` ,
262+ { mode : 0o755 } ,
263+ ) ;
264+ const stored = ( ) => readFileSync ( store , 'utf8' ) . split ( '\n' ) . includes ( '42' ) ;
265+ const attempt = ( generate , publish , env = { } , artifactId = '42' ) => {
266+ const needs = {
267+ context : { result : 'success' , outputs : { context_artifact_id : artifactId } } ,
268+ generate : { result : generate } ,
269+ publish : { result : publish } ,
270+ } ;
271+ if ( ! evaluate ( condition , needs ) ) return null ;
272+ return spawnSync ( 'bash' , [ '-e' , '-c' , script ] , {
273+ encoding : 'utf8' ,
274+ env : {
275+ ...process . env ,
276+ PATH : `${ join ( dir , 'bin' ) } :${ process . env . PATH } ` ,
277+ STORE : store ,
278+ ARTIFACT_ID : artifactId ,
279+ GITHUB_REPOSITORY : 'kernel/example' ,
280+ ...env ,
281+ } ,
282+ } ) ;
283+ } ;
284+
285+ // Generation fails or is cancelled: the context stays for the retry, which then deletes it.
286+ writeFileSync ( store , '42\n' ) ;
287+ assert . equal ( attempt ( 'failure' , 'skipped' ) , null ) ;
288+ assert . equal ( attempt ( 'cancelled' , 'skipped' ) , null ) ;
289+ assert . ok ( stored ( ) ) ;
290+ let run = attempt ( 'success' , 'success' ) ;
291+ assert . equal ( run . status , 0 , run . stderr ) ;
292+ assert . ok ( ! stored ( ) ) ;
293+
294+ // Publish fails after generate used the context: it is deleted, and the retry tolerates that.
295+ writeFileSync ( store , '42\n' ) ;
296+ run = attempt ( 'success' , 'failure' ) ;
297+ assert . equal ( run . status , 0 , run . stderr ) ;
298+ assert . ok ( ! stored ( ) ) ;
299+ run = attempt ( 'success' , 'success' ) ;
300+ assert . equal ( run . status , 0 , run . stderr ) ;
301+ assert . match ( run . stdout , / a l r e a d y d e l e t e d / ) ;
302+
303+ run = attempt ( 'success' , 'success' , { SERVER_ERROR : '1' } ) ;
304+ assert . notEqual ( run . status , 0 ) ;
305+ assert . match ( run . stderr , / H T T P 5 0 0 / ) ;
306+ assert . equal ( attempt ( 'success' , 'skipped' , { } , '' ) , null ) ;
307+ } finally {
308+ rmSync ( dir , { recursive : true , force : true } ) ;
309+ }
310+ } ) ;
311+
312+ test ( `${ file } : the agent compares against the main commit the branch was rebuilt on` , ( ) => {
313+ const text = readFileSync ( new URL ( `../prompts/${ prompt } ` , import . meta. url ) , 'utf8' ) ;
314+ assert . match (
315+ step ( 'generate' , agentStep ) ,
316+ new RegExp ( `@\\.\\./\\.github/prompts/${ prompt . replace ( '.' , '\\.' ) } ` ) ,
317+ ) ;
318+ assert . match ( text , / d i f f a g a i n s t ` \$ M A I N _ S H A ` / ) ;
319+ assert . doesNotMatch ( text . replaceAll ( '`origin/main` may have moved since' , '' ) , / o r i g i n \/ m a i n / ) ;
320+ assert . match ( jobText . generate , / \n M A I N _ S H A : \$ \{ \{ n e e d s \. c o n t e x t \. o u t p u t s \. m a i n _ s h a \} \} \n / ) ;
321+ } ) ;
322+
216323 test ( `${ file } : generate rebuilds exactly the branch the context job prepared` , ( ) => {
217324 const script = runScript ( step ( 'generate' , 'Rebuild update branch' ) ) ;
218325 const dir = mkdtempSync ( join ( tmpdir ( ) , 'rebuild-' ) ) ;
@@ -259,6 +366,9 @@ for (const { file, agentStep, context } of updaters) {
259366 assert . equal ( git ( 'rev-parse' , 'HEAD^{tree}' ) , rebasedTree ) ;
260367 assert . equal ( rebased . output , `agent_base=${ git ( 'rev-parse' , 'HEAD' ) } \n` ) ;
261368
369+ // origin/main can move after the context job; the agent diffs against MAIN_SHA.
370+ git ( 'switch' , '-q' , '--detach' , main ) ;
371+ git ( 'update-ref' , 'refs/remotes/origin/main' , commit ( 'd.mdx' , 'd\n' , 'main moved again' ) ) ;
262372 const fromMain = run ( {
263373 REBASED : '' ,
264374 UPDATE_SHA : update ,
@@ -267,6 +377,8 @@ for (const { file, agentStep, context } of updaters) {
267377 } ) ;
268378 assert . equal ( fromMain . status , 0 , fromMain . stderr ) ;
269379 assert . equal ( git ( 'rev-parse' , 'HEAD' ) , main ) ;
380+ assert . equal ( git ( 'diff' , '--name-only' , main ) , '' ) ;
381+ assert . equal ( git ( 'diff' , '--name-only' , 'origin/main' ) , 'd.mdx' ) ;
270382
271383 const moved = run ( {
272384 REBASED : 'true' ,
0 commit comments