Skip to content

Commit f22528c

Browse files
author
settings-as-code-release
committed
feat(snapshot): repository, actions, and rulesets read back
The three bespoke sections gain snapshot(): repository reads back the documented PATCH fields (an allowlist pinned to the octokit PATCH body type, since the passthrough slice cannot tell server fields apart; name and null fields are left out), topics, every readable toggle, and the two GraphQL keys, noting Git LFS, an owner-enforced toggle, and any denied sub-read; actions reads every endpoint back onto its key through the routing table (each routed destination now carries its own snapshot), with a denied sub-read a note naming the key and the allowlist read only under the selected policy; rulesets reads repository-owned rulesets back as keep entries, noting an inherited ruleset and an invisible bypass_actors instead of claiming an empty list. The round-trip harness fake now dispatches GraphQL onto the mock's GraphQL handlers, which the repository row (and the GraphQL-reading sections still to come) needs; the engine test's supported-section pins and the changed-sections fan-out canary follow the new snapshot() declarations and import edges.
1 parent 92e27bd commit f22528c

16 files changed

Lines changed: 903 additions & 43 deletions

File tree

‎COVERAGE.md‎

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

‎src/sections/actions/actions.docs.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ coverage:
3535
Actions are on), and selected_actions with no allowed_actions infers allowed_actions:
3636
selected. RISK: a key GitHub adds that belongs on a NEW sub-endpoint gets routed to the base
3737
PUT where GitHub ignores it; audit the routing whenever GitHub adds a permissions
38-
sub-endpoint.
38+
sub-endpoint. Snapshot reads every endpoint back onto its key (the allowlist only under the
39+
selected policy; the read-only fields each GET adds, like maximum_allowed_days, fall away); a
40+
sub-endpoint the token cannot read, such as the OIDC template under a token without the
41+
Actions permission, is noted and left out rather than failing the section.
3942
schema:
4043
SettingsFile.actions: GitHub Actions permissions for the repository.
4144
ActionsConfig: GitHub Actions settings, routed to the right endpoint by key.

‎src/sections/actions/actions.test.ts‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MockApi } from "../../../test/mock-api.js";
66
import { provePlanIdempotent } from "../../../test/sections/plan-idempotence.js";
77
import { REPO } from "../../../test/sections/section-run.js";
88
import { PermissionDenied } from "../contract/errors.js";
9+
import { sectionGrant } from "../contract/module.js";
910
import { grantFor } from "../contract/permissions.js";
1011
import { actionsSection, endpointRouted } from "./index.js";
1112
// The casts on some plan() inputs below simulate keys GitHub adds: the
@@ -572,6 +573,7 @@ describe("actions", () => {
572573
label: "actions.access",
573574
applied: "applied",
574575
body: (value) => ({ access_level: value }),
576+
read: () => "none",
575577
});
576578
expect(typeof paired.plan).toBe("function");
577579
endpointRouted<"access_level", "Access">({
@@ -581,13 +583,15 @@ describe("actions", () => {
581583
label: "actions.access",
582584
applied: "applied",
583585
body: (value) => ({ access_level: value }),
586+
read: () => "none",
584587
});
585588
// @ts-expect-error an enum-valued key cannot be PUT bare: body is required
586589
endpointRouted<"access_level", "Access">({
587590
get: "getAccess",
588591
put: "putAccess",
589592
label: "actions.access",
590593
applied: "applied",
594+
read: () => "none",
591595
});
592596
});
593597

@@ -601,3 +605,127 @@ describe("actions", () => {
601605
const _silent: Op = silent;
602606
});
603607
});
608+
609+
describe("actions snapshot", () => {
610+
const snapshot = (api: GithubClient) =>
611+
actionsSection.snapshot(planContext(actionsSection, api, REPO));
612+
/** The note a sub-read the fake has no body for produces: its 404 classifies as a denial. */
613+
const leftOut = (key: string, path: string, grant = sectionGrant(actionsSection)) =>
614+
`actions.${key}: left out of the snapshot - the token was denied GET ${path}: 404 Not Found (a 404 here can also mean the resource does not exist). To fix, ${grant}`;
615+
616+
test("reads every endpoint back onto its key, dropping the server fields each GET adds", async () => {
617+
const api = liveActions({
618+
[BASE]: {
619+
enabled: true,
620+
allowed_actions: "selected",
621+
selected_actions_url: `https://api.github.com${BASE}/selected-actions`,
622+
},
623+
[`${BASE}/selected-actions`]: {
624+
github_owned_allowed: true,
625+
verified_allowed: false,
626+
patterns_allowed: ["actions/*"],
627+
},
628+
[`${BASE}/workflow`]: {
629+
default_workflow_permissions: "read",
630+
can_approve_pull_request_reviews: false,
631+
},
632+
[`${BASE}/access`]: { access_level: "organization" },
633+
[`${BASE}/artifact-and-log-retention`]: { days: 30, maximum_allowed_days: 400 },
634+
"/repos/o/r/actions/cache/retention-limit": { max_cache_retention_days: 5 },
635+
"/repos/o/r/actions/cache/storage-limit": { max_cache_size_gb: 20 },
636+
"/repos/o/r/actions/oidc/customization/sub": {
637+
use_default: false,
638+
include_claim_keys: ["repo", "context"],
639+
},
640+
[`${BASE}/fork-pr-contributor-approval`]: { approval_policy: "first_time_contributors" },
641+
[`${BASE}/fork-pr-workflows-private-repos`]: {
642+
run_workflows_from_fork_pull_requests: true,
643+
send_write_tokens_to_workflows: false,
644+
send_secrets_and_variables: false,
645+
require_approval_for_fork_pr_workflows: true,
646+
},
647+
});
648+
expect(await snapshot(api)).toEqual({
649+
value: {
650+
enabled: true,
651+
allowed_actions: "selected",
652+
default_workflow_permissions: "read",
653+
can_approve_pull_request_reviews: false,
654+
selected_actions: {
655+
github_owned_allowed: true,
656+
verified_allowed: false,
657+
patterns_allowed: ["actions/*"],
658+
},
659+
access_level: "organization",
660+
artifact_and_log_retention: { days: 30 },
661+
cache: { max_cache_retention_days: 5, max_cache_size_gb: 20 },
662+
oidc_customization_sub: { use_default: false, include_claim_keys: ["repo", "context"] },
663+
fork_pr_contributor_approval: { approval_policy: "first_time_contributors" },
664+
fork_pr_workflows_private_repos: {
665+
run_workflows_from_fork_pull_requests: true,
666+
send_write_tokens_to_workflows: false,
667+
send_secrets_and_variables: false,
668+
require_approval_for_fork_pr_workflows: true,
669+
},
670+
},
671+
notes: [],
672+
});
673+
expect(api.writes).toEqual([]);
674+
});
675+
676+
test("a denied sub-read is a note naming its key with the read's own grant; the allowlist is skipped off the selected policy", async () => {
677+
const live = liveActions({
678+
[BASE]: { enabled: true, allowed_actions: "all" },
679+
[`${BASE}/artifact-and-log-retention`]: { days: 90, maximum_allowed_days: 400 },
680+
"/repos/o/r/actions/cache/retention-limit": { max_cache_retention_days: 7 },
681+
"/repos/o/r/actions/cache/storage-limit": { max_cache_size_gb: 10 },
682+
[`${BASE}/fork-pr-contributor-approval`]: { approval_policy: "all_external_contributors" },
683+
});
684+
const requested: string[] = [];
685+
const api: GithubClient = {
686+
tryRequest: (method, path, payload) => {
687+
requested.push(`${method} ${path}`);
688+
return live.tryRequest(method, path, payload);
689+
},
690+
tryGraphql: live.tryGraphql,
691+
};
692+
const read = await snapshot(api);
693+
expect(read).toEqual({
694+
value: {
695+
enabled: true,
696+
allowed_actions: "all",
697+
artifact_and_log_retention: { days: 90 },
698+
cache: { max_cache_retention_days: 7, max_cache_size_gb: 10 },
699+
fork_pr_contributor_approval: { approval_policy: "all_external_contributors" },
700+
},
701+
notes: [
702+
leftOut(
703+
"default_workflow_permissions/can_approve_pull_request_reviews",
704+
`${BASE}/workflow`,
705+
),
706+
leftOut("access_level", `${BASE}/access`),
707+
leftOut(
708+
"oidc_customization_sub",
709+
"/repos/o/r/actions/oidc/customization/sub",
710+
grantFor({ repo: ["actions"] }, undefined, "write"),
711+
),
712+
`${leftOut("fork_pr_workflows_private_repos", `${BASE}/fork-pr-workflows-private-repos`)}. Note: the fork PR workflow settings are documented for private repositories, so a denial here can also mean the repository is public`,
713+
],
714+
});
715+
// Off the "selected" policy there is no allowlist to read, so the GET is never issued.
716+
expect(requested).not.toContain(`GET ${BASE}/selected-actions`);
717+
expect(live.writes).toEqual([]);
718+
});
719+
720+
test("a denied primary read fails the section like any other, carrying the grant advice", async () => {
721+
const api = liveActions({});
722+
let thrown: unknown;
723+
try {
724+
await snapshot(api);
725+
} catch (error) {
726+
thrown = error;
727+
}
728+
expect(thrown).toBeInstanceOf(PermissionDenied);
729+
expect((thrown as PermissionDenied).detail).toContain(sectionGrant(actionsSection));
730+
});
731+
});

‎src/sections/actions/index.ts‎

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
plainData,
2121
type SectionPlan,
2222
} from "../contract/plan.js";
23+
import { projectOntoSchema, readOrNote } from "../shared/snapshot-helpers.js";
2324
import { ActionsConfig } from "./schema.js";
2425

2526
const permission: SectionPermission = { repo: ["administration"] };
@@ -205,10 +206,13 @@ function sameClaimKeyOrder(declared: readonly string[], live: readonly string[])
205206
*/
206207
const 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
*/
213217
interface 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
*/
228238
export 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+
429487
function 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

Comments
 (0)