@@ -20,6 +20,7 @@ import {
2020 plainData ,
2121 type SectionPlan ,
2222} from "../contract/plan.js" ;
23+ import { projectOntoSchema , readOrNote } from "../shared/snapshot-helpers.js" ;
2324import { ActionsConfig } from "./schema.js" ;
2425
2526const permission : SectionPermission = { repo : [ "administration" ] } ;
@@ -205,10 +206,13 @@ function sameClaimKeyOrder(declared: readonly string[], live: readonly string[])
205206 */
206207const LiveOidcSub = z . looseObject ( { include_claim_keys : z . array ( z . string ( ) ) . nullish ( ) } ) ;
207208
209+ /** The base-permissions keys as the primary read reports them; the allowlist read hangs off the policy. */
210+ type BasePermissions = Pick < ActionsConfig , "enabled" | "allowed_actions" > ;
211+
208212/**
209- * A key served by its own endpoint pair. The routing table holds the handler
210- * itself , so a routed key without a handler cannot exist; a function-valued
211- * property , not method shorthand, so the per-key value types check strictly.
213+ * A key served by its own endpoint pair. The routing table holds the handlers
214+ * themselves , so a routed key without one cannot exist; function-valued
215+ * properties , not method shorthand, so the per-key value types check strictly.
212216 */
213217interface RoutedDestination < K extends keyof ActionsConfig > {
214218 /** Read the live state and plan the write the declared value is due. */
@@ -218,12 +222,18 @@ interface RoutedDestination<K extends keyof ActionsConfig> {
218222 declared : NonNullable < ActionsConfig [ K ] > ,
219223 plan : ActionsPlan ,
220224 ) => Promise < void > ;
225+ /** Read the live state back as the settings file would declare it; undefined when none applies. */
226+ snapshot : (
227+ ctx : ActionsContext ,
228+ section : SectionMeta ,
229+ base : BasePermissions ,
230+ ) => Promise < ActionsConfig [ K ] > ;
221231}
222232
223233/**
224- * The standard routed key: GET, diff under `label`, PUT the body on drift.
225- * ` N` is inferred from the GET alone so a PUT of another name does not
226- * compile; a non-object value (access_level) must say how it becomes a body.
234+ * The standard routed key: GET, diff under `label`, PUT the body on drift; the snapshot is the
235+ * GET read back through `read`. ` N` is inferred from the GET alone so a PUT of another name does
236+ * not compile; a non-object value (access_level) must say how it becomes a body.
227237 */
228238export function endpointRouted < K extends keyof ActionsConfig , N extends string > (
229239 wiring : {
@@ -237,6 +247,8 @@ export function endpointRouted<K extends keyof ActionsConfig, N extends string>(
237247 applied : string ;
238248 /** describe prose for the PUT, where the section spells one. */
239249 describe ?: string ;
250+ /** The GET body as the settings file declares the key (the inverse of `body`). */
251+ read : ( live : unknown ) => ActionsConfig [ K ] ;
240252 } & ( NonNullable < ActionsConfig [ K ] > extends Record < string , unknown >
241253 ? { body ?: ( declared : NonNullable < ActionsConfig [ K ] > ) => Record < string , unknown > }
242254 : { body : ( declared : NonNullable < ActionsConfig [ K ] > ) => Record < string , unknown > } ) ,
@@ -259,9 +271,16 @@ export function endpointRouted<K extends keyof ActionsConfig, N extends string>(
259271 } ) ;
260272 }
261273 } ,
274+ snapshot : async ( ctx ) => wiring . read ( await ctx . read [ wiring . get ] . call ( ) ) ,
262275 } ;
263276}
264277
278+ /** The live body of one routed key projected onto its slice of the section shape. */
279+ function sliceOf < K extends keyof ActionsConfig > ( key : K ) : ( live : unknown ) => ActionsConfig [ K ] {
280+ const slice : z . ZodType = ActionsConfig . shape [ key ] ;
281+ return ( live ) => projectOntoSchema ( slice , live ) as ActionsConfig [ K ] ;
282+ }
283+
265284// Forward-compatible key routing: every DECLARED ActionsConfig key names its
266285// destination here - "base" and "workflow" keys merge into those two PUT
267286// bodies, and a key with its own endpoint pair carries the HANDLER that
@@ -294,6 +313,15 @@ const KEY_DESTINATION = {
294313 } ) ;
295314 }
296315 } ,
316+ // The allowlist exists only under the "selected" policy (the GET 409s otherwise), so no
317+ // other policy reads it: an allowlist beside another policy is a document the shape rejects.
318+ snapshot : async ( ctx , _section , base ) => {
319+ if ( base . allowed_actions !== "selected" ) {
320+ return undefined ;
321+ }
322+ const probe = await ctx . read . getSelected . probeAbsent ( ) ;
323+ return "missing" in probe ? undefined : sliceOf ( "selected_actions" ) ( probe . data ) ;
324+ } ,
297325 } ,
298326 default_workflow_permissions : "workflow" ,
299327 can_approve_pull_request_reviews : "workflow" ,
@@ -303,13 +331,15 @@ const KEY_DESTINATION = {
303331 label : "actions.access" ,
304332 applied : "applied workflows access level" ,
305333 body : ( value ) => ( { access_level : value } ) ,
334+ read : ( live ) => sliceOf ( "access_level" ) ( ( live as { access_level ?: unknown } ) . access_level ) ,
306335 } ) ,
307336 artifact_and_log_retention : endpointRouted ( {
308337 get : "getRetention" ,
309338 put : "putRetention" ,
310339 label : "actions.artifact_and_log_retention" ,
311340 applied : "applied artifact and log retention" ,
312341 describe : "setting the artifact and log retention window" ,
342+ read : sliceOf ( "artifact_and_log_retention" ) ,
313343 } ) ,
314344 cache : {
315345 plan : async ( ctx , _section , declared , plan ) => {
@@ -332,6 +362,14 @@ const KEY_DESTINATION = {
332362 }
333363 }
334364 } ,
365+ // Each limit is its own GET; merged, the strict cache slice keeps exactly the two keys.
366+ snapshot : async ( ctx ) => {
367+ const limits : Record < string , unknown > = { } ;
368+ for ( const wiring of Object . values ( CACHE_ENDPOINT_BY_KEY ) ) {
369+ Object . assign ( limits , await ctx . read [ wiring . get ] . call ( ) ) ;
370+ }
371+ return sliceOf ( "cache" ) ( limits ) ;
372+ } ,
335373 } ,
336374 oidc_customization_sub : {
337375 plan : async ( ctx , section , declared , plan ) => {
@@ -370,20 +408,26 @@ const KEY_DESTINATION = {
370408 } ) ;
371409 }
372410 } ,
411+ snapshot : async ( ctx , section ) =>
412+ sliceOf ( "oidc_customization_sub" ) (
413+ parseLive ( section , ENDPOINTS . getOidcSub , LiveOidcSub , await ctx . read . getOidcSub . call ( ) ) ,
414+ ) ,
373415 } ,
374416 fork_pr_contributor_approval : endpointRouted ( {
375417 get : "getForkPrApproval" ,
376418 put : "putForkPrApproval" ,
377419 label : "actions.fork_pr_contributor_approval" ,
378420 applied : "applied the fork PR contributor approval policy" ,
379421 describe : "setting the fork PR contributor approval policy" ,
422+ read : sliceOf ( "fork_pr_contributor_approval" ) ,
380423 } ) ,
381424 fork_pr_workflows_private_repos : endpointRouted ( {
382425 get : "getForkPrPrivate" ,
383426 put : "putForkPrPrivate" ,
384427 label : "actions.fork_pr_workflows_private_repos" ,
385428 applied : "applied the private-repo fork PR workflow settings" ,
386429 describe : "setting the private-repo fork PR workflow settings" ,
430+ read : sliceOf ( "fork_pr_workflows_private_repos" ) ,
387431 } ) ,
388432} satisfies { [ K in keyof ActionsConfig ] -?: "base" | "workflow" | RoutedDestination < K > } ;
389433
@@ -426,6 +470,20 @@ async function planRouted<K extends RoutedKey>(
426470 await ROUTED_DESTINATIONS [ key ] . plan ( ctx , section , declared , plan ) ;
427471}
428472
473+ /** Read one routed key back; generic so the handler and the value stay correlated to one key. */
474+ async function snapshotRouted < K extends RoutedKey > (
475+ key : K ,
476+ ctx : ActionsContext ,
477+ section : SectionMeta ,
478+ base : BasePermissions ,
479+ notes : string [ ] ,
480+ ) : Promise < ActionsConfig [ K ] > {
481+ const read = await readOrNote ( notes , `actions.${ key } ` , ( ) =>
482+ ROUTED_DESTINATIONS [ key ] . snapshot ( ctx , section , base ) ,
483+ ) ;
484+ return "denied" in read ? undefined : read . value ;
485+ }
486+
429487function keysTo ( destination : "base" | "workflow" ) : Set < string > {
430488 return new Set (
431489 Object . entries ( KEY_DESTINATION )
@@ -520,4 +578,27 @@ export const actionsSection = {
520578 }
521579 return plan ;
522580 } ,
581+ /**
582+ * The base permissions (the primary read; a denial classifies like any section's), the workflow
583+ * token defaults, and every routed key read back onto its slice of the shape. A key the token
584+ * cannot read is a note naming it, never a failure of the section.
585+ */
586+ async snapshot ( ctx ) {
587+ const notes : string [ ] = [ ] ;
588+ const base = projectOntoSchema ( ActionsConfig , await ctx . read . getPermissions . call ( ) ) ;
589+ const value : Record < string , unknown > = { ...base } ;
590+ const workflow = await readOrNote ( notes , `actions.${ [ ...WORKFLOW_KEYS ] . join ( "/" ) } ` , ( ) =>
591+ ctx . read . getWorkflow . call ( ) ,
592+ ) ;
593+ if ( ! ( "denied" in workflow ) ) {
594+ Object . assign ( value , projectOntoSchema ( ActionsConfig , workflow . value ) ) ;
595+ }
596+ for ( const key of ROUTED_KEYS ) {
597+ const read = await snapshotRouted ( key , ctx , this , base , notes ) ;
598+ if ( read !== undefined ) {
599+ value [ key ] = read ;
600+ }
601+ }
602+ return { value : value as ActionsConfig , notes } ;
603+ } ,
523604} satisfies SectionModule < "actions" , typeof ENDPOINTS > ;
0 commit comments