Skip to content

Commit 3b57545

Browse files
committed
refactor(sections)!: environments plan through the secrets and variables engines; the sealing key is read at apply
The secrets engine reads the scope's sealing key from the first sealed PUT's thunk (once per scope), so the four repo families declare the key endpoint execution-phase and environments' nested secrets share the engine; the variables engine serves environments' nested variables too. Both engines take the scope's home noun and change-line suffix and render through the shared prose helpers; the variables engine proves every payload plain. The per-list duplicate rejecters in environments give way to rejectDuplicates with a resource noun. BREAKING CHANGE: check mode no longer reads the sealing key (a malformed key fails the first PUT at apply); the environment secret and variable lines use the engines' wording. docs/upgrading/v2-to-v3.md sections 10-11.
1 parent bb6fd9d commit 3b57545

15 files changed

Lines changed: 363 additions & 364 deletions

File tree

‎docs/upgrading/v2-to-v3.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ order: 20
44

55
# Upgrading from v2 to v3
66

7-
Fifteen breaks (the ninth is for library consumers). One is silent (the fallback), so run `mode: check` before the first v3 apply. The changelog entry for 3.0.0 will carry the release-please footers in the [CHANGELOG](https://github.com/Vivswan/github-settings-as-code/blob/main/CHANGELOG.md).
7+
Seventeen breaks (the ninth is for library consumers). One is silent (the fallback), so run `mode: check` before the first v3 apply. The changelog entry for 3.0.0 will carry the release-please footers in the [CHANGELOG](https://github.com/Vivswan/github-settings-as-code/blob/main/CHANGELOG.md).
88

99
| Break | v2 | v3 | What the old form does now |
1010
|---|---|---|---|
@@ -23,6 +23,8 @@ Fifteen breaks (the ninth is for library consumers). One is silent (the fallback
2323
| One wording per concept in drift lines and notes | Per-section spellings of "cannot verify", "left out", and field drift; quoted webhook labels | One template each on the converted sites | Only a grep over the output notices; [section 13](#13-one-wording-per-concept-in-drift-lines-and-notes) |
2424
| Webhooks manage web hooks only | A service hook was matched and deleted like any other | A service hook or url-less hook is outside the section | It is left alone and noted by snapshot; [section 14](#14-webhooks-manage-web-hooks-only) |
2525
| A ruleset without `source_type` is repository-owned | Kept with a note under `_undeclared: delete` | Deleted like any other undeclared repository ruleset | [section 15](#15-a-ruleset-without-source_type-is-repository-owned) |
26+
| The sealing key is read at apply time | Check mode read `GET .../secrets/public-key` and failed on a malformed key | The first sealed PUT reads it at apply | Check mode issues one request fewer per secret family; a malformed key fails at apply; [section 16](#16-the-sealing-key-is-read-at-apply-time) |
27+
| Environment secrets and variables plan through the shared engines | Their own wording | The engines' wording | Only a grep over the output notices; [section 17](#17-environment-secrets-and-variables-plan-through-the-shared-engines) |
2628

2729
## 1. The defaults-file fallback
2830

@@ -241,6 +243,27 @@ The other cannot-verify and omission notes (interaction_limits, teams, collabora
241243

242244
v2 refused to delete an undeclared ruleset whose list entry lacked `source_type`, with a note. v3 reads a missing `source_type` as `Repository`, the only kind the repository endpoints can write, so `_undeclared: delete` deletes it.
243245

246+
## 16. The sealing key is read at apply time
247+
248+
Every secret family (`actions_secrets`, `dependabot_secrets`, `codespaces_secrets`, `agents_secrets`) now reads `GET .../secrets/public-key` from the first sealed PUT, as environment secrets always did.
249+
250+
- Check mode issues one request fewer per family and never touches the key endpoint.
251+
- A malformed key fails the first PUT at apply, before its request leaves, where v2 failed the plan in check mode too.
252+
- The cannot-verify note carries the section label and the shared template: `actions_secrets: Actions secret values cannot be read back from GitHub, so check mode cannot verify them, only that each declared secret exists; apply re-seals and rewrites every declared value on every run`.
253+
254+
## 17. Environment secrets and variables plan through the shared engines
255+
256+
Their lines are the engines' lines now:
257+
258+
| Line | v2 | v3 |
259+
|---|---|---|
260+
| A kept undeclared variable | `variable "X" exists on environment "prod" but is not declared ...` | `variable "X" exists on the environment but is not declared ...` |
261+
| A variable delete | `DELETED undeclared variable "X" from environment "prod"` | `DELETED undeclared variable "X" in environment "prod"` |
262+
| Two entries naming one variable or secret | `environments: the "prod" entry declares variables that GitHub treats as the same variable ...` | `environments: the settings file declares entries that name the same variable of the "prod" environment: "a" and "A". Keep exactly one entry per resource` (branch policies and protection rules spell theirs the same way) |
263+
| The secrets cannot-verify note | once per environment that exists | once per environment with declared secrets, the missing environment included, under the `environments[prod].secrets` label |
264+
265+
Repository variable operations (`actions_variables`, `agents_variables`) gain a describe line in failure prose (`creating Actions variable "X"`); nothing else moves.
266+
244267
## Order of operations
245268

246269
1. Rename any settings file whose path contains a comma, and rename `undeclared` to `_undeclared` in every settings file; the v2 line accepts the old spelling only, so do both together with the pin move.

‎src/sections/actions_secrets/actions_secrets.test.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const LIST = "GET /repos/o/r/actions/secrets?per_page=100&page=1";
2020
const PUBLIC_KEY = "GET /repos/o/r/actions/secrets/public-key";
2121
const KEY_ROUTE = { data: { key_id: "test-key-id", key: MOCK_SECRETS_PUBLIC_KEY } };
2222
const CANNOT_VERIFY =
23-
"Actions secret values cannot be read back from GitHub, so check mode verifies only that each declared secret exists; apply re-seals and rewrites every declared value on each run";
23+
"actions_secrets: Actions secret values cannot be read back from GitHub, so check mode cannot verify them, only that each declared secret exists; apply re-seals and rewrites every declared value on every run";
2424

2525
type Declared = Parameters<typeof actionsSecretsSection.plan>[1];
2626

@@ -141,9 +141,9 @@ describe("actions_secrets planning", () => {
141141
notes: [CANNOT_VERIFY],
142142
drift: [],
143143
});
144+
// The sealing key is an execution-time read, so check mode issues the list alone.
144145
expect(api.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
145146
"GET /repos/o/r/actions/secrets?per_page=100&page=1",
146-
"GET /repos/o/r/actions/secrets/public-key",
147147
]);
148148
});
149149

@@ -156,9 +156,15 @@ describe("actions_secrets planning", () => {
156156
]);
157157
// Nothing at plan time could have resolved a reference: the resolver is first consulted when a thunk runs.
158158
const exec = tools({ $A: "value-a", $B: "value-b" });
159-
const payloads = result.ops.map((op) =>
160-
typeof op.payload === "function" ? sealedPayload({ payload: op.payload(exec) }) : null,
159+
const payloads = await Promise.all(
160+
result.ops.map(async (op) =>
161+
typeof op.payload === "function"
162+
? sealedPayload({ payload: await op.payload(exec) })
163+
: null,
164+
),
161165
);
166+
// Both thunks share one key read, issued when the first ran.
167+
expect(api.calls.filter((c) => c.path.endsWith("/public-key")).length).toBe(1);
162168
expect(exec.lookups).toEqual(["$A", "$B"]);
163169
expect(payloads.map((p) => p?.key_id)).toEqual(["test-key-id", "test-key-id"]);
164170
expect(payloads.map((p) => unsealSecretValue(p?.encrypted_value ?? ""))).toEqual([
@@ -413,20 +419,24 @@ describe("actions_secrets execution", () => {
413419
});
414420

415421
describe("actions_secrets sealing key", () => {
416-
test("an unusable public key fails the plan loudly, so no operation exists to execute", async () => {
422+
test("an unusable public key fails the first PUT before its request leaves; check mode never reads the key", async () => {
417423
const api = new MockApi({ [LIST]: listOf(), [PUBLIC_KEY]: { data: { key: 42 } } });
418-
await expect(plan(api, [{ name: "X", value: "$V" }])).rejects.toThrow(
424+
const planned = await plan(api, [{ name: "X", value: "$V" }]);
425+
expect(planned.ops.map((op) => op.role)).toEqual(["put"]);
426+
await expect(apply(api, [{ name: "X", value: "$V" }], tools({ $V: "v" }))).rejects.toThrow(
419427
/no usable \{key_id, key\} pair \(key_id is missing\)/,
420428
);
429+
expect(api.mutations()).toEqual([]);
421430

422431
// GitHub requires key_id in the PUT body to route the ciphertext, so an empty one is as unusable as a missing one.
423432
const emptyId = new MockApi({
424433
[LIST]: listOf(),
425434
[PUBLIC_KEY]: { data: { key_id: "", key: MOCK_SECRETS_PUBLIC_KEY } },
426435
});
427-
await expect(plan(emptyId, [{ name: "X", value: "$V" }])).rejects.toThrow(
436+
await expect(apply(emptyId, [{ name: "X", value: "$V" }], tools({ $V: "v" }))).rejects.toThrow(
428437
/no usable \{key_id, key\} pair \(key_id is empty\)/,
429438
);
439+
expect(emptyId.mutations()).toEqual([]);
430440
});
431441
});
432442

‎src/sections/actions_variables/actions_variables.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ describe("actions_variables", () => {
9999
'actions_variables[DEPLOY_REGION].value: declared "eu-west-1" != live "us-east-1"; apply will set the declared value',
100100
],
101101
change: 'updated Actions variable "DEPLOY_REGION"',
102+
describe: 'updating Actions variable "DEPLOY_REGION"',
102103
},
103104
{
104105
role: "create",
@@ -107,6 +108,7 @@ describe("actions_variables", () => {
107108
"actions_variables[BUILD_MODE]: missing - declared in the settings file but not on the repo; apply will create it",
108109
],
109110
change: 'created Actions variable "BUILD_MODE"',
111+
describe: 'creating Actions variable "BUILD_MODE"',
110112
},
111113
{
112114
role: "remove",
@@ -115,6 +117,7 @@ describe("actions_variables", () => {
115117
"actions_variables[RETIRED_FLAG]: undeclared - not in the settings file, so apply will DELETE it; add it to the settings file to keep it",
116118
],
117119
change: 'DELETED undeclared Actions variable "RETIRED_FLAG"',
120+
describe: 'deleting undeclared Actions variable "RETIRED_FLAG"',
118121
},
119122
],
120123
notes: [],
@@ -168,6 +171,22 @@ describe("actions_variables", () => {
168171
expect(api.calls).toHaveLength(0);
169172
});
170173

174+
test("a passthrough value JSON cannot carry fails the plan at the payload, on a create and on an update alike", async () => {
175+
// The loose shape admits any extra key; the engine proves the body plain before it is planned, so
176+
// NaN (which JSON would turn into null) never reaches the wire as a silent change of value.
177+
const api = new MockApi(listRoute([{ name: "PRESENT", value: "x" }]));
178+
const odd = { name: "NEW", value: "v", extra: Number.NaN };
179+
await expect(plan(api, [odd as never])).rejects.toThrow(
180+
/a planned payload carries a value JSON cannot carry at extra/,
181+
);
182+
await expect(plan(api, [{ ...odd, name: "PRESENT" } as never])).rejects.toThrow(
183+
/a planned payload carries a value JSON cannot carry at extra/,
184+
);
185+
// The control: a plain extra key rides through.
186+
const plain = await plan(api, [{ name: "NEW", value: "v", extra: 42 } as never]);
187+
expect(plain.ops.map((op) => op.payload)).toEqual([{ name: "NEW", value: "v", extra: 42 }]);
188+
});
189+
171190
test("wrapped _undeclared:keep leaves the undeclared variable as a note, never a DELETE", async () => {
172191
const api = new MockApi(listRoute(liveVariables));
173192
const result = await plan(api, {

‎src/sections/contract/endpoints.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ export interface GatedReadDecl extends EndpointDeclFields {
8484
readonly accessGrade: "write";
8585
readonly alwaysRewrite?: never;
8686
readonly unverifiable?: never;
87-
readonly phase?: never;
87+
/** As on a plain GET: "execution" gates the read behind a thunk's token (the Codespaces sealing key). */
88+
readonly phase?: "execution";
8889
}
8990

9091
interface EndpointDeclFields {

‎src/sections/environments/branch-policies.ts‎

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { UndeclaredPolicy } from "../../types.js";
99
import { liveByIdentity, parseLive } from "../contract/live.js";
1010
import { type SectionMeta, undeclaredDrift, undeclaredNote } from "../contract/module.js";
1111
import { hasDrift, plainData } from "../contract/plan.js";
12+
import { rejectDuplicates } from "../contract/requests.js";
1213
import { ENDPOINTS, type EnvironmentRestOp, type EnvironmentsRestContext } from "./endpoints.js";
1314
import type { NestedPlan } from "./nested.js";
1415
import type { DeploymentBranchPolicyConfig, EnvironmentConfig } from "./schema.js";
@@ -59,23 +60,17 @@ function livePolicyName(policy: LiveBranchPolicy, envName: string): string {
5960
* the zod shape (schema.ts), not here, so it fails before any section writes.
6061
*/
6162
export function validateBranchPolicies(
63+
section: SectionMeta,
6264
env: EnvironmentConfig,
6365
entries: readonly DeploymentBranchPolicyConfig[],
6466
): void {
65-
const seen = new Set<string>();
66-
const duplicates = new Set<string>();
67-
for (const pattern of entries) {
68-
if (seen.has(pattern.name)) {
69-
duplicates.add(pattern.name);
70-
}
71-
seen.add(pattern.name);
72-
}
73-
if (duplicates.size > 0) {
74-
throw new Error(
75-
`environments: the "${env.name}" entry declares deployment branch polic${duplicates.size === 1 ? "y" : "ies"} ` +
76-
`${[...duplicates].map((name) => `"${name}"`).join(", ")} more than once. Keep exactly one entry per pattern`,
77-
);
78-
}
67+
rejectDuplicates(
68+
section,
69+
entries,
70+
(pattern) => pattern.name,
71+
(pattern) => pattern.name,
72+
`deployment branch policy of the "${env.name}" environment`,
73+
);
7974
}
8075

8176
function createPolicyOp(

‎src/sections/environments/environments.test.ts‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe("environments plan", () => {
9999
'applied environment "prod"',
100100
'created variable "NEW" in environment "prod"',
101101
'updated variable "UPD" in environment "prod"',
102-
'DELETED undeclared variable "GONE" from environment "prod"',
102+
'DELETED undeclared variable "GONE" in environment "prod"',
103103
]);
104104
// The strip builds a fresh object, so the duplicate pre-pass (which reads env.variables across all entries) never sees a mutated declaration.
105105
expect(declared[0]?.variables).toEqual([
@@ -292,7 +292,7 @@ describe("environments variables case-insensitive matching", () => {
292292
},
293293
]),
294294
).rejects.toThrow(
295-
'environments: the "prod" entry declares variables that GitHub treats as the same variable (names are case-insensitive): "Region" and "REGION". Keep exactly one entry per variable',
295+
'environments: the settings file declares entries that name the same variable of the "prod" environment: "Region" and "REGION". Keep exactly one entry per resource',
296296
);
297297
expect(api.calls).toEqual([]);
298298
});
@@ -308,7 +308,7 @@ describe("environments variables undeclared policy", () => {
308308
{ name: "prod", variables: { _undeclared: "keep", entries: [] } },
309309
]);
310310
expect(result.notes).toEqual([
311-
'variable "LEGACY" exists on environment "prod" but is not declared in the settings file; kept under "_undeclared: keep" - add it to the settings file to manage it, or set "_undeclared: delete" to have apply DELETE it',
311+
'variable "LEGACY" exists on the environment but is not declared in the settings file; kept under "_undeclared: keep" - add it to the settings file to manage it, or set "_undeclared: delete" to have apply DELETE it',
312312
]);
313313
expect(api.mutations()).toEqual([]);
314314
});
@@ -319,9 +319,7 @@ describe("environments variables undeclared policy", () => {
319319
[VARIABLES_LIST]: variablesBody([{ name: "LEGACY", value: "x" }]),
320320
}).allowMutations("DELETE /repos/o/r/environments/prod/variables/LEGACY");
321321
const result = await apply(api, [{ name: "prod", variables: [] }]);
322-
expect(result.changes).toEqual([
323-
'DELETED undeclared variable "LEGACY" from environment "prod"',
324-
]);
322+
expect(result.changes).toEqual(['DELETED undeclared variable "LEGACY" in environment "prod"']);
325323
});
326324

327325
test("check mode under _undeclared:keep converges (note, not drift)", async () => {
@@ -585,7 +583,7 @@ describe("environments nested secrets validation and shape", () => {
585583
],
586584
},
587585
]),
588-
).rejects.toThrow(/"prod" entry declares secrets .*"token" and "TOKEN"/);
586+
).rejects.toThrow(/the same secret of the "prod" environment: "token" and "TOKEN"/);
589587
expect(api.calls).toEqual([]);
590588
});
591589

@@ -811,7 +809,7 @@ describe("environments deployment branch policies validation and shape", () => {
811809
await expect(
812810
plan(api, [envWithPolicies([{ name: "release/*" }, { name: "release/*", type: "tag" }])]),
813811
).rejects.toThrow(
814-
'environments: the "prod" entry declares deployment branch policy "release/*" more than once. Keep exactly one entry per pattern',
812+
'environments: the settings file declares entries that name the same deployment branch policy of the "prod" environment: "release/*" and "release/*". Keep exactly one entry per resource',
815813
);
816814
expect(api.calls).toEqual([]);
817815
});
@@ -1137,14 +1135,22 @@ describe("environments missing-environment planning across the nested families",
11371135
],
11381136
] as Array<[string, EnvironmentConfig, string, string, string]>)(
11391137
"%s: the sub-resource read is skipped, the note says it is unverifiable, and the create is planned",
1140-
async (_key, entry, expectedNote, subResourcePath, createDrift) => {
1138+
async (key, entry, expectedNote, subResourcePath, createDrift) => {
11411139
const api = new MockApi({});
11421140
const result = await check(api, [entry]);
11431141
expect(result.drift).toEqual([
11441142
"environments[prod]: missing - declared in the settings file but not on the repo; apply will create it",
11451143
createDrift,
11461144
]);
1147-
expect(result.notes).toEqual([expectedNote]);
1145+
// The secrets engine states once per scope that values never read back, beside the missing-environment note.
1146+
expect(result.notes).toEqual(
1147+
key === "secrets"
1148+
? [
1149+
expectedNote,
1150+
"environments[prod].secrets: prod environment secret values cannot be read back from GitHub, so check mode cannot verify them, only that each declared secret exists; apply re-seals and rewrites every declared value on every run",
1151+
]
1152+
: [expectedNote],
1153+
);
11481154
expect(api.calls.filter((c) => c.path.includes(subResourcePath))).toEqual([]);
11491155
},
11501156
);
@@ -1161,7 +1167,7 @@ describe("environments deployment protection rules validation and shape", () =>
11611167
},
11621168
]),
11631169
).rejects.toThrow(
1164-
'environments: the "prod" entry declares the deployment protection rule App "deploy-gate" more than once. Keep exactly one entry per App',
1170+
'environments: the settings file declares entries that name the same deployment protection rule App of the "prod" environment: "deploy-gate" and "deploy-gate". Keep exactly one entry per resource',
11651171
);
11661172
expect(api.calls).toEqual([]);
11671173
});
@@ -1358,7 +1364,7 @@ describe("environments convergence", () => {
13581364
'applied environment "prod"',
13591365
'created variable "DEPLOY_REGION" in environment "prod"',
13601366
'updated variable "log_level" in environment "prod"',
1361-
'DELETED undeclared variable "GONE" from environment "prod"',
1367+
'DELETED undeclared variable "GONE" in environment "prod"',
13621368
'updated secret "DEPLOY_TOKEN" in environment "prod"',
13631369
'created deployment branch policy "release/*" in environment "prod"',
13641370
'deleted deployment branch policy "v*" in environment "prod" to change its immutable type (branch -> tag)',
@@ -1389,7 +1395,7 @@ describe("environments convergence", () => {
13891395
"PUT /repos/o/r/environments/prod/secrets/DEPLOY_TOKEN",
13901396
]);
13911397
expect(first.notes).toEqual([
1392-
"prod environment secret values cannot be read back from GitHub, so check mode verifies only that each declared secret exists; apply re-seals and rewrites every declared value on each run",
1398+
"environments[prod].secrets: prod environment secret values cannot be read back from GitHub, so check mode cannot verify them, only that each declared secret exists; apply re-seals and rewrites every declared value on every run",
13931399
'prod environment secret "KEPT" exists on the environment but is not declared in the ' +
13941400
'settings file; kept under "_undeclared: keep" - add it to the settings file to manage ' +
13951401
'it, or set "_undeclared: delete" to have apply DELETE it (a deleted secret\'s value is ' +
@@ -1471,7 +1477,7 @@ describe("environments convergence", () => {
14711477
[{ role: "putSecret", drift: [], change: 'updated secret "NEW" in environment "staging"' }],
14721478
);
14731479
expect(second.notes).toEqual([
1474-
"staging environment secret values cannot be read back from GitHub, so check mode verifies only that each declared secret exists; apply re-seals and rewrites every declared value on each run",
1480+
"environments[staging].secrets: staging environment secret values cannot be read back from GitHub, so check mode cannot verify them, only that each declared secret exists; apply re-seals and rewrites every declared value on every run",
14751481
]);
14761482
});
14771483
});

0 commit comments

Comments
 (0)