Skip to content

fix(environments): name a key GitHub only reports or does not echo on an entry or a nested entry - #604

Merged
Vivswan merged 5 commits into
mainfrom
fix/environments-undeclared-keys
Nov 12, 2026
Merged

Vivswan merged 5 commits into
mainfrom
fix/environments-undeclared-keys

Conversation

@Vivswan

@Vivswan Vivswan commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Before

$ TMPDIR="$(mktemp -d)" bun probe.mts /repo
input                   start                 apply                         check after  notes naming the key (check, apply, check after)
A entry id: 1           environment missing   failed  PUT 422               drift        0 0 0
A entry id: 1           live, id 1            applied  no write             clean        0 0 0
A entry id: 1           live, id 99           failed  PUT 422               drift        0 0 0
B variable updated_at   variable missing      applied  POST 201             drift        0 0 0
B variable updated_at   live, another stamp   applied  PATCH 204            drift        0 0 0
B variable updated_at   live, the same stamp  applied  no write             clean        0 0 0
C pattern id: 7         pattern missing       applied  POST 200             clean        0 0 0
C pattern id: 7         pattern live, id 41   applied  no write             clean        0 0 0
D pattern typ: tag      pattern missing       applied  POST 200             clean        0 0 0
D pattern typ: tag      pattern live          applied  no write             clean        0 0 0
control wait_timer: 5   environment live      applied  PUT 200              clean        0 0 0

no note names a key
$ bun test/e2e/run.ts --scenario environment-branch-policies-unknown-key-noted --print-stdout | grep -E '::notice::|drift: |result: '
        result: clean

After

$ TMPDIR="$(mktemp -d)" bun probe.mts /repo
input                   start                 apply                         check after  notes naming the key (check, apply, check after)
A entry id: 1           environment missing   failed  PUT 422               drift        1 1 1
A entry id: 1           live, id 1            applied  no write             clean        1 1 1
A entry id: 1           live, id 99           failed  PUT 422               drift        1 1 1
B variable updated_at   variable missing      applied  POST 201             drift        1 1 1
B variable updated_at   live, another stamp   applied  PATCH 204            drift        1 1 1
B variable updated_at   live, the same stamp  applied  no write             clean        1 1 1
C pattern id: 7         pattern missing       applied  POST 200             clean        1 1 1
C pattern id: 7         pattern live, id 41   applied  no write             clean        1 1 1
D pattern typ: tag      pattern missing       applied  POST 200             clean        0 0 1
D pattern typ: tag      pattern live          applied  no write             clean        1 1 1
control wait_timer: 5   environment live      applied  PUT 200              clean        0 0 0

::notice::environments[prod]: "id" is a value GitHub reports, not a setting it accepts (the GET returns it, the PUT does not take it), so GitHub answers this environment's PUT with a 422 whenever apply sends one; remove it from the settings file
::notice::environments[prod].variables[X]: "updated_at" is a value GitHub reports, not a setting it accepts (the GET returns it, neither the POST nor the PATCH takes it), so a declared value that differs from the live one drifts on every check and its update re-runs on every apply without converging; remove it from the settings file
::notice::environments[prod].deployment_branch_policies[main]: "id" is a value GitHub reports, not a setting it accepts (the GET returns it, the POST does not take it), so the pattern is unaffected and no check reports that as drift; remove it from the settings file
::notice::environments[prod].deployment_branch_policies[v*]: declared key "typ" does not exist on the live deployment branch policy, so if GitHub ignores it the pattern is unaffected and no check reports that as drift. Fix the key name, or remove it from the settings file
$ bun test/e2e/run.ts --scenario environment-branch-policies-unknown-key-noted --print-stdout | grep -E '::notice::|drift: |result: '
        ::notice::environments[production].deployment_branch_policies[v*]: declared key "typ" does not exist on the live deployment branch policy, so if GitHub ignores it the pattern is unaffected and no check reports that as drift. Fix the key name, or remove it from the settings file
        result: clean

No file that ran before stops running: nothing new is refused.

A reported key's parse refusal waits for version 4 behind COMPAT(v4). Taking it earlier is the owner's decision.

How

  • Three key lists, pinned both ways to @octokit/openapi-types.
  • One function, reportedKeyNote, reads the declaration alone, whatever value the key holds.
  • A pattern's other keys go through phantomKeys once the pattern is live.
  • phantomNote takes an optional tail: a pattern's extra key gates no write.
  • The variable note sits in the shared engine, reaching actions_variables and agents_variables.
  • Library: the repository's phantomKeys and phantomNote, covering the unechoed key.

Proof

  • Tests: 140 pass, 0 fail, 17 new, in the environments and variables suites.
  • End to end: 62 of 62 scenarios pass, two new.
  • Other phantomNote callers: 583 tests pass, no expectation changed.
Technical details
  • The two tables come from a scratch script that is not in the repository. It is in the collapsed block at the end of this section, with the command that runs it.
  • What the script runs: each input from each start state through the e2e runner's parseScenario and runScenario, as a check, an apply, and a check right after the apply.
  • The last column counts the notes that name the input's key in each of those three runs, and the lines under the table are those notes, each once.
  • Before is the script against an export of origin/main, After the same command against this branch.
  • The scenario blocks: the Before copies the committed scenario into that export, since origin/main does not hold it. The After is the same command on this branch.
  • How a note is prefixed: the engine leaves a line alone when it already leads with its section key, so a note prints as ::notice::environments[prod]: ....
  • What backs A's claim: the descriptor closes the environment PUT body (additionalProperties: false), and test/e2e/mock/request-body.test.ts pins the mock's 422 for a closed body.
  • Why no entry key is refused: can_admins_bypass sits in the repository's own live fixture (test/sections/environments/snapshot-row.ts), is absent from the descriptor, and completes a run when it equals the live value.
  • Reported keys on an entry: id, node_id, url, html_url, created_at, updated_at, protection_rules.
  • Reported keys on a pattern: id, node_id.
  • Reported keys on a variable: created_at, updated_at.
  • The pin fails both ways: dropping html_url from the entry list is TS2344, adding name to the pattern list is TS2322 (bun run typecheck).
  • Named once: a reported key is left out of the unechoed-key note, so a live body that lacks it never names it twice.
  • A reported key draws its note whatever its value. id: null against a live id 99 drifts and rides the PUT body, so it is not inert, and a test row pins the note.
  • The three gaps below belong to the other class only, a key the live resource does not echo.
  • Recorded, not built: the run that creates the resource. deployment_branch_policies: [{name: "v*", typ: tag}] on a pattern that does not exist yet prints no note in the creating apply, and one in every later run.
  • Recorded, not built: an unechoed key declared null or as an empty string. deployment_branch_policies: [{name: "v*", typ: ""}] against a live pattern prints no note, because the diff reads both values as not declared.
  • Recorded, not built: a reviewer entry that no longer matches a live reviewer. reviewers: [{type: User, id: 1, typ: Team}, {type: User, id: 2}] against live User reviewers 1 and 2 prints no note and two drift lines, on origin/main too: the extra key stops the entry matching.
  • Unchanged: the existing note names a key inside deployment_branch_policy once the environment exists, and a key inside reviewers[] while its entry still matches a live reviewer.
  • Published schema: it still agrees with the runtime, since nothing new is refused, so test/schema-corpus.test.ts needs no known-divergence entry.
  • Red first: on the source of origin/main the 17 new cases give 16 fail and 1 pass, the named control (bun test test/sections/environments/environments.test.ts test/sections/actions_variables/actions_variables.test.ts -t "outside the entry shapes|only reports").
  • New scenario for the unattempted report, which had none: environments-apply-refused-entry-reports-unattempted pins the warning and the two not attempted lines on stdout and in the summary.
  • Proof commands: bun test test/sections/environments test/sections/actions_variables test/sections/agents_variables, and bun test/e2e/run.ts --sections environments,actions_variables,agents_variables.
  • Existing callers of phantomNote: ten call expressions in nine files, their arguments unchanged.
  • Their tests, 583 pass in 25 files, after bun run build:schema: bun test test/engine/diff.test.ts test/sections/actions/ test/sections/autolinks/ test/sections/branches/ test/sections/deploy_keys/ test/sections/interaction_limits/ test/sections/labels/ test/sections/pages/ test/sections/repository/ test/sections/rulesets/ test/sections/shared/setup-section.test.ts.
  • Their scenarios, 87 of 88 pass: bun test/e2e/run.ts --sections actions,branches,code_quality_setup,code_scanning_default_setup,pages.
  • The one that fails, pages-enable-needs-build-type-or-source, fails on origin/main alone too: it expects the section key twice, and this change touches neither the scenario nor that line.
  • Three markers: two in src/sections/environments/schema.ts, one on variableConfig in src/sections/shared/schema-helpers.ts, each inside its declaration's doc comment and naming the actions section's reported-only refusal as its successor.
  • Gates, each exit 0: typecheck, lint, lint:arch, knip, check:compat, build:check.
  • Re-expected tests: none.
  • Files: src/sections/shared/reported-key-note.ts, src/sections/environments/{reported-keys.ts,index.ts,branch-policies.ts,schema.ts}, src/sections/shared/secrets-and-variables/variables-engine.ts, src/sections/shared/schema-helpers.ts, src/engine/diff.ts.
  • Tests and docs: test/sections/environments/{environments.test.ts,scenarios/environment-branch-policies-unknown-key-noted.yml,scenarios/environments-apply-refused-entry-reports-unattempted.yml}, test/sections/actions_variables/actions_variables.test.ts, docs/sections/environments.docs.yml, and the generated docs/reference/coverage.md.
The table script, probe.mts

Save it outside the checkout, then run TMPDIR="$(mktemp -d)" bun probe.mts /path/to/checkout.

// Prints the today/now table: each input, from each starting state, through the real bundle against the e2e mock.
// A check, an apply, then a check right after the apply. Save it outside the checkout and run:
//   TMPDIR="$(mktemp -d)" bun probe.mts /path/to/checkout

import { rmSync } from "node:fs";
import { join } from "node:path";

const root = process.argv[2] ?? process.cwd();
const { parseScenario } = await import(join(root, "test/e2e/scenario.ts"));
const { runScenario } = await import(join(root, "test/e2e/runner/runner.ts"));

type Json = Record<string, unknown>;
const FLAGS = { protected_branches: false, custom_branch_policies: true };
const BARE = { name: "prod", protection_rules: [], deployment_branch_policy: null };
const CUSTOM = { ...BARE, deployment_branch_policy: FLAGS };
const STAMP = "2026-01-01T00:00:00Z";
const variable = (stamp: string) => ({ name: "X", value: "v", created_at: stamp, updated_at: stamp });
const pattern = (name: string) => ({ environments: { prod: CUSTOM }, environment_branch_policies: { prod: [{ id: 41, name, type: "branch" }] } });

const INPUTS: { what: string; key: string; entry: Json; starts: Record<string, Json> }[] = [
  {
    what: "A entry id: 1",
    key: "id",
    entry: { name: "prod", id: 1 },
    starts: {
      "environment missing": { environments: {} },
      "live, id 1": { environments: { prod: { ...BARE, id: 1 } } },
      "live, id 99": { environments: { prod: { ...BARE, id: 99 } } },
    },
  },
  {
    what: "B variable updated_at",
    key: "updated_at",
    entry: { name: "prod", variables: [{ name: "X", value: "v", updated_at: STAMP }] },
    starts: {
      "variable missing": { environments: { prod: BARE } },
      "live, another stamp": { environments: { prod: BARE }, environment_variables: { prod: [variable("2026-07-01T00:00:00Z")] } },
      "live, the same stamp": { environments: { prod: BARE }, environment_variables: { prod: [variable(STAMP)] } },
    },
  },
  {
    what: "C pattern id: 7",
    key: "id",
    entry: { name: "prod", deployment_branch_policy: FLAGS, deployment_branch_policies: [{ name: "main", id: 7 }] },
    starts: { "pattern missing": { environments: { prod: CUSTOM } }, "pattern live, id 41": pattern("main") },
  },
  {
    what: "D pattern typ: tag",
    key: "typ",
    entry: { name: "prod", deployment_branch_policy: FLAGS, deployment_branch_policies: [{ name: "v*", typ: "tag" }] },
    starts: { "pattern missing": { environments: { prod: CUSTOM } }, "pattern live": pattern("v*") },
  },
  {
    what: "control wait_timer: 5",
    key: "wait_timer",
    entry: { name: "prod", wait_timer: 5 },
    starts: { "environment live": { environments: { prod: BARE } } },
  },
];

async function run(mode: "check" | "apply", entry: Json, live: Json) {
  const expect = { exit_code: [0, 1, 2], ...(mode === "apply" ? { fixpoint: "converges" } : {}) };
  const scenario = { name: `probe-${mode}`, settings: { environments: [entry] }, inputs: { mode }, live_state: live, expect };
  const report = await runScenario(parseScenario(scenario, "probe"));
  if (report.artifactDir !== undefined) {
    rmSync(report.artifactDir, { recursive: true, force: true });
  }
  return report;
}

const notesNaming = (stdout: string, key: string) =>
  stdout.split("\n").filter((line) => line.startsWith("::notice::") && line.includes(`"${key}"`));

const seen = new Set<string>();
console.log(`${"input".padEnd(24)}${"start".padEnd(22)}${"apply".padEnd(30)}${"check after".padEnd(13)}notes naming the key (check, apply, check after)`);
for (const { what, key, entry, starts } of INPUTS) {
  for (const [start, live] of Object.entries(starts)) {
    const check = await run("check", entry, live);
    const apply = await run("apply", entry, live);
    const after = apply.reruns[0];
    const own = apply.requests.slice(0, apply.requests.length - after.requests.length);
    const writes = own.filter((request: { method: string }) => request.method !== "GET");
    const sent = writes.map((request: { method: string; status: number }) => `${request.method} ${request.status}`).join(", ");
    const notes = [check.stdout, apply.stdout, after.stdout].map((stdout) => notesNaming(stdout, key));
    for (const note of notes.flat()) {
      seen.add(note);
    }
    const applied = `${apply.outputs.result}  ${sent === "" ? "no write" : sent}`;
    console.log(`${what.padEnd(24)}${start.padEnd(22)}${applied.padEnd(30)}${String(after.outputs.result).padEnd(13)}${notes.map((list) => list.length).join(" ")}`);
  }
}
console.log(seen.size === 0 ? "\nno note names a key" : `\n${[...seen].join("\n")}`);

… an entry or a nested entry

An environment entry, and each entry of its nested lists, is an open shape: a key the shape does not declare passes the parse and rides the request. Two classes of such a key had no line naming them.

A key GitHub only reports (the GET returns it, the write does not take it) is now named by a note in every run, from the declaration alone: id, node_id, url, html_url, created_at, updated_at and protection_rules on an entry, id and node_id on a deployment branch-policy pattern, created_at and updated_at on a variable. The three lists are pinned both ways to @octokit/openapi-types. The variable note lives in the shared variables engine, so actions_variables and agents_variables print it too.

An undeclared key on a branch-policy pattern that the live pattern does not echo (a typo such as typ for type) is now named by a note in every run where the pattern exists. The planner sends such a key on the create call alone and compares it in no run, so it adds no drift; phantomNote takes an optional tail for a write the comparison does not gate, and every existing caller prints the sentence it printed before.

No file that parsed before is refused. Each open shape that still accepts a reported key carries a COMPAT(v4) marker naming the parse refusal that replaces the note.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 04:21
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

File size check

0 over a hard cap (fails), 6 warning(s).

File Size Tier Cap
src/engine/diff.ts:56 11 comment lines warn 10
src/github/api-error.ts:9 11 comment lines warn 10
src/problem.ts:73 18 comment lines warn 10
src/sections/contract/errors.ts:18 11 comment lines warn 10
src/sections/contract/module.ts:55 12 comment lines warn 10
src/sections/shared/list/decl.ts:265 14 comment lines warn 10

Split the file, wrap the line, shorten or exempt the comment, or list the path in .file-size-allow.local with a # reason.

5 managed file(s) skipped; repo-platform owns them.

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Member shape check

18 finding(s).

File Line Rule
src/action/io.ts 24 one-line-comment
src/cli/actions.ts 12 one-line-comment
src/github/api.ts 55 one-line-comment
src/sections/environments/schema.ts 46 one-line-comment
src/sections/environments/schema.ts 108 one-line-comment
src/sections/pages/index.ts 71 one-line-comment
test/cli/actions.test.ts 45 one-line-comment
test/scripts/release-pipeline-fixture.test.ts 25 one-line-comment
test/scripts/release-pipeline-fixture.ts 53 one-line-comment
test/sections/contract/validate-hook.test.ts 80 all-or-none
test/sections/teams/teams.test.ts 20 all-or-none
test/sections/teams/teams.test.ts 23 all-or-none
test/sections/teams/teams.test.ts 24 all-or-none
test/sections/teams/teams.test.ts 25 all-or-none
test/sections/teams/teams.test.ts 26 all-or-none
test/sections/teams/teams.test.ts 28 all-or-none
test/sections/teams/teams.test.ts 31 all-or-none
test/sections/teams/teams.test.ts 34 constant-spacing

Add the blank lines, make the comment one line directly above the item, comment every field or none, or list the path in .member-shape-allow.local with a # reason.

4 generated file(s) skipped.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are identified for the diagnostic-note changes.

No open issues 🎉

What changed in this PR

Adds diagnostic notes to GitHub Settings as Code for environment, branch-policy, and variable keys that GitHub only reports or does not echo back. Existing parsing and reconciliation behavior stays unchanged; rejection of reported-only keys remains deferred to v4.

Changes:

  • Adds reported-only key lists checked against OpenAPI types and notes that avoid naming the same key twice.
  • Reports unechoed branch-policy keys and extends the shared note formatter with an optional ending.
  • Adds unit tests, an end-to-end scenario, and documentation for the diagnostics.
File Description
test/​sections/​environments/​scenarios/​environment-branch-policies-unknown-key-noted.yml Verifies a misspelled pattern key produces a note without drift or writes.
test/​sections/​environments/​environments.test.ts Tests reported-only and unechoed keys across environment and nested entries.
test/​sections/​actions_variables/​actions_variables.test.ts Tests reported timestamp notes for repository variables.
src/​sections/​shared/​secrets-and-variables/​variables-engine.ts Adds shared variable timestamp diagnostics and suppresses duplicate notes.
src/​sections/​shared/​schema-helpers.ts Adds a compatibility comment deferring timestamp rejection to v4.
src/​sections/​environments/​schema.ts Adds compatibility comments for future reported-only key rejection.
src/​sections/​environments/​reported-keys.ts Defines OpenAPI-checked key lists and a reported-key note formatter.
src/​sections/​environments/​index.ts Adds environment reported-key notes, including for missing environments.
src/​sections/​environments/​branch-policies.ts Adds reported-only and unechoed pattern-key diagnostics.
src/​engine/​diff.ts Allows callers to customize the ending of phantom-key notes.
docs/​sections/​environments.docs.yml Documents when the new diagnostics appear.
docs/​reference/​coverage.md Updates generated coverage documentation to match.

Review effort: Balanced

… writes, and pin the unattempted report

The note for a key GitHub only reports was written twice, in the environments section and in the shared variables engine. It is now one function in src/sections/shared/reported-key-note.ts, called by the entry, the branch-policy pattern, and the variable.

A variable that does not exist yet is written by a POST, so the variable note now says neither the POST nor the PATCH takes the key.

The environmentDrift comment is two short paragraphs, and each COMPAT(v4) marker sits in its declaration's doc comment.

A reported key declared null still draws its note, and a test row pins it: such a declaration drifts against the live value and rides the write.

A new scenario pins what a refused entry leaves unattempted: the first environment's PUT answers 422, and the run names the second environment's variable and secret as not attempted, on stdout and in the summary.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 05:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are identified for the environment and variable diagnostic changes.

No open issues 🎉

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 12, 2026 05:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the diagnostic-notice changes.

No open issues 🎉

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 12, 2026 06:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are recorded for the environment and variable-key diagnostic changes.

No open issues 🎉

Review effort: Balanced

@Vivswan
Vivswan marked this pull request as ready for review November 12, 2026 06:07
@Vivswan Vivswan added the merge-when-green Owner approved: merge once every gate is green label Nov 12, 2026
@Vivswan
Vivswan merged commit a1ede06 into main Nov 12, 2026
32 checks passed
@Vivswan
Vivswan deleted the fix/environments-undeclared-keys branch November 12, 2026 06:38
This was referenced Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-when-green Owner approved: merge once every gate is green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants