diff --git a/.github/scripts/changed-sections.ts b/.github/scripts/changed-sections.ts index a2b414c6..614eee3e 100644 --- a/.github/scripts/changed-sections.ts +++ b/.github/scripts/changed-sections.ts @@ -64,12 +64,14 @@ export const ALL_SELECTING_PREFIXES = [ // so a change there selects everything. "src/sections/contract/", "src/engine/", + "src/flows/", "src/github/", "src/action/", "src/discovery/", "src/report/", // Cross-cutting: gap files define supplemental route typing across sections. "src/upstream-gaps/", + "src/index.ts", "src/io.ts", "src/main.ts", "src/plain-data.ts", diff --git a/.github/scripts/gen-docs.ts b/.github/scripts/gen-docs.ts index fd9ce630..1b1b7c51 100644 --- a/.github/scripts/gen-docs.ts +++ b/.github/scripts/gen-docs.ts @@ -5,8 +5,8 @@ // map rendered from architecture.yml. Authored prose from the docs registry + coverage-data. import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { MERGE_RESULT } from "../../src/action/deliver.js"; import { REPO_RESULTS, type RepoResult } from "../../src/engine/orchestrate.js"; +import { MERGE_RESULT } from "../../src/flows/deliver.js"; import type { SectionDocs } from "../../src/sections/contract/docs.js"; import { type SectionMeta, diff --git a/AGENTS.md b/AGENTS.md index 9893132a..0c6a5979 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ GitHub Settings as Code: GitHub Action applying declarative repository settings: - `lib/settings.schema.json` is the COMMITTED JSON Schema for settings.yml, generated from the zod slices in `src/sections//schema.ts` as composed into the settings document by `src/schema.ts`. Each slice is ONE declaration that produces its config type (z.infer), the section's tolerant runtime shape (the section module derives `loosen()` itself; the factory-minted families - secrets, variables, the two setup sections, and the list sections on `listSection` - get it from their shared factory in `src/sections/shared/`, which does that derivation once), and its part of the published schema (`.meta({id})` names its definitions; the descriptions come from the `schema:` map in the section's `.docs.yml`, keyed `Definition.field`, which the generator attaches and fails on when a site or a key is unmatched); `src/schema.ts` adds only the document-level wrappers and pins each property to its slice with a lockstep type. Regenerate the schema with `bun run build:schema` after a schema-affecting `src/` change - CI's schema-check job fails when it drifts. `lib/index.js`, the bundled entrypoint the action runs (node24), is NOT committed on main: every workflow that executes the action builds it first (`bun run build:bundle`), the e2e runner builds it to a temp path, and a release builds and commits it on a packaged child of the release commit, which is what every `vX.Y.Z` tag and the moving major point at (main itself stays source-only). `lib/` is exempt from the typography check (third-party unicode in the bundle; the schema descriptions are checked at source) and excluded from [biome](https://biomejs.dev). - The import layering of `src/` is declared in `architecture.yml` and enforced by `bun run lint:arch`, so a new cross-layer import is a deliberate edit to that file; the module map in `docs/reference/architecture.md` is generated from it. -- The apply/check engine layout: `src/main.ts` is the thin bundled entrypoint; `src/io.ts` is the Io output port (action-layer-free; the action implements it over @actions/core); `src/types.ts` holds the leaf type vocabulary; `src/action/` is the GitHub Actions layer (inputs, settings reading, step summaries, redaction, the single- and multi-repo run flows); `src/engine/` is the per-repo pipeline (orchestrate, validate, merge, diff); `src/github/` is the REST client, pagination, and repo-file fetch; `src/discovery/` resolves multi-repo targets; `src/report/` composes the private report. Under `src/sections/`, each settings section is one self-contained DIRECTORY `src/sections//`: `index.ts` (the `SectionModule` - key, PAT grant advice, the loose shape derived from the slice, handler), `schema.ts` (the zod slice), `mock.ts` (the e2e mock fragment), `generators.ts` (the fuzz fragment; the secret and variable factory families share theirs in `test/e2e/generators.ts` instead), `.test.ts` (absent where a shared factory's suite under `test/sections/` covers the section), and `scenarios/`. Beside the section directories sit `contract/` (the layered section contract: permissions/endpoints/graphql/module/errors/requests/live), `shared/` (the cross-section engines and factories, plus the schema helpers), and `registry.ts` (the single registration point). All GitHub API list calls must go through `listAll()` (bare-array endpoints) or `listAllEnveloped()` (endpoints that wrap the list in a `{total_count, : []}` envelope), both backed by the single page loop in `src/github/paginate.ts`; errors through `call()`/`throwFor()` so the permission policy (`on-missing-permission`, `required-sections`) works. +- The apply/check engine layout: `src/main.ts` is the thin bundled entrypoint; `src/index.ts` is the public entry (declaration-only re-exports, pinned by test/library/public-surface.test.ts); `src/action/` is the GitHub Actions layer and a CONSUMER of that entry: its only import outside itself is `../index.js`, the one edge `architecture.yml` grants it; `src/flows/` holds the run flows the action and a library caller share (single, multi, merge, delivery, redaction); `src/io.ts` is the import-free Io output port; `src/types.ts` holds the leaf type vocabulary; `src/engine/` is the per-repo pipeline (orchestrate, validate, merge, diff, secret references); `src/github/` is the REST client, pagination, and repo-file fetch; `src/discovery/` resolves multi-repo targets; `src/report/` composes and delivers the private report. Under `src/sections/`, each settings section is one self-contained DIRECTORY `src/sections//`: `index.ts` (the `SectionModule` - key, PAT grant advice, the loose shape derived from the slice, handler), `schema.ts` (the zod slice), `mock.ts` (the e2e mock fragment), `generators.ts` (the fuzz fragment; the secret and variable factory families share theirs in `test/e2e/generators.ts` instead), `.test.ts` (absent where a shared factory's suite under `test/sections/` covers the section), and `scenarios/`. Beside the section directories sit `contract/` (the layered section contract: permissions/endpoints/graphql/module/errors/requests/live), `shared/` (the cross-section engines and factories, plus the schema helpers), and `registry.ts` (the single registration point). All GitHub API list calls must go through `listAll()` (bare-array endpoints) or `listAllEnveloped()` (endpoints that wrap the list in a `{total_count, : []}` envelope), both backed by the single page loop in `src/github/paginate.ts`; errors through `call()`/`throwFor()` so the permission policy (`on-missing-permission`, `required-sections`) works. - Adding a section: create `src/sections//` with the six pieces above plus its `.docs.yml` (the Sections table cells for docs/reference/sections.md, the COVERAGE.md prose, and the schema descriptions the generators render; the docs loader fails without it), compose the `SettingsFile` property from the slice and add the key to `SECTION_KEYS` in `src/schema.ts`, and add one line in `src/sections/registry.ts` - the compiler flags each forgotten step (the registry's mapped type, the mock fragment registration, the slice composition pin, and the generator aggregation are all keyed by SectionKey). - Adding a section endpoint: declare it in the section module's `ENDPOINTS`, add the matching handler under its `section.role` key in the section's `mock.ts` (the fragment's handler record is typed over the section's exact key union, so a missing or phantom handler fails to compile, and `assertHandlerCompleteness()` backstops at construction), add a scenario that reaches it (the coverage tripwire fails on a cold route), and regenerate the trimmed spec (`bun .github/scripts/trim-openapi.ts`; the artifact stays gitignored). `USED_PATHS` (in `test/e2e/openapi/paths.ts`) picks the new path up automatically. Core routes the action calls outside the sections (the repo fetch, the settings-file contents read, `repos: "*"` discovery, and the private-report issue channel) are served by a separate core-path handler, and a request that matches no registered section or core route fails loudly. - Two declarations on each section are single sources the rest of the system reads: `permission` (a `SectionPermission`) drives the PAT grant prose (`grantFor`), the mock's permission gate, and the fuzz oracle; the `ENDPOINTS` dictionary drives the paths, the mock routes, and `USED_PATHS`. `undeclaredDefault` drives the Undeclared-default column of the generated Sections table on docs/reference/sections.md (`bun run build:docs`; `build:check` fails on drift). Change one and its consumers follow. diff --git a/architecture.yml b/architecture.yml index 967c3d6f..4afdd494 100644 --- a/architecture.yml +++ b/architecture.yml @@ -9,6 +9,8 @@ layers: main: [src/main.ts] action: [src/action/] + library: [src/index.ts] + flows: [src/flows/] engine: [src/engine/] sections: [src/sections/] github: [src/github/] @@ -28,13 +30,16 @@ exclude: - "src/**/*.d.ts" # from -> the layers it may import; a layer absent here imports nothing outside itself. +# The action is a consumer of the library: its one edge is src/index.ts. edges: main: [action] - action: [discovery, engine, github, io, private, report, schema, types] - engine: [action, discovery, github, io, plain-data, schema, sections, types] + action: [library] + library: [discovery, engine, flows, github, io, report, schema, sections, types] + flows: [discovery, engine, github, io, private, report, schema] + engine: [discovery, github, io, plain-data, schema, sections, types] sections: [discovery, engine, github, schema, types, upstream-gaps] github: [io, plain-data] discovery: [github, private] - report: [action, discovery, engine, github, io, private, schema, sections, types] + report: [discovery, engine, github, io, private, schema, sections, types] schema: [sections, types] upstream-gaps: [types] diff --git a/biome.json b/biome.json index df8615a1..ce1f3574 100644 --- a/biome.json +++ b/biome.json @@ -73,7 +73,7 @@ } }, { - "includes": ["src/action/redact.ts"], + "includes": ["src/flows/redact.ts"], "linter": { "rules": { "style": { diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 2d90945d..e3f7fd13 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -14,7 +14,7 @@ The [module map](#the-module-map) at the end is generated from [architecture.yml ```mermaid flowchart TD - read["src/action/settings-read.ts
readSettingsFile()"] + read["src/flows/settings-read.ts
readSettingsFile()"] mode{"mode"} fold["src/engine/layers.ts
stripNulls() mergeLayers()"] validate["src/engine/orchestrate.ts
validateSettingsDoc()"] @@ -25,7 +25,7 @@ flowchart TD api["src/github/api.ts
GithubClient"] drift["check: the plan's drift lines
src/sections/contract/plan.ts planDrift() planCheckNotes()"] exec["apply: the plan's writes
src/engine/execute.ts executePlan()"] - report["src/action/deliver.ts
concludeRun()"] + report["src/flows/deliver.ts
concludeRun()"] read -->|YAML text, parsed to an unknown document per file| mode mode -->|merge: every layer, each validated on its own first| fold fold -->|one folded document, a notice per null opt-out| validate @@ -124,8 +124,8 @@ Demonstrated by: [test/e2e/scenarios/merge-union-optout.yml](https://github.com/ flowchart LR operator["operator-authored documents
settings-file layers, repos-dir files, the defaults-file"] target["target-authored documents
a repository's own settings file, fetched via repos"] - refs["src/action/secret-refs.ts
validateSecretRef()"] - resolve["src/action/secret-refs.ts
resolveSecretRefs()"] + refs["src/engine/secret-refs.ts
validateSecretRef()"] + resolve["src/engine/secret-refs.ts
resolveSecretRefs()"] refused["hard error for that section"] operator -->|SettingsSource operator| refs target -->|SettingsSource target| refs @@ -140,7 +140,7 @@ Two kinds of document reach the engine: A `$NAME` secret reference resolves from the workflow step's environment, so it is honored only in operator documents. A target repository must never be able to route the operator's secrets into itself. Provenance is a property of the source document, decided once where the document is chosen, and every value in it shares that one source. -Demonstrated by: [test/action/secret-refs.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/action/secret-refs.test.ts), [test/engine/secrets.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/secrets.test.ts), [test/e2e/scenarios/multi-secrets-target-ref-rejected.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-secrets-target-ref-rejected.yml). +Demonstrated by: [test/engine/secret-refs.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/secret-refs.test.ts), [test/engine/secrets.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/secrets.test.ts), [test/e2e/scenarios/multi-secrets-target-ref-rejected.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-secrets-target-ref-rejected.yml). ## The section-module contract @@ -191,7 +191,7 @@ Demonstrated by: [test/sections/registry.test.ts](https://github.com/Vivswan/git ```mermaid flowchart TD targets["src/discovery/central.ts resolveCentralTargets()
src/discovery/discover.ts discoverRepos()"] - each["one target at a time
src/action/multi.ts runMulti()"] + each["one target at a time
src/flows/multi.ts runMulti()"] own["applied from that file, as written"] fetch["src/github/repo-file.ts
getRepoFile()"] probe["the repository object names the default branch"] @@ -219,7 +219,7 @@ Targets come from checked-in files under `repos-dir`, from the `repos` list, or - A contents 404 alone proves nothing (a missing file, a missing grant, or an invisible repository all look the same). The proof is the default-branch ref read, a call that needs Contents: read and succeeds whether or not the file exists: 200 means the token could have read the file, so the file is absent. - A ref read that is denied leaves the proof inconclusive, and the target fails naming the grant. A denial must never look like a missing file. -Demonstrated by: [test/action/multi.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/action/multi.test.ts), [test/e2e/scenarios/multi-missing-and-failing.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-missing-and-failing.yml), [test/e2e/scenarios/multi-contents-denied.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-contents-denied.yml). +Demonstrated by: [test/flows/multi.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/flows/multi.test.ts), [test/e2e/scenarios/multi-missing-and-failing.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-missing-and-failing.yml), [test/e2e/scenarios/multi-contents-denied.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-contents-denied.yml). ## The module map @@ -230,6 +230,8 @@ Each node is one layer of `src/`, labelled with the paths it owns; an arrow mean graph TD main["src/main.ts"] action["src/action/"] + library["src/index.ts"] + flows["src/flows/"] engine["src/engine/"] sections["src/sections/"] github["src/github/"] @@ -242,15 +244,23 @@ graph TD private["src/private.ts
src/private-open.ts"] upstream_gaps["src/upstream-gaps/"] main --> action - action --> discovery - action --> engine - action --> github - action --> io - action --> private - action --> report - action --> schema - action --> types - engine --> action + action --> library + library --> discovery + library --> engine + library --> flows + library --> github + library --> io + library --> report + library --> schema + library --> sections + library --> types + flows --> discovery + flows --> engine + flows --> github + flows --> io + flows --> private + flows --> report + flows --> schema engine --> discovery engine --> github engine --> io @@ -268,7 +278,6 @@ graph TD github --> plain_data discovery --> github discovery --> private - report --> action report --> discovery report --> engine report --> github diff --git a/knip.json b/knip.json index 96f7ec21..2d4d5ce0 100644 --- a/knip.json +++ b/knip.json @@ -2,6 +2,7 @@ "$schema": "https://unpkg.com/knip@6/schema.json", "entry": [ "src/main.ts", + "src/index.ts", "src/schema.ts", ".github/scripts/*.ts", "**/*.test.ts", diff --git a/src/action/artifact.ts b/src/action/artifact.ts new file mode 100644 index 00000000..ec256c27 --- /dev/null +++ b/src/action/artifact.ts @@ -0,0 +1,33 @@ +/** + * The ArtifactUploader port over @actions/artifact: the one production + * implementation, so the report layer never touches the artifact service. + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DefaultArtifactClient } from "@actions/artifact"; +import type { ArtifactUploader } from "../index.js"; + +/** @actions/artifact uploads files from disk, so the ciphertext round-trips through a private temp directory. */ +export const actionsArtifactUploader: ArtifactUploader = { + async upload(name, file) { + // DefaultArtifactClient emits its own core.warning before it throws on a + // missing runtime token, so invoking it without one would double-warn. + // Fail before constructing it; deliverArtifactReport turns this into the + // single warning callers see. + if (!process.env.ACTIONS_RUNTIME_TOKEN) { + throw new Error( + "the artifact service is unavailable: no ACTIONS_RUNTIME_TOKEN in the environment. Artifact upload needs a GitHub-hosted or self-hosted Actions runner (it is not available on GitHub Enterprise Server or outside Actions)", + ); + } + const dir = await mkdtemp(join(tmpdir(), "settings-as-code-report-")); + try { + const path = join(dir, file.name); + await writeFile(path, file.data); + await new DefaultArtifactClient().uploadArtifact(name, [path], dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, +}; diff --git a/src/action/inputs.ts b/src/action/inputs.ts index 2d6c95e2..1d7be212 100644 --- a/src/action/inputs.ts +++ b/src/action/inputs.ts @@ -5,39 +5,38 @@ * execution code never touches raw inputs. */ -import { resolve } from "node:path"; import * as core from "@actions/core"; import { AFFILIATIONS, ARCHIVED_FILTERS, + DEFAULT_API_VERSION, DEFAULT_DISCOVERY_FILTERS, + DEFAULT_SETTINGS_FILE, type DiscoveryFilters, FORKS_FILTERS, - VISIBILITY_FILTERS, -} from "../discovery/discover.js"; -import { parseRepoSlug, type RepoRef } from "../discovery/targets.js"; -import type { Layering } from "../engine/layers.js"; -import { DEFAULT_API_VERSION } from "../github/api.js"; -import { parseRecipient } from "../report/artifact-report.js"; -import { SECTION_KEYS, type SectionKey } from "../schema.js"; -import type { MustBeNever } from "../types.js"; -import { - DEFAULT_PRIVATE_REPORT, - DEFAULT_PRIVATE_REPOS, + type Layering, + type MergeConfig, + type MultiConfig, + type MustBeNever, PRIVATE_REPORT_CHANNELS, PRIVATE_REPOS_POLICIES, type PrivateReportChannel, type PrivateReposPolicy, -} from "./redact.js"; + parseRecipient, + parseRepoSlug, + quoteList, + type RunFlowConfig, + SECTION_KEYS, + type SectionKey, + type SingleConfig, + VISIBILITY_FILTERS, +} from "../index.js"; -/** - * Default settings-file path, and the sentinel the multi-repo guard - * compares against: an unchanged value means the user did not override it, - * so combining it with repos/repos-dir is rejected. Single source for the - * action.yml `settings-file` default, this fallback, the override check, - * and multi.ts's remote-path prose. - */ -export const DEFAULT_SETTINGS_FILE = ".github/settings.yml"; +/** Default `private-repos`, pinned against action.yml by the contract test. */ +export const DEFAULT_PRIVATE_REPOS = "redact" satisfies PrivateReposPolicy; + +/** Default `private-report`, pinned against action.yml by the contract test. */ +const DEFAULT_PRIVATE_REPORT = "none" satisfies PrivateReportChannel; /** * One action input: its action.yml entry (description, default) and its @@ -340,10 +339,6 @@ function readEnum( return value as T; } -export function quoteList(names: string[]): string { - return names.map((name) => `"${name}"`).join(", "); -} - /** What separates the entries of a list input; a single path can never contain one. */ const LIST_SEPARATOR = /[\n,]/; @@ -463,68 +458,21 @@ function resolveReportPublicKey(channel: PrivateReportChannel): string | { error return key; } -/** The inputs shared by the two engine modes (apply and check). */ -interface CommonConfig { - token: string; - mode: "apply" | "check"; - onMissingPermission: "fail" | "warn"; - requiredSections: Set; - onlySections: Set; - apiVersion: string; - /** Whether to hide private/internal targets from the public view. */ - privateRepos: PrivateReposPolicy; - /** Where the full unredacted report for a redacted target is delivered. */ - privateReport: PrivateReportChannel; - /** - * The age recipient the `artifact` channel encrypts every report to. Empty - * for the other channels (parse rejects a value supplied without the artifact - * channel), a validated `age1...` recipient when the channel is `artifact`. - */ - reportPublicKey: string; - /** - * The workflow's own repository (GITHUB_REPOSITORY), read once here so the - * run flows stay env-free. A target equal to this slug is never redacted: - * a repository operating on itself leaks nothing. - */ - selfSlug: string; - /** - * Link to the workflow run, for the private report metadata. Built once here - * from GITHUB_SERVER_URL/GITHUB_REPOSITORY/GITHUB_RUN_ID so the run flows stay - * env-free; empty when those are unset (local runs), which the report tolerates. - */ - runUrl: string; -} - /** - * A mode: merge run: the layers to fold, low to high, and where the result - * goes. No token, no API version, no target, no section allowlist: merge mode - * never reaches GitHub and writes every section the layers declare, and the - * type carries that (no field to read a token or an allowlist from). + * The inputs shared by the two engine modes (apply and check): the flow + * config plus what only the action reads. selfSlug (GITHUB_REPOSITORY) and + * runUrl (GITHUB_SERVER_URL/GITHUB_REPOSITORY/GITHUB_RUN_ID) are read from + * the environment once here, so the run flows stay env-free. */ -export interface MergeConfig { - kind: "merge"; - settingsFiles: string[]; - mergedFile: string; - layering: Layering; +interface CommonConfig extends RunFlowConfig { + token: string; + apiVersion: string; } /** Everything run() needs, already validated; `kind` picks the mode. */ export type RunConfig = - | (CommonConfig & - ( - | { kind: "single"; repo: RepoRef; settingsFile: string } - | { - kind: "multi"; - reposDir: string; - reposInput: string; - defaultsFile: string; - adminOwner: string; - discoveryFilters: DiscoveryFilters; - /** Filter inputs the user explicitly set, for the misuse rejections. */ - discoveryFiltersSet: string[]; - } - )) - | MergeConfig; + | (CommonConfig & (({ kind: "single" } & SingleConfig) | ({ kind: "multi" } & MultiConfig))) + | ({ kind: "merge" } & MergeConfig); /** * The inputs a mode: merge run reads, plus `token`, which it tolerates unread @@ -553,7 +501,7 @@ export const MERGE_REJECTED_INPUTS: readonly InputName[] = ( ).filter((name) => !(MERGE_INPUTS as readonly string[]).includes(name)); /** Read and validate the mode: merge inputs; the first problem wins. */ -function parseMergeConfig(): { config: MergeConfig } | { error: string } { +function parseMergeConfig(): { config: Extract } | { error: string } { const rejected = MERGE_REJECTED_INPUTS.filter((name) => { const value = input(name); return value !== "" && value !== INPUT_DECLS[name].default; @@ -584,21 +532,6 @@ function parseMergeConfig(): { config: MergeConfig } | { error: string } { error: `the "settings-file" input is "${inputOrDefault("settings-file")}", which lists no file. In mode: merge it is the ordered list of layers to fold, newline- or comma-separated, lowest first; name at least one settings file`, }; } - // The merge reads every layer, then writes the folded document to - // merged-file: a merged-file that names a layer would overwrite that layer, - // and the next run would fold the merged document as if it were a layer. - // Paths are compared resolved, so "./a.yml" and "a.yml" collide. - const mergedPath = resolve(mergedFile); - const collision = settingsFiles.findIndex((layer) => resolve(layer) === mergedPath); - if (collision !== -1) { - return { - error: - `the "merged-file" input "${mergedFile}" is layer ${collision + 1} of the ` + - `"settings-file" list ("${settingsFiles[collision]}"): the merge would overwrite that ` + - `layer with the folded document, and the next run would fold the merged document as a ` + - `layer. Write the merged document to a path outside the layer list`, - }; - } return { config: { kind: "merge", settingsFiles, mergedFile, layering } }; } diff --git a/src/action/io.ts b/src/action/io.ts index 80274c55..be7c66cf 100644 --- a/src/action/io.ts +++ b/src/action/io.ts @@ -5,7 +5,7 @@ import { appendFileSync } from "node:fs"; import * as core from "@actions/core"; -import { type Io, maskRegistry, type OutputName } from "../io.js"; +import { type Io, maskRegistry, type OutputName } from "../index.js"; /** * The description of every action output, generated into the action.yml diff --git a/src/action/merge-mode.ts b/src/action/merge-mode.ts deleted file mode 100644 index 2322beb0..00000000 --- a/src/action/merge-mode.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * The mode: merge run flow: fold the settings-file layers into one validated - * document and write it to merged-file. The written document is exactly what - * a later apply or check step runs from that path; nothing here touches - * GitHub, so the flow takes no client and needs no token. - */ - -import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; -import { stringify as stringifyYaml } from "yaml"; -import { describeOptOut } from "../engine/layers.js"; -import type { Io } from "../io.js"; -import { concludeMerge, failRun } from "./deliver.js"; -import type { MergeConfig } from "./inputs.js"; -import { foldLayers, readLayerFiles } from "./layers.js"; -import { writeMergeSummary } from "./summary.js"; - -/** How the merged document is named in its own validation errors. */ -const MERGED_LABEL = "the merged settings document"; - -/** Execute a mode: merge run; returns the process exit code. */ -export function runMerge(cfg: MergeConfig, io: Io): number { - const read = readLayerFiles(cfg.settingsFiles); - if ("error" in read) { - return failRun(io, read.error); - } - const folded = foldLayers(read.layers, MERGED_LABEL, cfg.layering, io); - if ("error" in folded) { - return failRun(io, folded.error); - } - for (const notice of folded.notices) { - io.annotate("notice", describeOptOut(notice)); - } - try { - mkdirSync(dirname(cfg.mergedFile), { recursive: true }); - writeFileSync(cfg.mergedFile, stringifyYaml(folded.settings)); - } catch (error) { - return failRun( - io, - `cannot write the merged document to ${cfg.mergedFile}: ${String(error)}. Check that the "merged-file" input names a writable path`, - ); - } - writeMergeSummary(io, cfg.settingsFiles, cfg.mergedFile); - return concludeMerge(io, { layers: cfg.settingsFiles, mergedFile: cfg.mergedFile }); -} diff --git a/src/action/run.ts b/src/action/run.ts index 3086792d..503c900a 100644 --- a/src/action/run.ts +++ b/src/action/run.ts @@ -17,61 +17,23 @@ * * mode: merge folds an ordered list of settings files into one document * written to merged-file, for a later apply or check step to run; it never - * touches GitHub (merge-mode.ts). + * touches GitHub (src/flows/merge.ts). */ -import type { RepoRef } from "../discovery/targets.js"; -import { runForRepo, validateSettingsDoc } from "../engine/orchestrate.js"; -import { GithubApi, type GithubClient } from "../github/api.js"; -import { createVisibilityResolver } from "../github/repo-visibility.js"; -import type { Io } from "../io.js"; -import type { ArtifactUploader } from "../report/artifact-report.js"; -import { applyMarkerInjection } from "../report/delivery.js"; import { + type ArtifactUploader, concludeRun, - type Exposure, - engineOutcome, - failedTarget, failRun, - withDelivery, -} from "./deliver.js"; + GithubApi, + type GithubClient, + type Io, + runMerge, + runMulti, + runSingle, +} from "../index.js"; +import { actionsArtifactUploader } from "./artifact.js"; import { parseConfig } from "./inputs.js"; import { actionsIo } from "./io.js"; -import { runMerge } from "./merge-mode.js"; -import { runMulti } from "./multi.js"; -import { attempt, publicChannel, redactedChannel, type TargetChannel } from "./redact.js"; -import { readSettingsFile } from "./settings-read.js"; - -/** - * Open the single-repo target's channel, masking its slug when redacted. - * Redaction fails closed: the target is hidden unless the probe proves it - * public (the self repository and the `show` policy skip the probe). - */ -async function openSingleRepoChannel( - api: GithubClient, - cfg: { privateRepos: string; repo: RepoRef; selfSlug: string }, - io: Io, -): Promise<{ channel: TargetChannel; exposure: Exposure }> { - const shown = (): { channel: TargetChannel; exposure: Exposure } => ({ - channel: publicChannel(io, cfg.repo.slug, false), - exposure: { kind: "shown" }, - }); - if (cfg.privateRepos !== "redact") { - return shown(); - } - if (cfg.repo.slug.toLowerCase() === cfg.selfSlug.toLowerCase()) { - return shown(); - } - const visibility = await createVisibilityResolver(api)(cfg.repo.slug); - if (visibility === "public") { - return shown(); - } - io.mask(cfg.repo.slug); - return { - channel: redactedChannel(io, cfg.repo.slug, "private repository"), - exposure: { kind: "redacted", visibility }, - }; -} /** * Execute the action; returns the process exit code. `overrides` exists for @@ -83,6 +45,7 @@ export async function run(overrides?: { uploader?: ArtifactUploader; }): Promise { const io = overrides?.io ?? actionsIo; + const uploader = overrides?.uploader ?? actionsArtifactUploader; const parsed = parseConfig(); if ("error" in parsed) { @@ -96,59 +59,16 @@ export async function run(overrides?: { const api = overrides?.api ?? new GithubApi({ token: cfg.token, io, apiVersion: cfg.apiVersion }); if (cfg.kind === "multi") { - const { fatal, targets } = await runMulti(api, cfg, io, overrides?.uploader); + const { fatal, targets } = await runMulti(api, cfg, io, uploader); if (fatal) { return failRun(io, fatal); } return concludeRun(io, { kind: "multi", mode: cfg.mode, targets }); } - // Single-repo mode. The settings file is local and operator-authored, so - // read/parse/validate errors name only the local path and never redact. - // Only the engine's live-value output and the fail/preflight annotations - // can carry the private target's state, so those go through the channel, - // which captures them when the target is a different, non-public repository. - const read = readSettingsFile(cfg.settingsFile); - if ("error" in read) { - return failRun( - io, - `cannot read settings from ${cfg.settingsFile}: ${read.error}. Check that the file exists at that path (set the "settings-file" input if it lives elsewhere) and is valid YAML`, - ); + const single = await runSingle(api, cfg, io, uploader); + if ("fatal" in single) { + return failRun(io, single.fatal); } - const validated = validateSettingsDoc(read.doc, cfg.settingsFile, cfg.onlySections, io); - if ("error" in validated) { - return failRun(io, validated.error); - } - - const opened = await openSingleRepoChannel(api, cfg, io); - const { channel } = opened; - const target = await withDelivery({ api, cfg, io, uploader: overrides?.uploader }, (delivery) => - delivery.target({ repo: cfg.repo, ...opened }, (injectsMarker) => { - const injected = applyMarkerInjection(validated.settings, injectsMarker); - if (injected.notice) { - channel.io.annotate("notice", injected.notice); - } - return attempt( - channel, - async () => - engineOutcome( - await runForRepo( - api, - { - repo: cfg.repo, - settings: injected.settings, - mode: cfg.mode, - onMissingPermission: cfg.onMissingPermission, - requiredSections: cfg.requiredSections, - onlySections: cfg.onlySections, - }, - channel.io, - ), - channel.io, - ), - failedTarget, - ); - }), - ); - return concludeRun(io, { kind: "single", mode: cfg.mode, target }); + return concludeRun(io, { kind: "single", mode: cfg.mode, target: single.target }); } diff --git a/src/engine/orchestrate.ts b/src/engine/orchestrate.ts index 343e7716..def8a2d4 100644 --- a/src/engine/orchestrate.ts +++ b/src/engine/orchestrate.ts @@ -4,11 +4,6 @@ * All output goes through the Io sink; callers decide how (or whether) to tag lines per repository. */ -import { - resolveSecretRefs, - type SettingsSource, - validateSecretRef, -} from "../action/secret-refs.js"; import type { RepoRef } from "../discovery/targets.js"; import type { GithubClient } from "../github/api.js"; import type { Io } from "../io.js"; @@ -23,6 +18,7 @@ import { import { SECTIONS } from "../sections/registry.js"; import type { MustBeNever } from "../types.js"; import { executePlan } from "./execute.js"; +import { resolveSecretRefs, type SettingsSource, validateSecretRef } from "./secret-refs.js"; import { collectSecretValues, type SectionSecretValue } from "./secrets.js"; import { validateSectionShapes } from "./validate.js"; diff --git a/src/action/secret-refs.ts b/src/engine/secret-refs.ts similarity index 98% rename from src/action/secret-refs.ts rename to src/engine/secret-refs.ts index a647bf39..e664e482 100644 --- a/src/action/secret-refs.ts +++ b/src/engine/secret-refs.ts @@ -101,7 +101,7 @@ export type SecretRefsResolution = /** * One designated secret field's value, tagged with the provenance of the * DOCUMENT that declared it. Provenance is decided once, where the document - * is chosen (action/multi.ts readTargetSettings): a target-fetched document + * is chosen (flows/multi.ts readTargetSettings): a target-fetched document * is "target", so its references are refused, and every other document is * "operator", so its references resolve. */ diff --git a/src/engine/secrets.ts b/src/engine/secrets.ts index 002a5c39..155ba460 100644 --- a/src/engine/secrets.ts +++ b/src/engine/secrets.ts @@ -11,13 +11,13 @@ * operator layers merge mode folds into it), a central per-repo file, and the * defaults document applied whole to a target that has no file of its own - * is operator-authored. The multi-repo flow decides the value where it - * chooses the document (action/multi.ts); the single-repo flow takes the + * chooses the document (flows/multi.ts); the single-repo flow takes the * engine's "operator" default. */ -import type { SettingsSource, SourcedSecretValue } from "../action/secret-refs.js"; import type { SectionKey, SettingsFile } from "../schema.js"; import type { SectionModule } from "../sections/contract/module.js"; +import type { SettingsSource, SourcedSecretValue } from "./secret-refs.js"; /** One declared secret value, tagged with the section that declared it. */ export interface SectionSecretValue extends SourcedSecretValue { diff --git a/src/action/deliver.ts b/src/flows/deliver.ts similarity index 87% rename from src/action/deliver.ts rename to src/flows/deliver.ts index d9602b59..141a2aed 100644 --- a/src/action/deliver.ts +++ b/src/flows/deliver.ts @@ -17,12 +17,18 @@ import type { RepoVisibility } from "../github/repo-visibility.js"; import type { Io } from "../io.js"; import { isPrivate } from "../private.js"; import type { ArtifactUploader } from "../report/artifact-report.js"; -import { openReportChannel, type RunConclusion } from "../report/delivery.js"; import { - emitRedactedResult, + ARTIFACT_NEEDS_UPLOADER, isIssueChannel, - isPrivateVisibility, + openReportChannel, type PrivateReportChannel, + type RunConclusion, +} from "../report/delivery.js"; +import type { SectionKey } from "../schema.js"; +import { + emitRedactedResult, + isPrivateVisibility, + type PrivateReposPolicy, type PublicTargetView, publicDetail, type TargetChannel, @@ -93,11 +99,36 @@ export function runOutcome( export interface DeliveryConfig { mode: "apply" | "check"; privateReport: PrivateReportChannel; + /** The age recipient the `artifact` channel encrypts every report to; empty for the other channels. */ reportPublicKey: string; + /** The repository the run acts for; a target equal to it is never redacted. */ selfSlug: string; + /** Link to the workflow run, for the private report metadata; may be empty. */ runUrl: string; } +/** The inputs the single- and multi-repo flows share: the engine options, the redaction policy, and the delivery inputs. */ +export interface RunFlowConfig extends DeliveryConfig { + onMissingPermission: "fail" | "warn"; + requiredSections: Set; + onlySections: Set; + /** Whether to hide private/internal targets from the public view. */ + privateRepos: PrivateReposPolicy; +} + +/** + * The fatal message when the `artifact` channel has no upload port, null otherwise. + * Both flows check it before any API work; openReportChannel asserts the same rule. + */ +export function missingUploaderProblem( + cfg: Pick, + uploader: ArtifactUploader | undefined, +): string | null { + return cfg.privateReport === "artifact" && uploader === undefined + ? ARTIFACT_NEEDS_UPLOADER + : null; +} + /** * How a target appears in the public view: in the clear, or hidden behind * its placeholder together with the visibility the probe resolved for it. @@ -126,7 +157,6 @@ export interface Delivery { /** * Run `body` with the run's delivery open, then flush it even when `body` throws: * the artifact channel uploads every accumulated report as ONE document here. - * `uploader` is the test port; production passes undefined for @actions/artifact. */ export async function withDelivery( run: { api: GithubClient; cfg: DeliveryConfig; io: Io; uploader?: ArtifactUploader }, diff --git a/src/action/layers.ts b/src/flows/layers.ts similarity index 100% rename from src/action/layers.ts rename to src/flows/layers.ts diff --git a/src/flows/library.ts b/src/flows/library.ts new file mode 100644 index 00000000..0c3d1d51 --- /dev/null +++ b/src/flows/library.ts @@ -0,0 +1,77 @@ +/** The engine for a caller with no action inputs: the mode and the Io sink are fixed here. */ + +import { stringify as stringifyYaml } from "yaml"; +import { + type RepoRunOptions, + type RepoRunResult, + runForRepo, + type ValidatedSettings, + validateSettingsDoc, +} from "../engine/orchestrate.js"; +import type { GithubClient } from "../github/api.js"; +import { type CollectedLine, collectingIo, type Io } from "../io.js"; +import type { SectionKey } from "../schema.js"; + +/** How a document with no caller-given source is named in its own errors. */ +const UNNAMED_SOURCE = "the settings document"; + +const NO_ALLOWLIST: ReadonlySet = new Set(); + +/** `sections` is the allowlist; unknown keys outside it come back as `warnings` instead of failing. */ +export function validateSettings( + doc: unknown, + options: { source?: string; sections?: ReadonlySet } = {}, +): { ok: true; settings: ValidatedSettings; warnings: string[] } | { ok: false; error: string } { + const collected = collectingIo(); + const validated = validateSettingsDoc( + doc, + options.source ?? UNNAMED_SOURCE, + options.sections ?? NO_ALLOWLIST, + collected.io, + ); + if ("error" in validated) { + return { ok: false, error: validated.error }; + } + return { + ok: true, + settings: validated.settings, + warnings: collected.lines.map((entry) => entry.line), + }; +} + +/** The engine's result plus every line it printed when the caller brought no Io of their own. */ +export type RepoRunReport = RepoRunResult & { log: CollectedLine[] }; + +async function runMode( + client: GithubClient, + opts: Omit, + mode: RepoRunOptions["mode"], + io: Io | undefined, +): Promise { + const collected = collectingIo(); + const result = await runForRepo(client, { ...opts, mode }, io ?? collected.io); + return { ...result, log: io === undefined ? collected.lines : [] }; +} + +/** Plan and diff every active section against the live repository; nothing is written. */ +export function checkRepository( + client: GithubClient, + opts: Omit, + io?: Io, +): Promise { + return runMode(client, opts, "check", io); +} + +/** Plan every active section and execute the plan's writes. */ +export function applyRepository( + client: GithubClient, + opts: Omit, + io?: Io, +): Promise { + return runMode(client, opts, "apply", io); +} + +/** The merged document exactly as mode: merge writes it to merged-file. */ +export function renderMergedYaml(settings: ValidatedSettings): string { + return stringifyYaml(settings); +} diff --git a/src/flows/merge.ts b/src/flows/merge.ts new file mode 100644 index 00000000..927b3a3c --- /dev/null +++ b/src/flows/merge.ts @@ -0,0 +1,79 @@ +/** + * The mode: merge run flow: fold the settings-file layers into one validated + * document and write it to merged-file. The written document is exactly what + * a later apply or check step runs from that path; nothing here touches + * GitHub, so the flow takes no client and needs no token. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { describeOptOut, type Layering } from "../engine/layers.js"; +import type { Io } from "../io.js"; +import { concludeMerge, failRun } from "./deliver.js"; +import { foldLayers, readLayerFiles } from "./layers.js"; +import { renderMergedYaml } from "./library.js"; +import { writeMergeSummary } from "./summary.js"; + +/** + * A mode: merge run: the layers to fold, low to high, and where the result + * goes. No token, no API version, no target, no section allowlist: merge mode + * never reaches GitHub and writes every section the layers declare, and the + * type carries that (no field to read a token or an allowlist from). + */ +export interface MergeConfig { + settingsFiles: string[]; + mergedFile: string; + layering: Layering; +} + +/** How the merged document is named in its own validation errors. */ +const MERGED_LABEL = "the merged settings document"; + +/** + * The layer merged-file would overwrite, as a message, or null. Paths are + * compared resolved, so "./a.yml" and "a.yml" collide. Guarded here, beside + * the write: the next run would fold the merged document as if it were a layer. + */ +function mergedFileCollision(cfg: MergeConfig): string | null { + const mergedPath = resolve(cfg.mergedFile); + const collision = cfg.settingsFiles.findIndex((layer) => resolve(layer) === mergedPath); + if (collision === -1) { + return null; + } + return ( + `the "merged-file" input "${cfg.mergedFile}" is layer ${collision + 1} of the ` + + `"settings-file" list ("${cfg.settingsFiles[collision]}"): the merge would overwrite that ` + + `layer with the folded document, and the next run would fold the merged document as a ` + + `layer. Write the merged document to a path outside the layer list` + ); +} + +/** Execute a mode: merge run; returns the process exit code. */ +export function runMerge(cfg: MergeConfig, io: Io): number { + const collision = mergedFileCollision(cfg); + if (collision !== null) { + return failRun(io, collision); + } + const read = readLayerFiles(cfg.settingsFiles); + if ("error" in read) { + return failRun(io, read.error); + } + const folded = foldLayers(read.layers, MERGED_LABEL, cfg.layering, io); + if ("error" in folded) { + return failRun(io, folded.error); + } + for (const notice of folded.notices) { + io.annotate("notice", describeOptOut(notice)); + } + try { + mkdirSync(dirname(cfg.mergedFile), { recursive: true }); + writeFileSync(cfg.mergedFile, renderMergedYaml(folded.settings)); + } catch (error) { + return failRun( + io, + `cannot write the merged document to ${cfg.mergedFile}: ${String(error)}. Check that the "merged-file" input names a writable path`, + ); + } + writeMergeSummary(io, cfg.settingsFiles, cfg.mergedFile); + return concludeMerge(io, { layers: cfg.settingsFiles, mergedFile: cfg.mergedFile }); +} diff --git a/src/action/multi.ts b/src/flows/multi.ts similarity index 94% rename from src/action/multi.ts rename to src/flows/multi.ts index 345f2313..3f8b14e3 100644 --- a/src/action/multi.ts +++ b/src/flows/multi.ts @@ -30,6 +30,7 @@ import { type Target, } from "../discovery/targets.js"; import { runForRepo, type ValidatedSettings, validateSettingsDoc } from "../engine/orchestrate.js"; +import type { SettingsSource } from "../engine/secret-refs.js"; import { type GithubClient, isPermissionError, RERUN_ADVICE } from "../github/api.js"; import { getRepoFile } from "../github/repo-file.js"; import { createVisibilityResolver, type RepoVisibility } from "../github/repo-visibility.js"; @@ -37,51 +38,47 @@ import type { Io } from "../io.js"; import type { Private } from "../private.js"; import type { ArtifactUploader } from "../report/artifact-report.js"; import { applyMarkerInjection } from "../report/delivery.js"; -import type { SectionKey } from "../schema.js"; import { engineOutcome, failedTarget, + missingUploaderProblem, type OpenedTarget, + type RunFlowConfig, type TargetResult, targetFailure, withDelivery, } from "./deliver.js"; -import { DEFAULT_SETTINGS_FILE, quoteList } from "./inputs.js"; import { attempt, openTargetChannel, - type PrivateReportChannel, - type PrivateReposPolicy, planRedaction, type RedactionPlan, type TargetChannel, type TargetOutcome, } from "./redact.js"; -import type { SettingsSource } from "./secret-refs.js"; import { parseSettingsDoc, readSettingsFile } from "./settings-read.js"; -export interface MultiConfig { +/** + * The settings file a remote target is read from, and the action's default + * `settings-file`: the single source for the action.yml default, the + * multi-repo override guard in src/action/inputs.ts, and the prose below. + */ +export const DEFAULT_SETTINGS_FILE = ".github/settings.yml"; + +/** Names as an error message lists them: each quoted, comma-separated. */ +export function quoteList(names: string[]): string { + return names.map((name) => `"${name}"`).join(", "); +} + +export interface MultiConfig extends RunFlowConfig { reposDir: string; reposInput: string; defaultsFile: string; + /** The owner a bare `.yml` file under repos-dir belongs to. */ adminOwner: string; - mode: "apply" | "check"; - onMissingPermission: "fail" | "warn"; - requiredSections: Set; - onlySections: Set; discoveryFilters: DiscoveryFilters; /** Filter inputs the user explicitly set, for the misuse rejections. */ discoveryFiltersSet: string[]; - /** Whether to hide private/internal targets from the public view. */ - privateRepos: PrivateReposPolicy; - /** Where the full unredacted report for a redacted target is delivered. */ - privateReport: PrivateReportChannel; - /** The age recipient the artifact channel encrypts to (empty otherwise). */ - reportPublicKey: string; - /** GITHUB_REPOSITORY: a target equal to it is never redacted (carve-out). */ - selfSlug: string; - /** Link to the workflow run, for the private report metadata (may be empty). */ - runUrl: string; } /** @@ -261,9 +258,6 @@ export async function runMulti( api: GithubClient, cfg: MultiConfig, io: Io, - // The artifact upload port, injected only by tests alongside the stub api - // and capturing io; production omits it and the real @actions/artifact - // uploader applies. uploader?: ArtifactUploader, ): Promise<{ fatal: string | null; targets: TargetOutcome[] }> { // Central-resolution warnings are buffered so nothing emits before the @@ -288,6 +282,11 @@ export async function runMulti( return { fatal: message, targets: [] }; }; + const noUploader = missingUploaderProblem(cfg, uploader); + if (noUploader !== null) { + return fail(noUploader); + } + let defaults: ValidatedSettings | null = null; if (cfg.defaultsFile) { const read = readSettingsFile(cfg.defaultsFile); diff --git a/src/action/redact.ts b/src/flows/redact.ts similarity index 83% rename from src/action/redact.ts rename to src/flows/redact.ts index bd3e6a8b..fe779082 100644 --- a/src/action/redact.ts +++ b/src/flows/redact.ts @@ -14,44 +14,17 @@ import type { Target } from "../discovery/targets.js"; import type { RepoRunResult } from "../engine/orchestrate.js"; import type { RepoVisibility } from "../github/repo-visibility.js"; -import { type AnnotationLevel, type Io, prefixedIo } from "../io.js"; +import { type CollectedLine, type Io, prefixedIo } from "../io.js"; import { isPrivate, markPrivate, type Private } from "../private.js"; import { revealPrivate } from "../private-open.js"; +import type { RedactedDetail, TargetDetail } from "../report/delivery.js"; import type { SectionKey } from "../schema.js"; /** The `private-repos` input values; the single source its type derives from. */ export const PRIVATE_REPOS_POLICIES = ["redact", "show"] as const; -/** Default `private-repos`, pinned against action.yml by the contract test. */ -export const DEFAULT_PRIVATE_REPOS = "redact"; - export type PrivateReposPolicy = (typeof PRIVATE_REPOS_POLICIES)[number]; -/** - * The `private-report` channel values. `none` delivers nothing; `issue` posts - * the full unredacted report to the private target repo itself (the one - * GitHub-ACL-private channel a public run has); `issue-on-failure` is the - * quiet variant of `issue` - it writes the issue only when the run needs - * attention (failed, or check-mode drift) and closes it on recovery, so a - * healthy repo never sees an issue; `artifact` uploads every redacted - * target's report as one age-encrypted workflow artifact, for readers who - * hold the key but no GitHub access to the targets. - */ -export const PRIVATE_REPORT_CHANNELS = ["none", "issue", "issue-on-failure", "artifact"] as const; - -/** Default `private-report`, pinned against action.yml by the contract test. */ -export const DEFAULT_PRIVATE_REPORT = "none"; - -export type PrivateReportChannel = (typeof PRIVATE_REPORT_CHANNELS)[number]; - -/** The channels that deliver through the target repo's report issue. */ -export type IssueChannel = Extract; - -/** Narrow a channel to the issue-delivering pair. */ -export function isIssueChannel(channel: PrivateReportChannel): channel is IssueChannel { - return channel === "issue" || channel === "issue-on-failure"; -} - /** * The note appended to every redacted line: it names the two escape hatches * (opt out, or run from a context where the target's own logs are private). @@ -75,22 +48,6 @@ export const WITHHELD_REPORT_NOTICE = "withheld rather than risk delivering it to a public repository. Grant the token metadata " + "read access and re-run; a transient API failure also leaves visibility unverified"; -/** - * One target's rich end state: slug, section outcomes with live detail, and - * the note for a skip or failure that produced no outcomes. Open in the clear; - * sealed with the transcript when redacted. - */ -interface TargetDetail { - slug: string; - outcomes: RepoRunResult["outcomes"]; - note?: string; -} - -/** A redacted target's detail also carries every line its run would have printed. */ -export interface RedactedDetail extends TargetDetail { - transcript: CapturedLine[]; -} - /** One multi-repo target's end state: safe closed values plus the detail the public view projects from. */ export interface TargetOutcome { source: Target["source"]; @@ -265,19 +222,13 @@ export function planRedaction( }; } -/** One recorded line from a captured Io: annotations carry a level, log lines do not. */ -export interface CapturedLine { - level?: AnnotationLevel; - line: string; -} - /** * An Io that lets nothing textual out: annotate/log are recorded for the * private report, debug/summary/output are dropped (those surfaces are written * from the public view), only the mask registry passes through. */ -export function capturingIo(io: Io): { io: Io; drain(): CapturedLine[] } { - const captured: CapturedLine[] = []; +export function capturingIo(io: Io): { io: Io; drain(): CollectedLine[] } { + const captured: CollectedLine[] = []; return { io: { annotate: (level, message) => captured.push({ level, line: message }), diff --git a/src/action/settings-read.ts b/src/flows/settings-read.ts similarity index 100% rename from src/action/settings-read.ts rename to src/flows/settings-read.ts diff --git a/src/flows/single.ts b/src/flows/single.ts new file mode 100644 index 00000000..9122a645 --- /dev/null +++ b/src/flows/single.ts @@ -0,0 +1,129 @@ +/** + * The single-repo run flow: one local settings file applied to, or checked + * against, one repository. The file is operator-authored, so its read, parse, + * and validation errors name only the local path and never redact. Only the + * engine's live-value output and the fail/preflight annotations can carry the + * target's state, so those go through the target's channel, which captures + * them when the target is a different, non-public repository. + */ + +import type { RepoRef } from "../discovery/targets.js"; +import { runForRepo, validateSettingsDoc } from "../engine/orchestrate.js"; +import type { GithubClient } from "../github/api.js"; +import { createVisibilityResolver } from "../github/repo-visibility.js"; +import type { Io } from "../io.js"; +import type { ArtifactUploader } from "../report/artifact-report.js"; +import { applyMarkerInjection } from "../report/delivery.js"; +import { + type Exposure, + engineOutcome, + failedTarget, + missingUploaderProblem, + type RunFlowConfig, + withDelivery, +} from "./deliver.js"; +import { + attempt, + publicChannel, + redactedChannel, + type TargetChannel, + type TargetOutcome, +} from "./redact.js"; +import { readSettingsFile } from "./settings-read.js"; + +export interface SingleConfig extends RunFlowConfig { + repo: RepoRef; + /** The local settings file, read and validated before the target is touched. */ + settingsFile: string; +} + +/** + * Open the single-repo target's channel, masking its slug when redacted. + * Redaction fails closed: the target is hidden unless the probe proves it + * public (the self repository and the `show` policy skip the probe). + */ +async function openSingleRepoChannel( + api: GithubClient, + cfg: Pick, + io: Io, +): Promise<{ channel: TargetChannel; exposure: Exposure }> { + const shown = (): { channel: TargetChannel; exposure: Exposure } => ({ + channel: publicChannel(io, cfg.repo.slug, false), + exposure: { kind: "shown" }, + }); + if (cfg.privateRepos !== "redact") { + return shown(); + } + if (cfg.repo.slug.toLowerCase() === cfg.selfSlug.toLowerCase()) { + return shown(); + } + const visibility = await createVisibilityResolver(api)(cfg.repo.slug); + if (visibility === "public") { + return shown(); + } + io.mask(cfg.repo.slug); + return { + channel: redactedChannel(io, cfg.repo.slug, "private repository"), + exposure: { kind: "redacted", visibility }, + }; +} + +/** + * Run one repository from its settings file. A problem before the target runs + * (no uploader for the artifact channel, an unreadable or invalid settings + * file) is `fatal`; otherwise the target's outcome, which concludeRun turns + * into the summary, the outputs, and the exit code. + */ +export async function runSingle( + api: GithubClient, + cfg: SingleConfig, + io: Io, + uploader?: ArtifactUploader, +): Promise<{ fatal: string } | { target: Omit }> { + const noUploader = missingUploaderProblem(cfg, uploader); + if (noUploader !== null) { + return { fatal: noUploader }; + } + const read = readSettingsFile(cfg.settingsFile); + if ("error" in read) { + return { + fatal: `cannot read settings from ${cfg.settingsFile}: ${read.error}. Check that the file exists at that path (set the "settings-file" input if it lives elsewhere) and is valid YAML`, + }; + } + const validated = validateSettingsDoc(read.doc, cfg.settingsFile, cfg.onlySections, io); + if ("error" in validated) { + return { fatal: validated.error }; + } + + const opened = await openSingleRepoChannel(api, cfg, io); + const { channel } = opened; + const target = await withDelivery({ api, cfg, io, uploader }, (delivery) => + delivery.target({ repo: cfg.repo, ...opened }, (injectsMarker) => { + const injected = applyMarkerInjection(validated.settings, injectsMarker); + if (injected.notice) { + channel.io.annotate("notice", injected.notice); + } + return attempt( + channel, + async () => + engineOutcome( + await runForRepo( + api, + { + repo: cfg.repo, + settings: injected.settings, + mode: cfg.mode, + onMissingPermission: cfg.onMissingPermission, + requiredSections: cfg.requiredSections, + onlySections: cfg.onlySections, + }, + channel.io, + ), + channel.io, + ), + failedTarget, + ); + }), + ); + return { target }; +} diff --git a/src/action/summary.ts b/src/flows/summary.ts similarity index 100% rename from src/action/summary.ts rename to src/flows/summary.ts diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..535a6d55 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,157 @@ +/** + * The public entry: everything a library consumer, the action included, may + * import from src/. Declaration-only: each name is defined in the layer that + * owns it, and src/action/ reaches the rest of src/ through this file alone + * (architecture.yml declares that as its one edge). + */ + +// Discovery +export { resolveCentralTargets } from "./discovery/central.js"; +export { + AFFILIATIONS, + ARCHIVED_FILTERS, + DEFAULT_DISCOVERY_FILTERS, + type DiscoveryFilters, + discoverRepos, + FORKS_FILTERS, + VISIBILITY_FILTERS, +} from "./discovery/discover.js"; +export { parseReposInput } from "./discovery/repos-input.js"; +// Check and apply one repository +export { + type CentralTarget, + dedupeTargets, + parseRepoSlug, + type RemoteTarget, + type RepoRef, + type Target, +} from "./discovery/targets.js"; +// Validate and merge +export { + describeOptOut, + type Layer, + type Layering, + mergeLayers, + type OptOutNotice, + stripNulls, +} from "./engine/layers.js"; +export { + preflightProbe, + REPO_RESULTS, + type RepoResult, + type RepoRunOptions, + type RepoRunResult, + runForRepo, + type SectionOutcome, + skippedSectionKeys, + type ValidatedSettings, + validateSettingsDoc, + worstOf, +} from "./engine/orchestrate.js"; +// The run flows +export { concludeRun, failRun, MERGE_RESULT, type RunFlowConfig } from "./flows/deliver.js"; +export { foldLayers, readLayerFiles } from "./flows/layers.js"; +export { + applyRepository, + checkRepository, + type RepoRunReport, + renderMergedYaml, + validateSettings, +} from "./flows/library.js"; +export { type MergeConfig, runMerge } from "./flows/merge.js"; +export { DEFAULT_SETTINGS_FILE, type MultiConfig, quoteList, runMulti } from "./flows/multi.js"; +export { + capturingIo, + PRIVATE_REPOS_POLICIES, + type PrivateReposPolicy, + type PublicTargetView, + planRedaction, + publicDetail, + type TargetOutcome, + toPublicView, +} from "./flows/redact.js"; +export { parseSettingsDoc, readSettingsFile } from "./flows/settings-read.js"; +export { runSingle, type SingleConfig } from "./flows/single.js"; +// The client +export { + type ApiError, + DEFAULT_API_VERSION, + GithubApi, + type GithubClient, + type GraphqlOp, + isPermissionError, + isRateLimitError, + RERUN_ADVICE, + type TraceIo, +} from "./github/api.js"; +export { getRepoFile } from "./github/repo-file.js"; +export { createVisibilityResolver, type RepoVisibility } from "./github/repo-visibility.js"; +// The Io port +export { + type AnnotationLevel, + type CollectedLine, + collectingIo, + type Io, + type MaskPair, + maskRegistry, + type OutputName, + prefixedIo, + silentIo, +} from "./io.js"; +// The private report +export { + type ArtifactUploader, + deliverArtifactReport, + encryptReport, + parseRecipient, +} from "./report/artifact-report.js"; +export { composeReport, type ReportInput } from "./report/composer.js"; +export { + applyMarkerInjection, + openReportChannel, + PRIVATE_REPORT_CHANNELS, + type PrivateReportChannel, +} from "./report/delivery.js"; +export { ISSUE_TITLE, MARKER_LABEL, MARKER_LABEL_CONFIG } from "./report/issue-report.js"; +// The settings document +export { + DOCUMENT_DIRECTIVE_KEYS, + PROBOT_PARITY_KEYS, + SECTION_KEYS, + type SectionKey, + SettingsFile, + UNDECLARED_POLICY_SECTIONS, + type UndeclaredPolicySection, +} from "./schema.js"; + +// Section metadata +export { + type EndpointDecl, + endpointMethod, + endpointPath, + type Route, +} from "./sections/contract/endpoints.js"; +export type { GraphqlOpDecl } from "./sections/contract/graphql.js"; +export { + denialPosture, + type KeyedListLayering, + readGating, + type SectionMeta, + type SectionModule, + sectionGrant, + sectionOperations, + writeGatedReads, +} from "./sections/contract/module.js"; +export { + grantFor, + type PatResource, + type SectionPermission, +} from "./sections/contract/permissions.js"; +export { + allEndpoints, + allGraphqlOps, + SECTIONS, + sectionModule, + type TaggedEndpoint, +} from "./sections/registry.js"; +export type { MustBeNever, UndeclaredPolicy, UndeclaredPolicyList } from "./types.js"; diff --git a/src/io.ts b/src/io.ts index 7ace8125..ed0ee32f 100644 --- a/src/io.ts +++ b/src/io.ts @@ -85,3 +85,48 @@ export function prefixedIo(io: Io, prefix: string): Io { masked: io.masked, }; } + +/** One recorded Io line: annotations carry their level, log lines do not. */ +export interface CollectedLine { + level?: AnnotationLevel; + line: string; +} + +/** An Io that records instead of printing. The debug trace is dropped, as a runner without step debugging drops it. */ +export function collectingIo(): { + io: Io; + lines: CollectedLine[]; + outputs: Partial>; + summary: string[]; +} { + const lines: CollectedLine[] = []; + const outputs: Partial> = {}; + const summary: string[] = []; + return { + io: { + annotate: (level, message) => lines.push({ level, line: message }), + log: (line) => lines.push({ line }), + debug: () => {}, + summary: (markdown) => summary.push(markdown), + output: (name, value) => { + outputs[name] = value; + }, + ...maskRegistry(() => {}), + }, + lines, + outputs, + summary, + }; +} + +/** An Io that drops everything. Fresh per call, so one caller's masks never reach another's registry. */ +export function silentIo(): Io { + return { + annotate: () => {}, + log: () => {}, + debug: () => {}, + summary: () => {}, + output: () => {}, + ...maskRegistry(() => {}), + }; +} diff --git a/src/report/artifact-report.ts b/src/report/artifact-report.ts index 13638307..3d4d8a49 100644 --- a/src/report/artifact-report.ts +++ b/src/report/artifact-report.ts @@ -4,15 +4,11 @@ * artifact on the (public) run. Access control is key possession - for * readers who hold the private key but have no GitHub access to the * targets. Crypto comes from the `age-encryption` package (typage, by - * age's author); nothing here rolls its own primitives. The upload sits - * behind a small injectable port so composition and encryption are - * unit-testable without the artifact service. + * age's author); nothing here rolls its own primitives. The upload is a + * port the caller supplies (the action's is src/action/artifact.ts), so + * composition and encryption never touch the artifact service here. */ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { DefaultArtifactClient } from "@actions/artifact"; import { Encrypter } from "age-encryption"; export const ARTIFACT_NAME = "settings-as-code-private-report"; @@ -42,37 +38,11 @@ export async function encryptReport(recipient: string, content: string): Promise return encrypter.encrypt(content); } -/** The upload port, injectable so tests never touch the artifact service. */ +/** The upload port: the action implements it over @actions/artifact, tests capture. */ export interface ArtifactUploader { upload(name: string, file: { name: string; data: Uint8Array }): Promise; } -/** - * The production port: @actions/artifact uploads files from disk, so the - * ciphertext takes a round trip through a private temp directory. - */ -const actionsUploader: ArtifactUploader = { - async upload(name, file) { - // DefaultArtifactClient emits its own core.warning before it throws on a - // missing runtime token, so invoking it without one would double-warn. - // Fail before constructing it; deliverArtifactReport turns this into the - // single warning callers see. - if (!process.env.ACTIONS_RUNTIME_TOKEN) { - throw new Error( - "the artifact service is unavailable: no ACTIONS_RUNTIME_TOKEN in the environment. Artifact upload needs a GitHub-hosted or self-hosted Actions runner (it is not available on GitHub Enterprise Server or outside Actions)", - ); - } - const dir = await mkdtemp(join(tmpdir(), "settings-as-code-report-")); - try { - const path = join(dir, file.name); - await writeFile(path, file.data); - await new DefaultArtifactClient().uploadArtifact(name, [path], dir); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }, -}; - export type ArtifactDelivery = { uploaded: true } | { warning: string }; /** @@ -84,9 +54,9 @@ export type ArtifactDelivery = { uploaded: true } | { warning: string }; * leaves this module as ciphertext. */ export async function deliverArtifactReport( + uploader: ArtifactUploader, document: string, recipient: string, - uploader: ArtifactUploader = actionsUploader, ): Promise { try { const ciphertext = await encryptReport(recipient, document); diff --git a/src/report/delivery.ts b/src/report/delivery.ts index 739265f0..6b9dba2f 100644 --- a/src/report/delivery.ts +++ b/src/report/delivery.ts @@ -4,11 +4,10 @@ * plus the marker-label injection that keeps an apply from deleting the issue's label. */ -import type { PrivateReportChannel, RedactedDetail } from "../action/redact.js"; import type { RepoRef } from "../discovery/targets.js"; -import type { RepoResult, ValidatedSettings } from "../engine/orchestrate.js"; +import type { RepoResult, RepoRunResult, ValidatedSettings } from "../engine/orchestrate.js"; import type { GithubClient } from "../github/api.js"; -import type { Io } from "../io.js"; +import type { CollectedLine, Io } from "../io.js"; import type { Private } from "../private.js"; import { revealPrivate } from "../private-open.js"; import { type ArtifactUploader, deliverArtifactReport } from "./artifact-report.js"; @@ -20,6 +19,48 @@ import { MARKER_LABEL, } from "./issue-report.js"; +/** + * The `private-report` channel values. `none` delivers nothing; `issue` posts + * the full unredacted report to the private target repo itself (the one + * GitHub-ACL-private channel a public run has); `issue-on-failure` is the + * quiet variant of `issue` - it writes the issue only when the run needs + * attention (failed, or check-mode drift) and closes it on recovery, so a + * healthy repo never sees an issue; `artifact` uploads every redacted + * target's report as one age-encrypted workflow artifact, for readers who + * hold the key but no GitHub access to the targets. + */ +export const PRIVATE_REPORT_CHANNELS = ["none", "issue", "issue-on-failure", "artifact"] as const; + +export type PrivateReportChannel = (typeof PRIVATE_REPORT_CHANNELS)[number]; + +/** The `artifact` channel cannot open without an upload port; the message names the fix. */ +export const ARTIFACT_NEEDS_UPLOADER = + "private-report: artifact needs an artifact uploader, and none was supplied: the action supplies its own; a library caller passes one as the uploader argument, or picks another private-report channel"; + +/** The channels that deliver through the target repo's report issue. */ +export type IssueChannel = Extract; + +/** Narrow a channel to the issue-delivering pair. */ +export function isIssueChannel(channel: PrivateReportChannel): channel is IssueChannel { + return channel === "issue" || channel === "issue-on-failure"; +} + +/** + * One target's rich end state: slug, section outcomes with live detail, and + * the note for a skip or failure that produced no outcomes. Open in the clear; + * sealed with the transcript when redacted. + */ +export interface TargetDetail { + slug: string; + outcomes: RepoRunResult["outcomes"]; + note?: string; +} + +/** A redacted target's detail also carries every line its run would have printed. */ +export interface RedactedDetail extends TargetDetail { + transcript: CollectedLine[]; +} + /** The run metadata a private report needs, minus the per-target fields. */ export interface ReportRunMeta { /** The admin repository the workflow ran in (GITHUB_REPOSITORY / selfSlug). */ @@ -109,7 +150,8 @@ export interface ReportChannel { /** * The run's report channel from the `private-report` input, null for `none`. * Issue channels post per target as it closes; the artifact channel uploads ONE - * encrypted document on flush. `uploader` is the test port (production: undefined). + * encrypted document on flush through `uploader`, which the flows check for + * before any API work (missingUploaderProblem); the throw here is the backstop. */ export function openReportChannel( api: GithubClient, @@ -127,6 +169,9 @@ export function openReportChannel( case "issue-on-failure": return issueChannel(api, meta, "on-failure", io); case "artifact": + if (uploader === undefined) { + throw new Error(ARTIFACT_NEEDS_UPLOADER); + } return artifactChannel(meta, reportPublicKey, io, uploader); } } @@ -196,7 +241,7 @@ function artifactChannel( meta: ReportRunMeta, reportPublicKey: string, io: Io, - uploader?: ArtifactUploader, + uploader: ArtifactUploader, ): ReportChannel { const reports: string[] = []; return { @@ -207,7 +252,7 @@ function artifactChannel( if (reports.length === 0) { return; } - const delivery = await deliverArtifactReport(reports.join("\n\n"), reportPublicKey, uploader); + const delivery = await deliverArtifactReport(uploader, reports.join("\n\n"), reportPublicKey); if ("warning" in delivery) { io.annotate("warning", delivery.warning); } diff --git a/src/sections/actions_secrets/actions_secrets.test.ts b/src/sections/actions_secrets/actions_secrets.test.ts index 67d904a5..90887b02 100644 --- a/src/sections/actions_secrets/actions_secrets.test.ts +++ b/src/sections/actions_secrets/actions_secrets.test.ts @@ -227,8 +227,8 @@ describe("actions_secrets planning", () => { expect(api.calls).toEqual([]); }); - // orchestrate.ts validates references through action/secret-refs.ts (tested in - // test/action/secret-refs.test.ts); the section only extracts and looks up values. + // orchestrate.ts validates references through engine/secret-refs.ts (tested in + // test/engine/secret-refs.test.ts); the section only extracts and looks up values. }); describe("actions_secrets execution", () => { diff --git a/src/sections/secret_scanning_custom_patterns/secret_scanning_custom_patterns.test.ts b/src/sections/secret_scanning_custom_patterns/secret_scanning_custom_patterns.test.ts index b967cba0..f9dbe350 100644 --- a/src/sections/secret_scanning_custom_patterns/secret_scanning_custom_patterns.test.ts +++ b/src/sections/secret_scanning_custom_patterns/secret_scanning_custom_patterns.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { silentIo } from "../../../test/io-fake.js"; import { MockApi } from "../../../test/mock-api.js"; import { fragmentFake } from "../../../test/sections/fragment-fake.js"; import { provePlanIdempotent } from "../../../test/sections/plan-idempotence.js"; import { REPO } from "../../../test/sections/section-run.js"; import { validateSettingsDoc } from "../../engine/orchestrate.js"; +import { silentIo } from "../../io.js"; import { type PlainData, planContext, type SectionPlan } from "../contract/plan.js"; import { secretScanningPatternsSection } from "./index.js"; import { secretScanningCustomPatternsMockHandlers } from "./mock.js"; diff --git a/test/action/action-yml.test.ts b/test/action/action-yml.test.ts index c5ed6e97..6c9b1aca 100644 --- a/test/action/action-yml.test.ts +++ b/test/action/action-yml.test.ts @@ -8,7 +8,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; -import { MERGE_RESULT } from "../../src/action/deliver.js"; import { FILTER_INPUTS, INPUT_DECLS, @@ -18,6 +17,7 @@ import { import { OUTPUT_DECLS } from "../../src/action/io.js"; import { DEFAULT_DISCOVERY_FILTERS } from "../../src/discovery/discover.js"; import { REPO_RESULTS } from "../../src/engine/orchestrate.js"; +import { MERGE_RESULT } from "../../src/flows/deliver.js"; const ROOT = join(import.meta.dir, "..", ".."); diff --git a/test/action/artifact.test.ts b/test/action/artifact.test.ts new file mode 100644 index 00000000..a9b4a0bc --- /dev/null +++ b/test/action/artifact.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { DefaultArtifactClient } from "@actions/artifact"; +import { generateX25519Identity, identityToRecipient } from "age-encryption"; +import { actionsArtifactUploader } from "../../src/action/artifact.js"; +import { deliverArtifactReport } from "../../src/report/artifact-report.js"; + +describe("the action's uploader without a runtime token", () => { + const savedToken = process.env.ACTIONS_RUNTIME_TOKEN; + + afterEach(() => { + if (savedToken === undefined) { + delete process.env.ACTIONS_RUNTIME_TOKEN; + } else { + process.env.ACTIONS_RUNTIME_TOKEN = savedToken; + } + }); + + test("missing token yields exactly ONE warning and never invokes the artifact client", async () => { + delete process.env.ACTIONS_RUNTIME_TOKEN; + // Reaching the client would double-warn (it warns on its own before it + // throws). Its @actions/core import is a named binding a namespace spy + // cannot observe, so the client's entry point is what gets watched. + const uploadSpy = spyOn(DefaultArtifactClient.prototype, "uploadArtifact"); + try { + const recipient = await identityToRecipient(await generateX25519Identity()); + const result = await deliverArtifactReport( + actionsArtifactUploader, + "secret document", + recipient, + ); + + // exactly one warning, and it is ours (the client's own text never + // appears, and neither does any report content) + expect(result).toEqual({ + warning: + "could not upload the private report artifact: the artifact service is unavailable: no " + + "ACTIONS_RUNTIME_TOKEN in the environment. Artifact upload needs a GitHub-hosted or " + + "self-hosted Actions runner (it is not available on GitHub Enterprise Server or outside " + + "Actions). Re-run the workflow, or set private-report: none if it persists", + }); + expect(uploadSpy).toHaveBeenCalledTimes(0); + } finally { + uploadSpy.mockRestore(); + } + }); +}); diff --git a/test/action/inputs.test.ts b/test/action/inputs.test.ts index 7b67ec1d..69ff5b68 100644 --- a/test/action/inputs.test.ts +++ b/test/action/inputs.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { INPUT_DECLS, type MergeConfig, parseConfig } from "../../src/action/inputs.js"; +import { INPUT_DECLS, parseConfig, type RunConfig } from "../../src/action/inputs.js"; import { SECTION_KEYS } from "../../src/schema.js"; /** @@ -176,7 +176,7 @@ describe("the mode input", () => { describe("mode: merge", () => { /** What the two-layer merge env parses to; the tests below vary one input around it. */ - const MERGE_CONFIG: MergeConfig = { + const MERGE_CONFIG: Extract = { kind: "merge", settingsFiles: ["fleet.yml", "repo.yml"], mergedFile: "out/merged.yml", @@ -209,26 +209,6 @@ describe("mode: merge", () => { ); }); - test("a merged-file that names one of the layers is rejected, naming the layer's position", () => { - setMergeEnv({ "merged-file": "repo.yml" }); - expect(rejection()).toBe( - 'the "merged-file" input "repo.yml" is layer 2 of the "settings-file" list ("repo.yml"): ' + - "the merge would overwrite that layer with the folded document, and the next run would " + - "fold the merged document as a layer. Write the merged document to a path outside the " + - "layer list", - ); - }); - - test("the collision is found on the resolved paths, so a ./ spelling of a layer still collides", () => { - setMergeEnv({ "merged-file": "./fleet.yml" }); - expect(rejection()).toBe( - 'the "merged-file" input "./fleet.yml" is layer 1 of the "settings-file" list ' + - '("fleet.yml"): the merge would overwrite that layer with the folded document, and the ' + - "next run would fold the merged document as a layer. Write the merged document to a path " + - "outside the layer list", - ); - }); - test("a merged-file beside the layers but not among them is accepted", () => { setMergeEnv({ "merged-file": "./merged.yml" }); expect(parseConfig()).toEqual({ config: { ...MERGE_CONFIG, mergedFile: "./merged.yml" } }); diff --git a/test/action/io.test.ts b/test/action/io.test.ts index 28ecfe5e..d18f05c3 100644 --- a/test/action/io.test.ts +++ b/test/action/io.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "n import { tmpdir } from "node:os"; import { join } from "node:path"; import { actionsIo } from "../../src/action/io.js"; -import { writeSummary } from "../../src/action/summary.js"; +import { writeSummary } from "../../src/flows/summary.js"; /** * Whether `source` names the runner module as a module specifier - a static diff --git a/test/docs/guides.test.ts b/test/docs/guides.test.ts index 4aea2810..f1556e00 100644 --- a/test/docs/guides.test.ts +++ b/test/docs/guides.test.ts @@ -15,14 +15,14 @@ import { join, posix } from "node:path"; import { DecodingMode, decodeHTML } from "entities"; import { parse as parseYaml } from "yaml"; import { MERGE_REJECTED_INPUTS } from "../../src/action/inputs.js"; -import { foldLayers } from "../../src/action/layers.js"; import { type Layer, mergeLayers, stripNulls } from "../../src/engine/layers.js"; import { validateSettingsDoc } from "../../src/engine/orchestrate.js"; +import { foldLayers } from "../../src/flows/layers.js"; +import { silentIo } from "../../src/io.js"; import { SECTION_KEYS } from "../../src/schema.js"; import { NESTED_KEYS } from "../../src/sections/environments/nested.js"; import { SECTIONS } from "../../src/sections/registry.js"; import { STALE_VERSION_HINT } from "../../src/sections/secret_scanning_custom_patterns/index.js"; -import { silentIo } from "../io-fake.js"; import { deleteEnumerationProblems } from "./claims.js"; import { fencedBlocks, sectionLines } from "./markdown.js"; import { assertValidSettingsExample } from "./settings-examples.js"; diff --git a/test/docs/readme.test.ts b/test/docs/readme.test.ts index 52ed4ece..5310fdac 100644 --- a/test/docs/readme.test.ts +++ b/test/docs/readme.test.ts @@ -12,13 +12,11 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import { countWord } from "../../.github/scripts/lib/count-word.js"; -import { - DEFAULT_PRIVATE_REPOS, - PRIVATE_REPORT_CHANNELS, - REDACTED_DETAIL, -} from "../../src/action/redact.js"; +import { DEFAULT_PRIVATE_REPOS } from "../../src/action/inputs.js"; import { REPO_RESULTS } from "../../src/engine/orchestrate.js"; +import { REDACTED_DETAIL } from "../../src/flows/redact.js"; import { ARTIFACT_FILE, ARTIFACT_NAME } from "../../src/report/artifact-report.js"; +import { PRIVATE_REPORT_CHANNELS } from "../../src/report/delivery.js"; import { PROBOT_PARITY_KEYS, SECTION_KEYS } from "../../src/schema.js"; import { DOCS } from "../../src/sections/docs-registry.js"; import { SECTIONS } from "../../src/sections/registry.js"; diff --git a/test/docs/settings-examples.ts b/test/docs/settings-examples.ts index f690d269..a5f1c4ba 100644 --- a/test/docs/settings-examples.ts +++ b/test/docs/settings-examples.ts @@ -6,8 +6,8 @@ import { expect } from "bun:test"; import { validateSettingsDoc } from "../../src/engine/orchestrate.js"; +import { silentIo } from "../../src/io.js"; import { SPECIAL_KEYS } from "../../src/sections/repository/index.js"; -import { silentIo } from "../io-fake.js"; /** Assert `doc` validates and its repository special-looking keys are real. */ export function assertValidSettingsExample(doc: unknown, label: string): void { diff --git a/test/e2e/apply-idempotence-proof.ts b/test/e2e/apply-idempotence-proof.ts index b65a3791..39d0eaf1 100644 --- a/test/e2e/apply-idempotence-proof.ts +++ b/test/e2e/apply-idempotence-proof.ts @@ -4,7 +4,7 @@ * runScenario invokes for expect.fixpoint: "apply_idempotent" through the runner's ChildInvoker. */ -import { isIssueChannel } from "../../src/action/redact.js"; +import { isIssueChannel } from "../../src/report/delivery.js"; import { ALWAYS_REWRITE_STATE_FAMILIES, recurrence, diff --git a/test/e2e/generators.test.ts b/test/e2e/generators.test.ts index 65ed44d5..39f4ede9 100644 --- a/test/e2e/generators.test.ts +++ b/test/e2e/generators.test.ts @@ -4,9 +4,9 @@ import { basename, join } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { describeOptOut } from "../../src/engine/layers.js"; import { validateSettingsDoc } from "../../src/engine/orchestrate.js"; +import { silentIo } from "../../src/io.js"; import { SECTION_KEYS, type SectionKey } from "../../src/schema.js"; import { allEndpoints, sectionShape } from "../../src/sections/registry.js"; -import { silentIo } from "../io-fake.js"; import { type LiveWitnessKind, UNDECLARED_KEY } from "./gen-support.js"; import { ARTIFACT_TEST_RECIPIENT, diff --git a/test/e2e/generators.ts b/test/e2e/generators.ts index 93336abd..996819c7 100644 --- a/test/e2e/generators.ts +++ b/test/e2e/generators.ts @@ -20,6 +20,7 @@ import { Ajv, type ValidateFunction } from "ajv"; import addFormats from "ajv-formats"; import settingsSchema from "../../lib/settings.schema.json" with { type: "json" }; import { validateSettingsDoc } from "../../src/engine/orchestrate.js"; +import { silentIo } from "../../src/io.js"; import { SECTION_KEYS, type SectionKey, @@ -56,7 +57,6 @@ import { genTeams } from "../../src/sections/teams/generators.js"; import { genWebhooks } from "../../src/sections/webhooks/generators.js"; import { genWorkflows } from "../../src/sections/workflows/generators.js"; import type { MustBeNever } from "../../src/types.js"; -import { silentIo } from "../io-fake.js"; import { ADMIN_SLUG } from "./constants.js"; import { E2E_SECRET_ENV, diff --git a/test/e2e/mock/core-paths.ts b/test/e2e/mock/core-paths.ts index 607a107a..ffbd5e2d 100644 --- a/test/e2e/mock/core-paths.ts +++ b/test/e2e/mock/core-paths.ts @@ -8,8 +8,8 @@ * exemption read. */ -import { isIssueChannel } from "../../../src/action/redact.js"; import { MAX_RETRIES } from "../../../src/github/api.js"; +import { isIssueChannel } from "../../../src/report/delivery.js"; import { ISSUE_REPORT_PERMISSION, MARKER_LABEL, diff --git a/test/e2e/oracle.ts b/test/e2e/oracle.ts index 6a587d05..c09befbb 100644 --- a/test/e2e/oracle.ts +++ b/test/e2e/oracle.ts @@ -9,6 +9,7 @@ import type { OptOutNotice } from "../../src/engine/layers.js"; import { validateSettingsDoc } from "../../src/engine/orchestrate.js"; +import { silentIo } from "../../src/io.js"; import { SECTION_KEYS, type SectionKey, @@ -24,7 +25,6 @@ import { } from "../../src/sections/contract/module.js"; import type { SectionPermission } from "../../src/sections/contract/permissions.js"; import { SECTIONS } from "../../src/sections/registry.js"; -import { silentIo } from "../io-fake.js"; import { type Json, LAYERING_DIRECTIVES, diff --git a/test/e2e/schema.ts b/test/e2e/schema.ts index 67fe0ce2..1f24a26c 100644 --- a/test/e2e/schema.ts +++ b/test/e2e/schema.ts @@ -12,7 +12,7 @@ import { join } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { z } from "zod"; import { FILTER_INPUTS } from "../../src/action/inputs.js"; -import { RESERVED_REF_PREFIXES } from "../../src/action/secret-refs.js"; +import { RESERVED_REF_PREFIXES } from "../../src/engine/secret-refs.js"; import { MARKER_LABEL, MARKER_LABEL_CONFIG } from "../../src/report/issue-report.js"; import { SECTION_KEYS } from "../../src/schema.js"; import type { PatResource } from "../../src/sections/contract/permissions.js"; diff --git a/test/engine/check-purity.test.ts b/test/engine/check-purity.test.ts index 21c3e4b6..60b79e2b 100644 --- a/test/engine/check-purity.test.ts +++ b/test/engine/check-purity.test.ts @@ -6,10 +6,10 @@ import { describe, expect, test } from "bun:test"; import { runForRepo, validateSettingsDoc } from "../../src/engine/orchestrate.js"; +import { silentIo } from "../../src/io.js"; import type { SectionKey } from "../../src/schema.js"; import { SECTION_KEYS } from "../../src/schema.js"; import { MOCK_SECRETS_KEY_ID, MOCK_SECRETS_PUBLIC_KEY } from "../e2e/mock/secrets.js"; -import { silentIo } from "../io-fake.js"; import { MockApi } from "../mock-api.js"; /** diff --git a/test/engine/layers.test.ts b/test/engine/layers.test.ts index 7a70dfe2..be5bef37 100644 --- a/test/engine/layers.test.ts +++ b/test/engine/layers.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { type Layer, type Layering, mergeLayers, stripNulls } from "../../src/engine/layers.js"; import { validateSettingsDoc } from "../../src/engine/orchestrate.js"; +import { silentIo } from "../../src/io.js"; import { planContext } from "../../src/sections/contract/plan.js"; import { labelsSection } from "../../src/sections/labels/index.js"; -import { silentIo } from "../io-fake.js"; import { MockApi } from "../mock-api.js"; import { REPO } from "../sections/section-run.js"; diff --git a/test/action/secret-refs.test.ts b/test/engine/secret-refs.test.ts similarity index 99% rename from test/action/secret-refs.test.ts rename to test/engine/secret-refs.test.ts index 26a87e51..196bcecb 100644 --- a/test/action/secret-refs.test.ts +++ b/test/engine/secret-refs.test.ts @@ -4,7 +4,7 @@ import { resolveSecretRefs, type SourcedSecretValue, validateSecretRef, -} from "../../src/action/secret-refs.js"; +} from "../../src/engine/secret-refs.js"; /** The entry label validateSecretRef weaves into its error prose. */ const LABEL = 'the secret entry "TEST_ENTRY"'; diff --git a/test/engine/secrets.test.ts b/test/engine/secrets.test.ts index 803b5f35..aff98cef 100644 --- a/test/engine/secrets.test.ts +++ b/test/engine/secrets.test.ts @@ -1,11 +1,10 @@ import { describe, expect, test } from "bun:test"; - -import type { SettingsSource } from "../../src/action/secret-refs.js"; import { runForRepo, type ValidatedSettings, validateSettingsDoc, } from "../../src/engine/orchestrate.js"; +import type { SettingsSource } from "../../src/engine/secret-refs.js"; import { collectSecretValues } from "../../src/engine/secrets.js"; import { type Io, maskRegistry } from "../../src/io.js"; import type { SectionKey, SettingsFile } from "../../src/schema.js"; diff --git a/test/action/deliver.test.ts b/test/flows/deliver.test.ts similarity index 94% rename from test/action/deliver.test.ts rename to test/flows/deliver.test.ts index b6d98b87..cec1bf22 100644 --- a/test/action/deliver.test.ts +++ b/test/flows/deliver.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { parseRepoSlug, type RepoRef } from "../../src/discovery/targets.js"; +import type { RepoResult, SectionOutcome } from "../../src/engine/orchestrate.js"; import { concludeRun, type Delivery, @@ -9,19 +11,17 @@ import { runOutcome, type TargetResult, withDelivery, -} from "../../src/action/deliver.js"; +} from "../../src/flows/deliver.js"; import { - type PrivateReportChannel, publicChannel, REDACTED_NOTE, redactedChannel, type TargetChannel, -} from "../../src/action/redact.js"; -import { parseRepoSlug, type RepoRef } from "../../src/discovery/targets.js"; -import type { RepoResult, SectionOutcome } from "../../src/engine/orchestrate.js"; +} from "../../src/flows/redact.js"; import { type Io, maskRegistry } from "../../src/io.js"; import { isPrivate } from "../../src/private.js"; import type { ArtifactUploader } from "../../src/report/artifact-report.js"; +import type { PrivateReportChannel } from "../../src/report/delivery.js"; import { MockApi } from "../mock-api.js"; const MARKER = "settings-as-code-report"; @@ -163,13 +163,25 @@ describe("withDelivery", () => { ["issue-on-failure", PRIVATE, true], ["artifact", PRIVATE, false], ])("under %s a %j target injects the marker: %p", async (channel, exposure, injects) => { - await delivered(cfg(channel, "apply"), {}, async (delivery, io) => { - const opened = { repo: repo("o/priv"), channel: publicChannel(io, "o/priv", true), exposure }; - await delivery.target(opened, async (injectsMarker) => { - expect(injectsMarker).toBe(injects); - return outcome("clean"); - }); - }); + // The artifact channel opens only with an upload port; nothing is delivered here. + const uploader: ArtifactUploader | undefined = + channel === "artifact" ? { upload: async () => {} } : undefined; + await delivered( + cfg(channel, "apply"), + {}, + async (delivery, io) => { + const opened = { + repo: repo("o/priv"), + channel: publicChannel(io, "o/priv", true), + exposure, + }; + await delivery.target(opened, async (injectsMarker) => { + expect(injectsMarker).toBe(injects); + return outcome("clean"); + }); + }, + uploader, + ); }); test("channel none: a redacted target closes sealed and speaks one closed-value line; a shown target closes open and stays silent", async () => { diff --git a/test/flows/library.test.ts b/test/flows/library.test.ts new file mode 100644 index 00000000..fba3eab4 --- /dev/null +++ b/test/flows/library.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { stringify as stringifyYaml } from "yaml"; +import { + applyRepository, + checkRepository, + collectingIo, + parseRepoSlug, + renderMergedYaml, + type ValidatedSettings, + validateSettings, +} from "../../src/index.js"; +import { MockApi } from "../mock-api.js"; + +const repo = parseRepoSlug("o/r") as NonNullable>; + +/** The brand is minted only by validation, so the tests' documents pass through it. */ +function branded(doc: unknown): ValidatedSettings { + const validated = validateSettings(doc); + if (!validated.ok) { + throw new Error(validated.error); + } + return validated.settings; +} +const settings = branded({ repository: { has_wiki: false } }); + +describe("validateSettings", () => { + test("a valid document comes back branded with no warnings", () => { + expect(validateSettings({ repository: { has_wiki: false } })).toEqual({ + ok: true, + settings, + warnings: [], + }); + }); + + test("an unknown key outside the sections allowlist is a returned warning, not a printed one", () => { + expect( + validateSettings( + { repository: { has_wiki: false }, typo: 1 }, + { source: "fleet.yml", sections: new Set(["repository"]) }, + ), + ).toEqual({ + ok: true, + settings, + warnings: [ + 'ignoring unknown top-level section(s) outside the "sections" allowlist: typo. Upgrade the action to a version that knows them, or remove them from fleet.yml', + ], + }); + }); + + test("an invalid document names the default source in its error", () => { + expect(validateSettings([1])).toEqual({ + ok: false, + error: + 'the settings document must be a YAML mapping of section names to settings, but its top level parsed as a list. Rewrite the top level as "section: ..." keys', + }); + }); +}); + +describe("checkRepository and applyRepository", () => { + test("check diffs without writing and returns the lines it printed", async () => { + const api = new MockApi({ "GET /repos/o/r": { data: { has_wiki: true } } }); + const result = await checkRepository(api, { + repo, + settings, + onMissingPermission: "fail", + requiredSections: new Set(), + onlySections: new Set(), + }); + expect(api.mutations()).toEqual([]); + expect(result).toEqual({ + repo: "o/r", + result: "drift", + outcomes: [ + { key: "repository", status: "drift", detail: ["repository.has_wiki: false != true"] }, + ], + preflightDenied: [], + log: [{ line: "drift: repository.has_wiki: false != true" }], + }); + }); + + test("apply writes the declared keys; a caller-supplied Io receives the lines and the log stays empty", async () => { + const api = new MockApi({ "GET /repos/o/r": { data: { has_wiki: true } } }).allowMutations( + "PATCH /repos/o/r", + ); + const collected = collectingIo(); + const result = await applyRepository( + api, + { + repo, + settings, + onMissingPermission: "fail", + requiredSections: new Set(), + onlySections: new Set(), + }, + collected.io, + ); + expect(api.mutations()).toEqual([ + { method: "PATCH", path: "/repos/o/r", payload: { has_wiki: false } }, + ]); + expect(result).toEqual({ + repo: "o/r", + result: "applied", + outcomes: [ + { key: "repository", status: "applied", detail: ["patched repository fields: has_wiki"] }, + ], + preflightDenied: [], + log: [], + }); + expect(collected.lines).toEqual([{ line: "repository: patched repository fields: has_wiki" }]); + }); +}); + +describe("renderMergedYaml", () => { + test("is the document's YAML serialization, byte for byte", () => { + expect(renderMergedYaml(settings)).toBe(stringifyYaml(settings)); + expect(renderMergedYaml(settings)).toBe("repository:\n has_wiki: false\n"); + }); +}); diff --git a/test/flows/merge.test.ts b/test/flows/merge.test.ts new file mode 100644 index 00000000..18cfc346 --- /dev/null +++ b/test/flows/merge.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { collectingIo, type MergeConfig, runMerge } from "../../src/index.js"; + +const FLEET = "repository:\n has_wiki: false\n"; +const REPO = "repository:\n has_issues: true\n"; + +describe("runMerge", () => { + let dir = ""; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "run-merge-")); + writeFileSync(join(dir, "fleet.yml"), FLEET); + writeFileSync(join(dir, "repo.yml"), REPO); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const cfg = (mergedFile: string): MergeConfig => ({ + settingsFiles: [join(dir, "fleet.yml"), join(dir, "repo.yml")], + mergedFile, + layering: "merge", + }); + + test("folds the layers into merged-file and concludes merged", () => { + const collected = collectingIo(); + expect(runMerge(cfg(join(dir, "out", "merged.yml")), collected.io)).toBe(0); + expect(readFileSync(join(dir, "out", "merged.yml"), "utf8")).toBe( + "repository:\n has_wiki: false\n has_issues: true\n", + ); + expect(collected.outputs).toEqual({ "skipped-sections": "", result: "merged" }); + }); + + test.each<[string, (dir: string) => string, number, string]>([ + ["a layer's own path", (d) => join(d, "repo.yml"), 2, "repo.yml"], + ["a ./ spelling of a layer, compared resolved", (d) => `${d}/./fleet.yml`, 1, "fleet.yml"], + ])( + "a merged-file naming %s fails before any write, naming the layer's position", + (_case, mergedFile, position, layer) => { + const collected = collectingIo(); + const target = mergedFile(dir); + expect(runMerge(cfg(target), collected.io)).toBe(1); + expect(collected.lines).toEqual([ + { + level: "error", + line: + `the "merged-file" input "${target}" is layer ${position} of the ` + + `"settings-file" list ("${join(dir, layer)}"): the merge would overwrite that ` + + `layer with the folded document, and the next run would fold the merged document as a ` + + `layer. Write the merged document to a path outside the layer list`, + }, + { line: "result: failed" }, + ]); + expect(readFileSync(join(dir, "fleet.yml"), "utf8")).toBe(FLEET); + expect(readFileSync(join(dir, "repo.yml"), "utf8")).toBe(REPO); + }, + ); +}); diff --git a/test/action/multi.test.ts b/test/flows/multi.test.ts similarity index 99% rename from test/action/multi.test.ts rename to test/flows/multi.test.ts index 62bfce85..c6e7f7ab 100644 --- a/test/action/multi.test.ts +++ b/test/flows/multi.test.ts @@ -3,9 +3,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Decrypter, generateX25519Identity, identityToRecipient } from "age-encryption"; -import { runMulti } from "../../src/action/multi.js"; -import type { TargetOutcome } from "../../src/action/redact.js"; import { DEFAULT_DISCOVERY_FILTERS } from "../../src/discovery/discover.js"; +import { runMulti } from "../../src/flows/multi.js"; +import type { TargetOutcome } from "../../src/flows/redact.js"; import { type Io, maskRegistry } from "../../src/io.js"; import { isPrivate } from "../../src/private.js"; import { diff --git a/test/action/redact.test.ts b/test/flows/redact.test.ts similarity index 99% rename from test/action/redact.test.ts rename to test/flows/redact.test.ts index dcea69bb..d38865fd 100644 --- a/test/action/redact.test.ts +++ b/test/flows/redact.test.ts @@ -10,10 +10,9 @@ import { REDACTED_NOTE, redactedChannel, toPublicView, -} from "../../src/action/redact.js"; -import { type Io, maskRegistry, prefixedIo } from "../../src/io.js"; +} from "../../src/flows/redact.js"; +import { type Io, maskRegistry, prefixedIo, silentIo } from "../../src/io.js"; import { isPrivate, markPrivate } from "../../src/private.js"; -import { silentIo } from "../io-fake.js"; /** A private-set predicate from a lowercase-keyed slug list. */ function privateSet(...slugs: string[]): (slug: string) => boolean { diff --git a/test/action/settings-read.test.ts b/test/flows/settings-read.test.ts similarity index 98% rename from test/action/settings-read.test.ts rename to test/flows/settings-read.test.ts index c4895ff3..d6491f00 100644 --- a/test/action/settings-read.test.ts +++ b/test/flows/settings-read.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseSettingsDoc } from "../../src/action/settings-read.js"; +import { parseSettingsDoc } from "../../src/flows/settings-read.js"; /** * Runs `fn` while recording every process warning and every stderr write it diff --git a/test/flows/single.test.ts b/test/flows/single.test.ts new file mode 100644 index 00000000..7b4620b9 --- /dev/null +++ b/test/flows/single.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { + collectingIo, + parseRepoSlug, + runMulti, + runSingle, + type SingleConfig, +} from "../../src/index.js"; +import { ARTIFACT_NEEDS_UPLOADER } from "../../src/report/delivery.js"; +import { MockApi } from "../mock-api.js"; + +const repo = parseRepoSlug("o/r") as NonNullable>; + +const cfg = (overrides: Partial = {}): SingleConfig => ({ + repo, + settingsFile: "test/fixtures/single.yml", + mode: "check", + onMissingPermission: "fail", + requiredSections: new Set(), + onlySections: new Set(), + privateRepos: "show", + privateReport: "none", + reportPublicKey: "", + selfSlug: "o/r", + runUrl: "", + ...overrides, +}); + +describe("runSingle", () => { + test("a clean check returns the one target's outcome and prints nothing", async () => { + const api = new MockApi({ "GET /repos/o/r": { data: { has_wiki: false } } }); + const collected = collectingIo(); + expect(await runSingle(api, cfg(), collected.io)).toEqual({ + target: { + result: "clean", + display: "o/r", + detail: { + slug: "o/r", + outcomes: [{ key: "repository", status: "clean", detail: [] }], + note: undefined, + }, + }, + }); + expect(collected.lines).toEqual([]); + }); + + test("the artifact channel without an uploader is fatal before any API call", async () => { + const api = new MockApi({}); + const fatal = { fatal: ARTIFACT_NEEDS_UPLOADER }; + expect( + await runSingle( + api, + cfg({ privateRepos: "redact", privateReport: "artifact", reportPublicKey: "age1x" }), + collectingIo().io, + ), + ).toEqual(fatal); + expect( + await runMulti( + api, + { + ...cfg({ privateRepos: "redact", privateReport: "artifact", reportPublicKey: "age1x" }), + reposDir: "", + reposInput: "o/a", + defaultsFile: "", + adminOwner: "o", + discoveryFilters: { + visibility: "all", + archived: "skip", + forks: "include", + affiliation: ["owner"], + topics: [], + exclude: [], + }, + discoveryFiltersSet: [], + }, + collectingIo().io, + ), + ).toEqual({ ...fatal, targets: [] }); + expect(api.calls).toEqual([]); + }); + + test("an unreadable settings file is fatal, naming the path and the input", async () => { + const api = new MockApi({}); + const result = await runSingle(api, cfg({ settingsFile: "missing.yml" }), collectingIo().io); + expect(result).toEqual({ + fatal: expect.stringMatching( + /^cannot read settings from missing\.yml: .*ENOENT.*\. Check that the file exists at that path \(set the "settings-file" input if it lives elsewhere\) and is valid YAML$/, + ), + }); + expect(api.calls).toEqual([]); + }); +}); diff --git a/test/io-fake.ts b/test/io-fake.ts deleted file mode 100644 index 2d117977..00000000 --- a/test/io-fake.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type Io, maskRegistry } from "../src/io.js"; - -/** - * A no-op Io for tests that only need validateSettingsDoc (or a run) to - * proceed without @actions/core. Fresh per call, so one test's masks never - * leak into another's registry. - */ -export function silentIo(): Io { - return { - annotate: () => {}, - log: () => {}, - debug: () => {}, - summary: () => {}, - output: () => {}, - ...maskRegistry(() => {}), - }; -} diff --git a/test/io/collecting.test.ts b/test/io/collecting.test.ts new file mode 100644 index 00000000..c671f26b --- /dev/null +++ b/test/io/collecting.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { collectingIo, silentIo } from "../../src/io.js"; + +describe("collectingIo", () => { + test("records annotations and log lines in order, the last value per output, and every summary block", () => { + const collected = collectingIo(); + collected.io.log("one"); + collected.io.annotate("warning", "two"); + collected.io.debug("dropped"); + collected.io.output("result", "clean"); + collected.io.output("result", "drift"); + collected.io.summary("## a"); + collected.io.summary("## b"); + collected.io.mask("o/priv"); + expect({ + lines: collected.lines, + outputs: collected.outputs, + summary: collected.summary, + masked: [...collected.io.masked()], + }).toEqual({ + lines: [{ line: "one" }, { level: "warning", line: "two" }], + outputs: { result: "drift" }, + summary: ["## a", "## b"], + masked: ["o/priv"], + }); + }); +}); + +describe("silentIo", () => { + test("drops every channel and keeps a registry of its own per call", () => { + const first = silentIo(); + const second = silentIo(); + first.log("x"); + first.annotate("error", "y"); + first.mask("o/priv"); + expect([...first.masked()]).toEqual(["o/priv"]); + expect([...second.masked()]).toEqual([]); + }); +}); diff --git a/test/library/public-surface.test.ts b/test/library/public-surface.test.ts new file mode 100644 index 00000000..cbab525d --- /dev/null +++ b/test/library/public-surface.test.ts @@ -0,0 +1,177 @@ +/** + * The public entry's contract: src/index.ts exports exactly the pinned names + * (a new or dropped export is a deliberate edit here), and the action reaches + * the rest of src/ only through it - a direct import is seen failing. + */ + +import { describe, expect, test } from "bun:test"; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseSync } from "oxc-parser"; +import { ARCHITECTURE_PATH, lintArchitecture } from "../../.github/scripts/arch-lint.js"; + +const ROOT = join(import.meta.dir, "..", ".."); +const ENTRY = "src/index.ts"; + +/** Every name src/index.ts exports, runtime and type-only alike, sorted. */ +function exportedNames(): string[] { + const path = join(ROOT, ENTRY); + const { module } = parseSync(path, readFileSync(path, "utf8")); + return module.staticExports + .flatMap((statement) => statement.entries.map((entry) => entry.exportName.name)) + .filter((name): name is string => name !== null && name !== undefined) + .sort(); +} + +describe("the public entry", () => { + test("exports exactly the pinned names", () => { + expect(exportedNames()).toEqual([ + "AFFILIATIONS", + "ARCHIVED_FILTERS", + "AnnotationLevel", + "ApiError", + "ArtifactUploader", + "CentralTarget", + "CollectedLine", + "DEFAULT_API_VERSION", + "DEFAULT_DISCOVERY_FILTERS", + "DEFAULT_SETTINGS_FILE", + "DOCUMENT_DIRECTIVE_KEYS", + "DiscoveryFilters", + "EndpointDecl", + "FORKS_FILTERS", + "GithubApi", + "GithubClient", + "GraphqlOp", + "GraphqlOpDecl", + "ISSUE_TITLE", + "Io", + "KeyedListLayering", + "Layer", + "Layering", + "MARKER_LABEL", + "MARKER_LABEL_CONFIG", + "MERGE_RESULT", + "MaskPair", + "MergeConfig", + "MultiConfig", + "MustBeNever", + "OptOutNotice", + "OutputName", + "PRIVATE_REPORT_CHANNELS", + "PRIVATE_REPOS_POLICIES", + "PROBOT_PARITY_KEYS", + "PatResource", + "PrivateReportChannel", + "PrivateReposPolicy", + "PublicTargetView", + "REPO_RESULTS", + "RERUN_ADVICE", + "RemoteTarget", + "RepoRef", + "RepoResult", + "RepoRunOptions", + "RepoRunReport", + "RepoRunResult", + "RepoVisibility", + "ReportInput", + "Route", + "RunFlowConfig", + "SECTIONS", + "SECTION_KEYS", + "SectionKey", + "SectionMeta", + "SectionModule", + "SectionOutcome", + "SectionPermission", + "SettingsFile", + "SingleConfig", + "TaggedEndpoint", + "Target", + "TargetOutcome", + "TraceIo", + "UNDECLARED_POLICY_SECTIONS", + "UndeclaredPolicy", + "UndeclaredPolicyList", + "UndeclaredPolicySection", + "VISIBILITY_FILTERS", + "ValidatedSettings", + "allEndpoints", + "allGraphqlOps", + "applyMarkerInjection", + "applyRepository", + "capturingIo", + "checkRepository", + "collectingIo", + "composeReport", + "concludeRun", + "createVisibilityResolver", + "dedupeTargets", + "deliverArtifactReport", + "denialPosture", + "describeOptOut", + "discoverRepos", + "encryptReport", + "endpointMethod", + "endpointPath", + "failRun", + "foldLayers", + "getRepoFile", + "grantFor", + "isPermissionError", + "isRateLimitError", + "maskRegistry", + "mergeLayers", + "openReportChannel", + "parseRecipient", + "parseRepoSlug", + "parseReposInput", + "parseSettingsDoc", + "planRedaction", + "prefixedIo", + "preflightProbe", + "publicDetail", + "quoteList", + "readGating", + "readLayerFiles", + "readSettingsFile", + "renderMergedYaml", + "resolveCentralTargets", + "runForRepo", + "runMerge", + "runMulti", + "runSingle", + "sectionGrant", + "sectionModule", + "sectionOperations", + "silentIo", + "skippedSectionKeys", + "stripNulls", + "toPublicView", + "validateSettings", + "validateSettingsDoc", + "worstOf", + "writeGatedReads", + ]); + }); + + test("a src/action file importing src/engine directly fails the architecture lint (negative control)", () => { + // A copy of src/ plus the offending file: the real tree draws every declared + // edge, so the forbidden import is the whole verdict. + const root = mkdtempSync(join(tmpdir(), "public-surface-")); + try { + cpSync(join(ROOT, "src"), join(root, "src"), { recursive: true }); + cpSync(join(ROOT, ARCHITECTURE_PATH), join(root, ARCHITECTURE_PATH)); + writeFileSync( + join(root, "src/action/direct.ts"), + 'import { runForRepo } from "../engine/orchestrate.js";\nexport const direct = runForRepo;\n', + ); + expect(lintArchitecture(root)).toEqual([ + "forbidden import action -> engine: src/action/direct.ts -> src/engine/orchestrate.ts; move it or declare the edge", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/private.test.ts b/test/private.test.ts index 365fbd26..04c2d167 100644 --- a/test/private.test.ts +++ b/test/private.test.ts @@ -11,10 +11,7 @@ import { revealPrivate } from "../src/private-open.js"; * an EXISTING export's value is the accepted residual of reviewed trusted code. */ const OPENER_IMPORTERS: Record = { - "src/action/redact.ts": [ - "DEFAULT_PRIVATE_REPORT", - "DEFAULT_PRIVATE_REPOS", - "PRIVATE_REPORT_CHANNELS", + "src/flows/redact.ts": [ "PRIVATE_REPOS_POLICIES", "REDACTED_DETAIL", "REDACTED_NOTE", @@ -22,7 +19,6 @@ const OPENER_IMPORTERS: Record = { "attempt", "capturingIo", "emitRedactedResult", - "isIssueChannel", "isPrivateVisibility", "openTargetChannel", "planRedaction", @@ -42,7 +38,13 @@ const OPENER_IMPORTERS: Record = { "formatSkipNotice", ], "src/private.ts": ["isPrivate", "markPrivate"], - "src/report/delivery.ts": ["applyMarkerInjection", "openReportChannel"], + "src/report/delivery.ts": [ + "ARTIFACT_NEEDS_UPLOADER", + "PRIVATE_REPORT_CHANNELS", + "applyMarkerInjection", + "isIssueChannel", + "openReportChannel", + ], "test/private.test.ts": [], }; const OPENER_MODULE = resolve("src/private-open"); diff --git a/test/report/artifact-report.test.ts b/test/report/artifact-report.test.ts index a7b63725..6bf9baa5 100644 --- a/test/report/artifact-report.test.ts +++ b/test/report/artifact-report.test.ts @@ -1,5 +1,4 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { DefaultArtifactClient } from "@actions/artifact"; +import { describe, expect, test } from "bun:test"; import { Decrypter, generateX25519Identity, identityToRecipient } from "age-encryption"; import { ARTIFACT_FILE, @@ -64,7 +63,7 @@ describe("deliverArtifactReport", () => { test("hands the uploader port ciphertext under the fixed artifact names", async () => { const { identity, recipient } = await testKeypair(); const { uploader, uploads } = captureUploader(); - const result = await deliverArtifactReport("secret document", recipient, uploader); + const result = await deliverArtifactReport(uploader, "secret document", recipient); expect(result).toEqual({ uploaded: true }); expect(uploads).toHaveLength(1); expect(uploads[0]?.name).toBe(ARTIFACT_NAME); @@ -84,7 +83,7 @@ describe("deliverArtifactReport", () => { throw new Error("Unable to get the ACTIONS_RUNTIME_TOKEN env variable"); }, }; - expect(await deliverArtifactReport("doc", recipient, uploader)).toEqual({ + expect(await deliverArtifactReport(uploader, "doc", recipient)).toEqual({ warning: "could not upload the private report artifact: Unable to get the ACTIONS_RUNTIME_TOKEN " + "env variable. Re-run the workflow, or set private-report: none if it persists", @@ -93,7 +92,7 @@ describe("deliverArtifactReport", () => { test("a malformed recipient is a warning and the uploader is never called", async () => { const { uploader, uploads } = captureUploader(); - const result = await deliverArtifactReport("doc", "not-a-key", uploader); + const result = await deliverArtifactReport(uploader, "doc", "not-a-key"); // The middle of the warning is the age library's own wording for a bad recipient, so only // our prefix and advice are pinned around it. expect(result).toEqual({ @@ -104,40 +103,3 @@ describe("deliverArtifactReport", () => { expect(uploads).toEqual([]); }); }); - -describe("default uploader without a runtime token", () => { - const savedToken = process.env.ACTIONS_RUNTIME_TOKEN; - - afterEach(() => { - if (savedToken === undefined) { - delete process.env.ACTIONS_RUNTIME_TOKEN; - } else { - process.env.ACTIONS_RUNTIME_TOKEN = savedToken; - } - }); - - test("missing token yields exactly ONE warning and never invokes the artifact client", async () => { - delete process.env.ACTIONS_RUNTIME_TOKEN; - // Reaching the client would double-warn (it warns on its own before it - // throws). Its @actions/core import is a named binding a namespace spy - // cannot observe, so the client's entry point is what gets watched. - const uploadSpy = spyOn(DefaultArtifactClient.prototype, "uploadArtifact"); - try { - const { recipient } = await testKeypair(); - const result = await deliverArtifactReport("secret document", recipient); - - // exactly one warning, and it is ours (the client's own text never - // appears, and neither does any report content) - expect(result).toEqual({ - warning: - "could not upload the private report artifact: the artifact service is unavailable: no " + - "ACTIONS_RUNTIME_TOKEN in the environment. Artifact upload needs a GitHub-hosted or " + - "self-hosted Actions runner (it is not available on GitHub Enterprise Server or outside " + - "Actions). Re-run the workflow, or set private-report: none if it persists", - }); - expect(uploadSpy).toHaveBeenCalledTimes(0); - } finally { - uploadSpy.mockRestore(); - } - }); -}); diff --git a/test/report/delivery.test.ts b/test/report/delivery.test.ts index 27bd46f3..cc6dc820 100644 --- a/test/report/delivery.test.ts +++ b/test/report/delivery.test.ts @@ -1,28 +1,25 @@ import { describe, expect, test } from "bun:test"; import { Decrypter, generateX25519Identity, identityToRecipient } from "age-encryption"; -import { runOutcome } from "../../src/action/deliver.js"; -import { - type PrivateReportChannel, - type RedactedDetail, - redactedChannel, -} from "../../src/action/redact.js"; import { parseRepoSlug, type RepoRef } from "../../src/discovery/targets.js"; import { type SectionOutcome, type ValidatedSettings, validateSettingsDoc, } from "../../src/engine/orchestrate.js"; -import { type Io, maskRegistry } from "../../src/io.js"; +import { runOutcome } from "../../src/flows/deliver.js"; +import { redactedChannel } from "../../src/flows/redact.js"; +import { type Io, maskRegistry, silentIo } from "../../src/io.js"; import { isPrivate, type Private } from "../../src/private.js"; import type { ArtifactUploader } from "../../src/report/artifact-report.js"; import { applyMarkerInjection, openReportChannel, + type PrivateReportChannel, + type RedactedDetail, type ReportRunMeta, } from "../../src/report/delivery.js"; import { MARKER_LABEL_CONFIG } from "../../src/report/issue-report.js"; import type { SettingsFile } from "../../src/schema.js"; -import { silentIo } from "../io-fake.js"; import { MockApi } from "../mock-api.js"; const MARKER = "settings-as-code-report"; diff --git a/test/report/issue-report.test.ts b/test/report/issue-report.test.ts index 0d36043a..2d7a1a60 100644 --- a/test/report/issue-report.test.ts +++ b/test/report/issue-report.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { validateSettingsDoc } from "../../src/engine/orchestrate.js"; +import { silentIo } from "../../src/io.js"; import { deliverIssueReport, ISSUE_TITLE, @@ -9,7 +10,6 @@ import { MARKER_LABEL_CONFIG, } from "../../src/report/issue-report.js"; import type { SettingsFile } from "../../src/schema.js"; -import { silentIo } from "../io-fake.js"; import { MockApi, type Route } from "../mock-api.js"; const SLUG = { owner: "o", name: "private-repo", slug: "o/private-repo" };