Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/reference/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ The side-by-side comparison with the Probot Settings app lives in the migration
- PUT /repos/{owner}/{repo}/environments/{name} passthrough; check mode flattens GET's protection_rules[] back into the PUT shape.
- A live wait timer or reviewers list the entry omits is drift, and apply refuses the PUT (the [replace-write rule](semantics.md)).
- Undeclared environments are left untouched.
- A key the entry shape does not declare rides the PUT. One the GET reports and the PUT does not take (id, node_id, url, html_url, created_at, updated_at, protection_rules) is named by a note in every run. Any other, unless declared null or as an empty string, is named by a note once the environment exists and its GET does not echo the key.
- In check mode against a missing environment the declared variables, secrets, patterns, and protection rules cannot be listed, so notes say they are unverifiable until it exists; against a live environment whose custom_branch_policies flag is off, the patterns earn the same note while the flag drift comes from the environment diff.
- Snapshot lists the environments (GET /repos/{owner}/{repo}/environments) and reads each one back as an entry: the PUT-shape settings off its GET body, and every nested list that has live items in its wrapped form under that list's default policy.
- The snapshot writes variables with their values, branch-policy patterns while the custom_branch_policies flag is on, and the enabled protection rules by App slug; those two lists sit behind the Actions grant: under on-missing-permission: warn a denial leaves the key out with a note and the rest of the environment reads back, under fail it fails the section.
Expand All @@ -236,6 +237,7 @@ The side-by-side comparison with the Probot Settings app lives in the migration

- A declared per-environment `variables` key reconciles that environment's Actions variables AFTER the PUT, through GET/POST /repos/{owner}/{repo}/environments/{name}/variables and PATCH/DELETE .../variables/{name}: create missing, update divergent values, and delete undeclared ones by default (the wrapped `_undeclared: keep` form keeps them as notes).
- Names match case-insensitively, values are plain text by design.
- A declared created_at or updated_at, which the GET reports and neither write takes, is named by a note in every run.

**Environment secrets** (`environments`)

Expand All @@ -248,6 +250,7 @@ The side-by-side comparison with the Probot Settings app lives in the migration

- A declared per-environment `deployment_branch_policies` key reconciles that environment's custom branch-policy patterns through GET/POST .../environments/{name}/deployment-branch-policies and DELETE .../deployment-branch-policies/{branch_policy_id}: create missing patterns, delete undeclared ones by default (`_undeclared: keep` softens to notes), and replace a matching pattern whose type differs.
- Type is immutable upstream, so the change is delete + recreate; the upstream PUT is deliberately unused - its body is the name alone, and the name is the pattern's identity, so it can never help reconciliation.
- A pattern key outside name and type rides the create call and no run compares it, so it adds no drift. One the GET reports (id, node_id) is named by a note in every run. Any other, unless declared null or as an empty string, is named by a note once the live pattern exists and does not echo the key.
- Declaring the key requires the singular `deployment_branch_policy` sibling with custom_branch_policies: true (rejected upfront otherwise, since the pattern writes would 404 only after the environment PUT landed).
- These endpoints sit outside the Environments PAT permission: the list read needs Actions read, the writes need Administration write.

Expand Down
13 changes: 13 additions & 0 deletions docs/sections/environments.docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ coverage:
A live wait timer or reviewers list the entry omits is drift, and apply refuses the PUT (the
[replace-write rule](semantics.md)).
- Undeclared environments are left untouched.
- >-
A key the entry shape does not declare rides the PUT. One the GET reports and the PUT does not
take (id, node_id, url, html_url, created_at, updated_at, protection_rules) is named by a
note in every run. Any other, unless declared null or as an empty string, is named by a note
once the environment exists and its GET does not echo the key.
- >-
In check mode against a missing environment the declared variables, secrets, patterns, and
protection rules cannot be listed, so notes say they are unverifiable until it exists; against
Expand Down Expand Up @@ -53,6 +58,9 @@ coverage:
PATCH/DELETE .../variables/{name}: create missing, update divergent values, and delete
undeclared ones by default (the wrapped `_undeclared: keep` form keeps them as notes).
- Names match case-insensitively, values are plain text by design.
- >-
A declared created_at or updated_at, which the GET reports and neither write takes, is named
by a note in every run.
- area: "[Environment secrets](https://docs.github.com/en/rest/actions/secrets)"
keys: secrets
endpoints: [listSecrets, secretsPublicKey, putSecret, removeSecret]
Expand Down Expand Up @@ -84,6 +92,11 @@ coverage:
Type is immutable upstream, so the change is delete + recreate; the upstream PUT is
deliberately unused - its body is the name alone, and the name is the pattern's identity, so
it can never help reconciliation.
- >-
A pattern key outside name and type rides the create call and no run compares it, so it adds
no drift. One the GET reports (id, node_id) is named by a note in every run. Any other, unless
declared null or as an empty string, is named by a note once the live pattern exists and does
not echo the key.
- >-
Declaring the key requires the singular `deployment_branch_policy` sibling with
custom_branch_policies: true (rejected upfront otherwise, since the pattern writes would 404
Expand Down
11 changes: 9 additions & 2 deletions src/engine/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,11 +581,18 @@ export function phantomKeys(desired: Record<string, unknown>, live: unknown): st
return phantomPaths(deltas(desired, live));
}

export function phantomNote(prefix: string, keys: string[], noun: string, rewrite: string): string {
/** `rewrite` and `tail` finish "so if GitHub ignores it ...": the default tail fits a write the comparison gates, which re-runs; a write nothing gates passes what happens instead. */
export function phantomNote(
prefix: string,
keys: string[],
noun: string,
rewrite: string,
tail = "on every apply without converging",
): string {
const list = keys.map((k) => `"${k}"`).join(", ");
const count = keys.length;
return (
`${prefix}: declared ${agree(count, "key", "keys")} ${list} ${agree(count, "does", "do")} not exist on the live ${noun}, ` +
`so if GitHub ignores ${agree(count, "it", "them")} ${rewrite} on every apply without converging. Fix the key name, or remove it from the settings file`
`so if GitHub ignores ${agree(count, "it", "them")} ${rewrite} ${tail}. Fix the key name, or remove it from the settings file`
);
}
48 changes: 44 additions & 4 deletions src/sections/environments/branch-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@

import { err, ok, Result, safeTry } from "neverthrow";
import { z } from "zod";
import { subsetDiff } from "../../engine/diff.js";
import { phantomKeys, phantomNote, subsetDiff } from "../../engine/diff.js";
import type { UndeclaredPolicy } from "../../types.js";
import { type DeclaredIssue, duplicateFieldIssues } from "../contract/declared.js";
import { missingDrift, undeclaredDrift, undeclaredNote } from "../contract/drift.js";
import type { SectionFailure } from "../contract/errors.js";
import { liveByIdentity, liveIdentity } from "../contract/live.js";
import type { SectionMeta } from "../contract/module.js";
import { hasDrift, type Read } from "../contract/plan.js";
import { reportedKeyNote } from "../shared/reported-key-note.js";
import {
type EnvironmentRestOp,
type EnvironmentsRestContext,
unreconcilable,
} from "./endpoints.js";
import type { LiveEnvironmentBody } from "./index.js";
import type { NestedPlan } from "./nested.js";
import { BRANCH_POLICY_REPORTED_KEYS } from "./reported-keys.js";
import type { DeploymentBranchPolicyConfig } from "./schema.js";

/** "delete" like the nested variables list: patterns are readable, recreatable configuration. */
Expand All @@ -41,6 +43,10 @@ function livePolicyType(policy: LiveBranchPolicy): string {

const POLICY = { list: "deployment branch-policy", entry: "policy" };

function patternLabel(envName: string, name: string): string {
return `environments[${envName}].deployment_branch_policies[${name}]`;
}

function livePolicyId(policy: LiveBranchPolicy, envName: string): Result<string, SectionFailure> {
if (policy.id === undefined) {
return err(unreconcilable(POLICY, envName, "an id"));
Expand Down Expand Up @@ -134,7 +140,7 @@ function patternOps(
existing: LiveBranchPolicy | undefined,
hidden: boolean,
): Result<EnvironmentRestOp[], SectionFailure> {
const label = `environments[${envName}].deployment_branch_policies[${pattern.name}]`;
const label = patternLabel(envName, pattern.name);
if (!existing) {
return ok([
{
Expand Down Expand Up @@ -180,6 +186,38 @@ function patternOps(
]);
}

/** What follows for a declared pattern key GitHub does not take, in the two halves both of its notes end on. */
const KEY_OUTCOME = ["the pattern is unaffected", "and no check reports that as drift"] as const;

/**
* The notes one declared pattern earns for keys outside its shape. Such a key rides the create call alone and no
* run compares it, so no drift line can name it: a key GitHub only reports is named from the declaration alone,
* any other once the live pattern exists and does not echo it.
*/
function undeclaredKeyNotes(
envName: string,
pattern: DeploymentBranchPolicyConfig,
existing: LiveBranchPolicy | undefined,
): string[] {
const label = patternLabel(envName, pattern.name);
const { name: _name, type: _type, ...extra } = pattern as Record<string, unknown>;
const reported = reportedKeyNote(label, extra, {
keys: BRANCH_POLICY_REPORTED_KEYS,
write: "the POST does not take",
outcome: `so ${KEY_OUTCOME.join(" ")}`,
});
const notes = [...reported.notes];
if (existing === undefined) {
return notes;
}

const unechoed = phantomKeys(extra, existing).filter((key) => !reported.keys.includes(key));
if (unechoed.length > 0) {
notes.push(phantomNote(label, unechoed, "deployment branch policy", ...KEY_OUTCOME));
}
return notes;
}

/** A keep-note or the delete for each live pattern the list does not name. */
function undeclaredPolicyOps(
envName: string,
Expand Down Expand Up @@ -211,7 +249,7 @@ function undeclaredPolicyOps(
},
drift: [
undeclaredDrift(BRANCH_POLICIES_DEFAULT_POLICY, {
label: `environments[${envName}].deployment_branch_policies[${name}]`,
label: patternLabel(envName, name),
action: "DELETE it",
}),
],
Expand Down Expand Up @@ -250,8 +288,10 @@ export async function planBranchPolicies(
const declared = new Set(entries.map((pattern) => pattern.name));

for (const pattern of entries) {
const ops = yield* patternOps(envName, pattern, liveByName.get(pattern.name), hidden);
const existing = liveByName.get(pattern.name);
const ops = yield* patternOps(envName, pattern, existing, hidden);
planned.ops.push(...ops);
planned.notes.push(...undeclaredKeyNotes(envName, pattern, existing));
}

const undeclared = yield* undeclaredPolicyOps(envName, liveByName, declared, policy);
Expand Down
31 changes: 20 additions & 11 deletions src/sections/environments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { liveByIdentity, liveIdentity } from "../contract/live.js";
import type { SectionMeta, SectionModule } from "../contract/module.js";
import type { SectionPermission } from "../contract/permissions.js";
import { hasDrift } from "../contract/plan.js";
import { reportedKeyNote } from "../shared/reported-key-note.js";
import { layeredList, routed } from "../shared/schema-helpers.js";
import { upperKey } from "../shared/secrets-and-variables/named-scope.js";
import { listSecretValues } from "../shared/secrets-and-variables/secrets-engine.js";
Expand All @@ -38,6 +39,7 @@ import {
planPinned,
snapshotPins,
} from "./pins.js";
import { ENVIRONMENT_REPORTED_KEYS } from "./reported-keys.js";
import { EnvironmentConfig, EnvironmentsConfig, environmentKey } from "./schema.js";
import { sharedSecretNotes, snapshotNested, withPins } from "./snapshot.js";

Expand Down Expand Up @@ -164,21 +166,27 @@ export function flattenEnvironment(live: LiveEnvironmentBody): Record<string, un
}

/**
* The PUT replaces the environment's settings whole (an omitted `reviewers` clears the reviewers rule), so a
* non-empty live setting the entry omits is drift too, and the lines it makes (`omitted`) are what apply refuses
* the write over. The live body is split the way the entry was, so only the PUT's own keys take part in that sweep.
* The entry passes unknown keys through, so a key the GET never echoes would re-PUT on every apply without
* converging; `notes` names it. An entry key the GET omits (deployment_branch_policy on an environment that never
* set one) is drift the PUT resolves, so only a key outside the entry shape is noted. An environment that does
* not exist yet earns the missing line alone.
* The PUT replaces the environment's settings whole, so a non-empty live setting the entry omits is drift too: the
* `omitted` lines, over which apply refuses the write. The live body is split the way the entry was, so only the
* PUT's own keys take part in that sweep.
*
* `notes` names a key no drift line explains. A key outside the entry shape that the GET never echoes would re-PUT
* on every apply, while an entry key the GET merely omits is drift the PUT resolves. A key GitHub only reports is
* named from the declaration, so a missing environment earns that note too.
*/
function environmentDrift(
label: string,
settings: Record<string, unknown>,
live: LiveEnvironmentBody | undefined,
): { drift: string[]; omitted: string[]; notes: string[] } {
const reported = reportedKeyNote(label, settings, {
keys: ENVIRONMENT_REPORTED_KEYS,
write: "the PUT does not take",
outcome: "so GitHub answers this environment's PUT with a 422 whenever apply sends one",
});
const notes = [...reported.notes];
if (live === undefined) {
return { drift: [missingDrift(label)], omitted: [], notes: [] };
return { drift: [missingDrift(label)], omitted: [], notes };
}

const flattened = flattenEnvironment(live);
Expand All @@ -187,10 +195,11 @@ function environmentDrift(
sweep: replaceSweep(EnvironmentConfig),
}).map((delta) => renderDelta(label, delta));
const phantom = phantomKeys(settings, flattened).filter(
(key) => !Object.hasOwn(EnvironmentConfig.shape, key),
(key) => !Object.hasOwn(EnvironmentConfig.shape, key) && !reported.keys.includes(key),
);
const notes =
phantom.length > 0 ? [phantomNote(label, phantom, "environment", "this PUT will re-run")] : [];
if (phantom.length > 0) {
notes.push(phantomNote(label, phantom, "environment", "this PUT will re-run"));
}
return { drift: [...subsetDiff(settings, flattened, label), ...omitted], omitted, notes };
}

Expand Down
50 changes: 50 additions & 0 deletions src/sections/environments/reported-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* The keys GitHub only reports on an environment entry and on a branch-policy pattern: its GET returns them and
* the write does not take them. The open shapes in schema.ts still parse one, so a plan names it in a note
* (../shared/reported-key-note.ts). Each list is pinned both ways to the vendored spec, by its satisfies and its
* MustBeNever.
*/

import type { components, operations } from "@octokit/openapi-types";
import type { MustBeNever } from "../../types.js";

type EnvironmentPutBody = NonNullable<
NonNullable<
operations["repos/create-or-update-environment"]["requestBody"]
>["content"]["application/json"]
>;

type ReportedEnvironmentKey = Exclude<
keyof components["schemas"]["environment"],
keyof EnvironmentPutBody | "name"
>;

/** On an environment entry; `name` addresses the PUT, so it is no reported key. */
export const ENVIRONMENT_REPORTED_KEYS = [
"id",
"node_id",
"url",
"html_url",
"created_at",
"updated_at",
"protection_rules",
] as const satisfies readonly ReportedEnvironmentKey[];

type _EnvironmentKeysComplete = MustBeNever<
Exclude<ReportedEnvironmentKey, (typeof ENVIRONMENT_REPORTED_KEYS)[number]>
>;

type ReportedBranchPolicyKey = Exclude<
keyof components["schemas"]["deployment-branch-policy"],
keyof components["schemas"]["deployment-branch-policy-name-pattern-with-type"]
>;

/** On a pattern entry, whose only write is the create POST. */
export const BRANCH_POLICY_REPORTED_KEYS = [
"id",
"node_id",
] as const satisfies readonly ReportedBranchPolicyKey[];

type _BranchPolicyKeysComplete = MustBeNever<
Exclude<ReportedBranchPolicyKey, (typeof BRANCH_POLICY_REPORTED_KEYS)[number]>
>;
10 changes: 8 additions & 2 deletions src/sections/environments/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@
/** GitHub's cap on pinned environments per repository. */
export const MAX_PINNED_ENVIRONMENTS = 10;

/** A branch pattern entry; an absent type is GitHub's own default, "branch". */
/**

Check warning on line 46 in src/sections/environments/schema.ts

View workflow job for this annotation

GitHub Actions / platform / standard-checks

src/sections/environments/schema.ts:46: one-line-comment
* A branch pattern entry; an absent type is GitHub's own default, "branch".
* COMPAT(v4): a key GitHub only reports (BRANCH_POLICY_REPORTED_KEYS in reported-keys.ts) still parses, rides the create POST, and is named by a note; delete the note and refuse the key here, the rule the actions section's reported-only sweep applies.
*/
export const DeploymentBranchPolicyConfig = open({
name: z.string(),
type: z.enum(DEPLOYMENT_BRANCH_POLICY_TYPES).optional(),
Expand Down Expand Up @@ -102,7 +105,10 @@
),
});

/** One environment entry: the PUT body's keys, plus the routed scalar and the nested lists nested.ts strips before the PUT. */
/**

Check warning on line 108 in src/sections/environments/schema.ts

View workflow job for this annotation

GitHub Actions / platform / standard-checks

src/sections/environments/schema.ts:108: one-line-comment
* One environment entry: the PUT body's keys, plus the routed scalar and the nested lists nested.ts strips before the PUT.
* COMPAT(v4): a key GitHub only reports (ENVIRONMENT_REPORTED_KEYS in reported-keys.ts) still parses, rides the PUT, and is named by a note; delete the note and refuse the key here, the rule the actions section's reported-only sweep applies.
*/
export const EnvironmentConfig = open({
name: z.string(),
// Routed (see EnvironmentRoutedScalars): stripped from the PUT body and applied through the
Expand Down
38 changes: 38 additions & 0 deletions src/sections/shared/reported-key-note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* The note for declared keys GitHub only reports: its GET returns them and no write takes them. An open shape still
* parses one, so its planner names it here, from the declaration alone, whatever the live side holds.
*/

import { agree } from "../../text.js";

/** One holder's reported keys and the words its note puts around them. */
interface ReportedKeys {
/** The keys the holder's GET reports and its write does not take. */
readonly keys: readonly string[];

/** The clause each key completes: "the PUT does not take", "neither the POST nor the PATCH takes". */
readonly write: string;

/** What follows for a declared one, from "so" on. */
readonly outcome: string;
}

/** The reported keys `declared` holds, and the one note naming them; no note when it holds none. */
export function reportedKeyNote(
label: string,
declared: object,
reported: ReportedKeys,
): { keys: readonly string[]; notes: string[] } {
const keys = reported.keys.filter((key) => Object.hasOwn(declared, key));
if (keys.length === 0) {
return { keys, notes: [] };
}

const list = keys.map((key) => `"${key}"`).join(", ");
const count = keys.length;
const them = agree(count, "it", "them");
const note =
`${label}: ${list} ${agree(count, "is a value", "are values")} GitHub reports, not ${agree(count, "a setting", "settings")} it accepts ` +
`(the GET returns ${them}, ${reported.write} ${them}), ${reported.outcome}; remove ${them} from the settings file`;
return { keys, notes: [note] };
}
Loading
Loading