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
2 changes: 1 addition & 1 deletion docs/playbooks/teams-not-collaborators.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ teams:
permission: pull
```

An empty `collaborators` list is authoritative: apply removes every direct collaborator (the repository owner is never touched), and team-derived access is unaffected because the section manages direct grants only. In check mode the same file emits one drift line per unauthorized direct grant, which is the access review report. Two caveats complete the picture. Undeclared collaborators are deleted under this file's plain-array declaration (the section's default policy) while undeclared teams are left untouched (see the [Sections table](../reference/sections.md)), so a team you stop declaring keeps its access until removed by hand. And pending invitations are reconciled alongside the collaborators: an undeclared pending invitation is cancelled under the same policy (kept as a note under `_undeclared: keep`), a declared user's pending invitation counts as converged at the declared permission, and only an invitation sent by email - which no username can declare - is left untouched, each surfaced as a note.
An empty `collaborators` list is authoritative: apply removes every direct collaborator (the repository owner is never touched), and team-derived access is unaffected because the section manages direct grants only. In check mode the same file emits one drift line per unauthorized direct grant, which is the access review report. Two caveats complete the picture. Undeclared collaborators are deleted under this file's plain-array declaration (the section's default policy) while undeclared teams holding a direct grant are kept and noted under the section's default (see the [Sections table](../reference/sections.md)): a team you stop declaring keeps its access until removed by hand, or until you set the wrapped `teams: {_undeclared: delete, entries: [...]}` form, which revokes the direct grants the file does not name. And pending invitations are reconciled alongside the collaborators: an undeclared pending invitation is cancelled under the same policy (kept as a note under `_undeclared: keep`), a declared user's pending invitation counts as converged at the declared permission, and only an invitation sent by email - which no username can declare - is left untouched, each surfaced as a note.
12 changes: 7 additions & 5 deletions docs/reference/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ The examples continue from one another and form one program (the docs tests comp
| `OptOutNotice` | type | A `null` that deleted what a lower layer declared: the layer and the path |
| `describeOptOut` | function | One notice as the line the action prints |
| `readLayerFiles` | function | Read paths into `Layer`s in order; the first unreadable file is the problem |
| `readSettingsFile` | function | Read and parse one YAML file, given its role (`settings-file`, `defaults-file`, `layer`), so the problem's advice fits |
| `SettingsFileRole` | type | The three roles |
| `readSettingsFile` | function | Read and parse one YAML file, given its role (`settings-file`, `defaults-file`, `layer`, `central-file`), so the problem's advice fits |
| `SettingsFileRole` | type | The four roles |
| `parseSettingsDoc` | function | Parse YAML text into an unknown document |
| `Problem` | type | Every typed failure a call can return, keyed by `code` |
| `describeProblem` | function | A `Problem` as the message the action prints |
Expand Down Expand Up @@ -191,10 +191,12 @@ console.log(snapshot.result, snapshot.yaml ?? "(failed: no document)");

| Name | Kind | Says |
|---|---|---|
| `executeRun` | function | The executor the action and the CLI share: a `RunConfig` plus a face's `RunDeps` runs to its exit code |
| `RunDeps` | type | What a face hands in: the `io`, the client factory, the artifact `uploader` only the Actions runner has, and the problem wording |
| `executeRun` | function | The executor the action and the CLI share: a `RunConfig` plus a face's `RunDeps` runs to its `RunEnd` |
| `RunDeps` | type | What a face hands in: the `io`, the client factory, and the artifact `uploader` only the Actions runner has |
| `RunEnd` | type | How the run ended: its `exitCode`, and the fatal `Problem` when it never reached a target |
| `RunConfig` | type | The action's parsed inputs: a `SingleConfig`, `MultiConfig`, `MergeConfig`, or `SnapshotConfig`, discriminated by `kind` |
| `parseConfig` | function | Build a `RunConfig` from an input reader and the environment the way the action does |
| `parseConfig` | function | Build a `RunConfig` from an input reader, the environment, and the face's `RunCapabilities`, the way the action does; a face without an artifact upload is refused `private-report: artifact` here |
| `RunCapabilities` | type | What the face can do: `artifactUpload`, whether it hands the run a workflow-artifact uploader |
| `InputReader` | type | `(name) => string`: how `parseConfig` reads an input |
| `ConfigEnv` | type | The environment `parseConfig` reads |
| `runSingle` | function | One repository from a local settings file, in check or apply |
Expand Down
35 changes: 32 additions & 3 deletions docs/upgrading/v2-to-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ order: 20

# Upgrading from v2 to v3

Twenty-one breaks (the ninth is for library consumers, the last for anyone pinning a sha). 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).
Twenty-three breaks (the ninth and twenty-third are for library consumers, the twenty-first for anyone pinning a sha). The silent ones include the fallback and the renamed `GSAC_RETRY_BASE_MS`, 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).

| Break | v2 | v3 | What the old form does now |
|---|---|---|---|
Expand All @@ -24,11 +24,13 @@ Twenty-one breaks (the ninth is for library consumers, the last for anyone pinni
| 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) |
| 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) |
| Underscore keys are directives, never notes | An unknown `_note: ...` at the top level was dropped silently | Only `_layering` and `_undeclared` exist; any other underscore key fails validation, on a wrapper and at the top level alike | Validation fails before any section runs, naming the two directives; [section 16](#16-underscore-keys-are-directives-never-notes) |
| `teams` takes the `_undeclared` knob | A plain array; an undeclared team was never listed or touched | `teams: {_undeclared: keep, entries: [...]}` accepted, default `keep`; `delete` revokes undeclared direct grants | No error. Every run now lists the repository's teams and notes each undeclared one; snapshots write the wrapper form; [section 17](#17-teams-takes-the-_undeclared-knob) |
| `teams` takes the `_undeclared` knob | A plain array; an undeclared team was never listed or touched | `teams: {_undeclared: keep, entries: [...]}` accepted, default `keep`; `delete` revokes undeclared direct grants | No error. Every run now lists the repository's teams and notes each undeclared direct grant (access granted at the organization level is noted only under `delete`); snapshots write the wrapper form; [section 17](#17-teams-takes-the-_undeclared-knob) |
| `GSAC_RETRY_BASE_MS` | `RETRY_BASE_MS`, undocumented | `GSAC_RETRY_BASE_MS`, in the inputs reference | No error: an unknown environment variable is ignored, so a harness setting the old name waits real seconds; [section 18](#18-gsac_retry_base_ms) |
| 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 19](#19-the-sealing-key-is-read-at-apply-time) |
| Environment secrets and variables plan through the shared engines | Their own wording | The engines' wording | Only a grep over the output notices; [section 20](#20-environment-secrets-and-variables-plan-through-the-shared-engines) |
| The `build` branch retires | Every green push appended a packaged commit to the `build` branch; `latest` and the release tags pointed into it | One packaged commit per `main` commit under the tag `build/<position>.<sha7>`, the ten newest kept; `latest`, `@v3`, and `vX.Y.Z` point at them; the branch is deleted once every consumer has repinned | A sha pin into the branch stops resolving when the branch goes; a pin taken from a build tag goes when ten newer commits have been packaged; [section 21](#21-the-build-branch-retires) says what to pin instead |
| One wording for every face | The command line reworded two remedies and refused `private-report: artifact` on its own; other remedies said "re-run the workflow" | One problem renderer; remedies name the input and its flag; `parseConfig` refuses the artifact channel for a face without an upload (`input-artifact-unsupported`) | No error. A step or script matching an error line by its old text stops matching; [section 22](#22-one-wording-for-every-face) |
| Library: one owner per refusal, one selection type | `parseConfig(read, env)`; `executeRun` returned a number and took `describe`; `artifact-uploader-missing`; `validateSettings` took a `Set` | `parseConfig(read, env, capabilities)`; `executeRun` returns `{exitCode, fatal?}`; `input-artifact-unsupported`; `SectionSelection` everywhere; `writeReplacing`, `snapshotFileDestination`, the `central-file` role | The call fails to compile, naming the missing argument or member; [section 23](#23-library-one-owner-per-refusal-one-selection-type) |

## 1. The defaults-file fallback

Expand Down Expand Up @@ -300,7 +302,6 @@ Their lines are the engines' lines now:
| 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 |

Repository variable operations (`actions_variables`, `agents_variables`) gain a describe line in failure prose (`creating Actions variable "X"`); nothing else moves.

## 21. The build branch retires

Nothing lands on the `build` branch any more, and the owner deletes it once the migration is complete: the first `build/<position>.<sha7>` tags exist, `latest` points at a tagged packaged commit, and every known consumer of a sha on the branch has repinned to a release tag, `@v3`, or an npm version. Its ruleset is declared disabled in `.github/settings.local.yml` (the apply leaves an undeclared ruleset alone, so dropping the entry would keep the deletion rule live); after `git push origin --delete build` the owner removes that entry. Repin now: a `Vivswan/github-settings-as-code@<sha>` pin into the branch stops resolving when it goes.
Expand All @@ -314,6 +315,34 @@ Every green push to `main` now mints one packaged commit, the main commit's chil
| A sha from the `build` branch | Until the owner deletes the branch | Repin to `@v3` or a release tag's commit now |
| A sha from a `build/*` tag | Until ten newer commits are packaged (the next merge, for an old tag) | Pin the release tag's commit or an npm version instead |

## 22. One wording for every face

The action and the command line print the same line for the same problem; the remedy names the input and, where one exists, its flag.

| Problem | v2 (action) | v2 (command line) | v3 (both) |
|---|---|---|---|
| No token | `Set the "token" input on the action step (or export GITHUB_TOKEN)` | `Pass --token, or export GITHUB_TOKEN` | `Set the "token" input (--token on the command line), or export GITHUB_TOKEN` |
| No repository | `Set the "repository" input (or GITHUB_REPOSITORY) to a value like "octocat/hello-world"` | `Pass --repository owner/name (inside GitHub Actions, GITHUB_REPOSITORY supplies it)` | `Set the "repository" input (--repository on the command line) to a value like "octocat/hello-world"; inside GitHub Actions, GITHUB_REPOSITORY supplies it` |
| Unknown section in `sections` | `Fix the name in the workflow's input list` | same | `Fix the section name` |
| A transient API failure | `... re-run the workflow, and retry later if it persists` | same | `... re-run, and retry later if it persists` (the report channels say `Re-run, or set private-report: none if it persists`) |
| `private-report: artifact` off the runner | accepted at the parse (the action has the upload; a library caller without an uploader failed later with `artifact-uploader-missing`) | `the "private-report" input is "artifact", which is not a supported private-report channel from the command line ...` | `private-report: artifact uploads the reports as a workflow artifact, which only the GitHub Actions runner can do, and this run has no artifact upload ... Set private-report to "issue", "issue-on-failure", or "none"` |
| `gsac init --settings-file a,b` | `the --settings-file value "a,b" contains a comma or a newline, which check and apply read as a list separator ...` | | `the "settings-file" input is "a,b", which contains a list separator: init writes exactly one settings file ... Name one file` |
| An unreadable `repos-dir` file | `cannot read settings from <path>: ... Fix the file, or delete it to stop managing this repository` | | `cannot read the central settings file <path>: ... Fix the file, or delete it to stop managing this repository` (a YAML syntax error in it reads the same way, where v2 said `cannot parse`) |

## 23. Library: one owner per refusal, one selection type

For `@vivswan/github-settings-as-code` consumers.

| v2 | v3 |
|---|---|
| `parseConfig(read, env)`; the command line refused `private-report: artifact` before calling it, and the flows checked for the uploader again (`artifact-uploader-missing`) | `parseConfig(read, env, { artifactUpload })`: the one refusal, `input-artifact-unsupported`; `runSingle` and `runMulti` no longer check |
| `executeRun(cfg, deps): Promise<number>`, with `deps.describe` rewording a fatal problem | `executeRun(cfg, deps): Promise<RunEnd>`, `{ exitCode, fatal? }`; `failRun(io, problem)` takes no wording hook |
| `validateSettingsDoc(doc, source, onlySections: ReadonlySet<SectionKey>, io)`; `validateSettings(doc, { sections?: ReadonlySet<SectionKey> })` | Both take a `SectionSelection` (`SectionSelection.ALL` for no allowlist) |
| `parseSnapshotFileConfig(read, env, snapshotFile)` | `parseSnapshotFileConfig(read, env, "settings-file")` reads the destination itself; `snapshotFileDestination(read, "settings-file")` names it before parsing |
| Every flow wrote its file in place with its own try/catch | `writeReplacing(path, text): Result<void, string>` stages and renames, for the snapshot, the merged file, and init alike |
Comment thread
Vivswan marked this conversation as resolved.
| `SettingsFileRole`: `settings-file`, `defaults-file`, `layer` | plus `central-file`, the repos-dir file a multi-repo target is read from |


## Order of operations

1. Rename any settings file whose path contains a comma, rename `undeclared` to `_undeclared` in every settings file, and move every other underscore key into a YAML comment; the v2 line accepts the old spellings only, so do all three together with the pin move.
Expand Down
3 changes: 2 additions & 1 deletion src/action/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ import { type Problem, parseConfig, type RunConfig } from "../index.js";

export function parseActionConfig(): Result<RunConfig, Problem> {
// @actions/core reads INPUT_<NAME> (uppercased, spaces to underscores, dashes kept: `settings-file` -> INPUT_SETTINGS-FILE) and trims.
return parseConfig((name) => core.getInput(name), process.env);
// The runner has the Actions artifact service, so the artifact report channel is admitted here alone.
return parseConfig((name) => core.getInput(name), process.env, { artifactUpload: true });
}
2 changes: 1 addition & 1 deletion src/action/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function run(overrides?: {
uploader: overrides?.uploader ?? actionsArtifactUploader,
};
return parseActionConfig().match(
(cfg) => executeRun(cfg, deps),
async (cfg) => (await executeRun(cfg, deps)).exitCode,
async (problem) => failRun(io, problem),
);
}
36 changes: 3 additions & 33 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/**
* What the file-only subcommands and init render for the program to print,
* and the CLI's own wording of a problem; check, apply, merge, and snapshot run
* through the library's executor from the program.
* What the file-only subcommands and init render for the program to print;
* check, apply, merge, and snapshot run through the library's executor from
* the program.
*/

import {
describeProblem,
type GitHubClient,
type Io,
type Problem,
type RunOutcome,
readSettingsFile,
SECTIONS,
Expand All @@ -17,7 +16,6 @@ import {
type ValidatedSettings,
validateSettings,
} from "../index.js";
import { INPUT_DECLS, PRIVATE_REPORT_CHANNELS } from "../internal.js";

/** What the CLI needs from its process: the environment and a client factory tests can stub. */
export interface CliHost {
Expand Down Expand Up @@ -110,31 +108,3 @@ export function permissionsFor(file: string, io: Io, bold: (text: string) => str
},
);
}

/**
* The action's wording for a problem, except where the remedy names the
* workflow step: from a terminal the fix is a flag or an environment variable.
*/
export function describeCliProblem(problem: Problem): string {
switch (problem.code) {
case "input-token-missing":
return "cannot call the GitHub API: no token was provided. Pass --token, or export GITHUB_TOKEN";
case "input-repository-not-slug":
return `cannot target a repository: "${problem.value}" is not an owner/name slug. Pass --repository owner/name (inside GitHub Actions, GITHUB_REPOSITORY supplies it)`;
default:
return describeProblem(problem);
}
}

/**
* The one report channel a terminal cannot serve: the artifact upload needs
* the Actions runner. Worded as the unsupported value it is from here.
*/
export const ARTIFACT_REFUSED: Problem = {
code: "input-unsupported-value",
input: "private-report",
value: "artifact",
noun: "private-report channel from the command line (the artifact upload needs the Actions runner)",
allowed: PRIVATE_REPORT_CHANNELS.filter((channel) => channel !== "artifact"),
fallback: INPUT_DECLS["private-report"].default,
};
Loading
Loading