diff --git a/docs/reference/coverage.md b/docs/reference/coverage.md index b9148f1e1..79e3d1823 100644 --- a/docs/reference/coverage.md +++ b/docs/reference/coverage.md @@ -371,6 +371,7 @@ The side-by-side comparison with the Probot Settings app lives in the migration - Covers build_type, source, cname, https_enforced, and any PUT field GitHub adds; pages: null disables the site. - POST /repos/{owner}/{repo}/pages (create accepts only build_type/source) then PUT for the rest; existing sites get straight PUT passthrough; pages: null issues DELETE, mirroring branches' protection: null. +- The create requires build_type or source, so a declaration with neither, against a repository without a site, sends no write: check reports drift naming the fix (declare build_type or source), and apply reports the same line as a notice and changes nothing. - In a mode: render fold, pages: null is written as the section's value even over a lower layer's pages declaration, so the rendered file turns the site off (the layering guide owns that rule); a multi-repo defaults-file never merges into a target's file. - Snapshot reads the live site back on the declared keys; a repository without Pages is omitted rather than declared null. @@ -446,10 +447,11 @@ The side-by-side comparison with the Probot Settings app lives in the migration - In a mode: render fold, interaction_limits: null is written as the section's value even over a lower layer's declaration, mirroring pages: null, so the rendered file clears the limit. - A 409 means an organization- or user-level limit overrides the repository's; writes surface that as a note, not a failure, while check mode still reports a mismatched declaration as drift - the org is the place to change it. - Snapshot reads back the repository-level limit (an inherited one is left out with a note, and the expiry duration is not readable), the cap when the feature exists and is enabled, and the bypass list when it has members. +- A read-only Administration grant reads the limit and is denied the cap and the bypass list, which GitHub gates at write: under on-missing-permission: warn the snapshot leaves each of the two keys out with a note naming the grant and still writes the limit, and under fail the section fails. **Pull request creation cap** (`interaction_limits`) -- `pull_request_creation_cap` routes to GET/PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap: persistent desired state with no self-expiry (enabled plus max_open_pull_requests 1-1000, read back verbatim), so check diffs it exactly and apply PATCHes only on divergence - no re-arm. +- `pull_request_creation_cap` routes to GET/PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap: persistent desired state with no self-expiry (enabled, max_open_pull_requests 1-1000, and include_drafts, read back verbatim), so check diffs it exactly and apply PATCHes only on divergence - no re-arm. - GitHub gates the cap GET at write, so a read-only Administration grant cannot check this key. - Where the cap is unavailable the endpoints answer 405, which apply surfaces as a note (like the 409) and check as honest drift. diff --git a/docs/sections/interaction_limits.docs.yml b/docs/sections/interaction_limits.docs.yml index 6d9daa67c..493c7d55b 100644 --- a/docs/sections/interaction_limits.docs.yml +++ b/docs/sections/interaction_limits.docs.yml @@ -36,6 +36,11 @@ coverage: Snapshot reads back the repository-level limit (an inherited one is left out with a note, and the expiry duration is not readable), the cap when the feature exists and is enabled, and the bypass list when it has members. + - >- + A read-only Administration grant reads the limit and is denied the cap and the bypass list, + which GitHub gates at write: under on-missing-permission: warn the snapshot leaves each of + the two keys out with a note naming the grant and still writes the limit, and under fail the + section fails. - area: >- [Pull request creation cap](https://docs.github.com/en/rest/interactions/repos#update-pull-request-creation-cap-for-a-repository) @@ -45,8 +50,8 @@ coverage: - >- `pull_request_creation_cap` routes to GET/PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap: persistent desired state with no - self-expiry (enabled plus max_open_pull_requests 1-1000, read back verbatim), so check diffs it - exactly and apply PATCHes only on divergence - no re-arm. + self-expiry (enabled, max_open_pull_requests 1-1000, and include_drafts, read back verbatim), + so check diffs it exactly and apply PATCHes only on divergence - no re-arm. - >- GitHub gates the cap GET at write, so a read-only Administration grant cannot check this key. - >- @@ -101,6 +106,9 @@ schema: InteractionLimitsConfig.pull_request_creation_cap.max_open_pull_requests: >- The maximum number of open pull requests one user may have, a whole number from 1 to 1000 (GitHub's range); anything else fails at parse time. + InteractionLimitsConfig.pull_request_creation_cap.include_drafts: >- + Whether draft pull requests count toward the cap. GitHub documents a boolean; the value is sent + as written, its type unchecked when the file is parsed. InteractionLimitsConfig.pull_request_creation_bypass: >- User logins exempt from the pull request creation cap, routed to GET/PUT/DELETE /repos/{r}/interaction-limits/pulls/bypass-list and reconciled: apply removes the undeclared diff --git a/docs/sections/pages.docs.yml b/docs/sections/pages.docs.yml index 83ec474ae..b5f95b02c 100644 --- a/docs/sections/pages.docs.yml +++ b/docs/sections/pages.docs.yml @@ -14,6 +14,10 @@ coverage: POST /repos/{owner}/{repo}/pages (create accepts only build_type/source) then PUT for the rest; existing sites get straight PUT passthrough; pages: null issues DELETE, mirroring branches' protection: null. + - >- + The create requires build_type or source, so a declaration with neither, against a repository + without a site, sends no write: check reports drift naming the fix (declare build_type or + source), and apply reports the same line as a notice and changes nothing. - >- In a mode: render fold, pages: null is written as the section's value even over a lower layer's pages declaration, so the rendered file turns the site off (the layering guide owns @@ -53,7 +57,9 @@ coverage: drift with no note. schema: SettingsFile.pages: GitHub Pages configuration; null disables Pages on the repository. - PagesConfig: "GitHub Pages site configuration; use `pages: null` to disable the site." + PagesConfig: >- + GitHub Pages site configuration; use `pages: null` to disable the site. A repository without a + site gets one only from a declared build_type or source. PagesConfig.build_type: '"legacy" (branch) or "workflow" (GitHub Actions).' PagesConfig.source: The update PUT requires both branch and path when source is sent. PagesConfig.source.branch: The branch Pages publishes from. diff --git a/src/sections/interaction_limits/index.ts b/src/sections/interaction_limits/index.ts index 989db3c57..19c4d77e0 100644 --- a/src/sections/interaction_limits/index.ts +++ b/src/sections/interaction_limits/index.ts @@ -12,7 +12,7 @@ import { ok, safeTry } from "neverthrow"; import { z } from "zod"; import { phantomKeys, phantomNote, subsetDiff } from "../../engine/diff.js"; import { agree } from "../../text.js"; -import { requirePlainMapping } from "../contract/declared.js"; +import { type PlainTyped, requirePlainMapping } from "../contract/declared.js"; import { cannotVerifyNote } from "../contract/drift.js"; import type { EndpointDecl } from "../contract/endpoints.js"; import type { SectionModule } from "../contract/module.js"; @@ -23,13 +23,15 @@ import { type PlannedOp, type Read, type SectionPlan, + type SnapshotContext, } from "../contract/plan.js"; -import { leftOutOfSnapshot, projectOntoSchema } from "../shared/snapshot-helpers.js"; +import { leftOutOfSnapshot, projectOntoSchema, readOrNote } from "../shared/snapshot-helpers.js"; import { BYPASS_MAX, InteractionLimitsConfig } from "./schema.js"; const permission: SectionPermission = { repo: ["administration"] }; -type DeclaredInteractionLimits = NonNullable; +/** The validated declaration: the cap's unchecked include_drafts reads as the plain data the engine's walk proved it. */ +type DeclaredInteractionLimits = PlainTyped>; const ORG_OVERRIDE = "an organization- or user-level interaction limit overrides this repository's"; @@ -68,7 +70,7 @@ const ENDPOINTS = { route: "PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap", statuses: { 200: "pull request creation cap updated", 405: CAP_UNAVAILABLE }, hints: { - 422: "enabled must be a boolean and max_open_pull_requests a whole number from 1 to 1000; see the pull request creation cap endpoint documentation", + 422: "enabled and include_drafts must be booleans and max_open_pull_requests a whole number from 1 to 1000; see the pull request creation cap endpoint documentation", }, }, // The bypass routes document no 405 (only the cap pair does), so on a repository without the cap @@ -357,6 +359,58 @@ function planBypassList( }); } +/** + * The cap and its bypass list as a snapshot declares them; a disabled cap is GitHub's default. Each is its + * own write-gated read, so a token that reads the limit can be denied either. + * + * cap answers 405 -> cap and bypass both omitted, noted + * cap or bypass read denied -> that key left out and noted under warn; the section fails under fail + * cap disabled, or nobody on the bypass -> omitted + */ +function snapshotPullRequestKeys( + ctx: SnapshotContext, + notes: string[], +): Read> { + return safeTry(async function* () { + const keys: Record = {}; + const cap = yield* await readOrNote( + ctx, + notes, + "interaction_limits.pull_request_creation_cap", + () => + ctx.read.capGet.tryCall(LiveCreationCap, { + describe: "reading the pull request creation cap", + }), + ); + if ("value" in cap) { + if ("error" in cap.value) { + notes.push( + `interaction_limits: ${CAP_UNAVAILABLE} (405), so pull_request_creation_cap and pull_request_creation_bypass are omitted`, + ); + return ok(keys); + } + // Parsed at the port: a body off the shape (a null, a quoted flag) fails the section instead of reading as "no cap". + if (cap.value.data.enabled) { + keys.pull_request_creation_cap = projectOntoSchema( + InteractionLimitsConfig.unwrap().shape.pull_request_creation_cap, + cap.value.data, + ); + } + } + + const bypass = yield* await readOrNote( + ctx, + notes, + "interaction_limits.pull_request_creation_bypass", + () => liveBypassLogins(ctx), + ); + if ("value" in bypass && bypass.value.length > 0) { + keys.pull_request_creation_bypass = bypass.value; + } + return ok(keys); + }); +} + export const interactionLimitsSection = { key: "interaction_limits", undeclaredDefault: "untouched", @@ -388,11 +442,7 @@ export const interactionLimitsSection = { }, /** * Only what the repository itself owns reads back; an inherited limit is the org's or user's - * setting, and a disabled cap is GitHub's default. - * - * limit inherited (org or user origin) -> noted, not declared - * cap answers 405 -> cap and bypass both omitted, noted - * cap disabled, or nobody on the bypass -> omitted + * setting, noted and not declared. */ async snapshot(ctx) { return safeTry(async function* () { @@ -412,26 +462,7 @@ export const interactionLimitsSection = { ), ); } - const cap = yield* ctx.read.capGet.tryCall(LiveCreationCap, { - describe: "reading the pull request creation cap", - }); - if ("error" in cap) { - notes.push( - `interaction_limits: ${CAP_UNAVAILABLE} (405), so pull_request_creation_cap and pull_request_creation_bypass are omitted`, - ); - } else { - // Parsed at the port: a body off the shape (a null, a quoted flag) fails the section instead of reading as "no cap". - if (cap.data.enabled) { - value.pull_request_creation_cap = projectOntoSchema( - InteractionLimitsConfig.unwrap().shape.pull_request_creation_cap, - cap.data, - ); - } - const bypass = yield* liveBypassLogins(ctx); - if (bypass.length > 0) { - value.pull_request_creation_bypass = bypass; - } - } + Object.assign(value, yield* snapshotPullRequestKeys(ctx, notes)); if (Object.keys(value).length === 0) { return ok({ value: undefined, notes }); } diff --git a/src/sections/interaction_limits/schema.ts b/src/sections/interaction_limits/schema.ts index 227f3ccc8..cf39f4a93 100644 --- a/src/sections/interaction_limits/schema.ts +++ b/src/sections/interaction_limits/schema.ts @@ -50,6 +50,9 @@ const InteractionLimits = z .min(CAP_MIN, CAP_RULE) .max(CAP_MAX, CAP_RULE) .optional(), + // Named so a snapshot keeps it (the projection reads back the named keys alone); its value is as + // unchecked as any key the open mapping passes through, which is how it has always parsed. + include_drafts: z.unknown().optional(), }).optional(), pull_request_creation_bypass: z.array(z.string()).optional(), }, diff --git a/src/sections/pages/index.ts b/src/sections/pages/index.ts index 13d7a152b..94618fab2 100644 --- a/src/sections/pages/index.ts +++ b/src/sections/pages/index.ts @@ -40,10 +40,22 @@ type PagesWirePayload = Omit & { source?: PagesSourceWire } /** * GitHub documents every field but build_type and source as update-only, so enabling a site is - * create-then-update. A Pick over the wire payload, so a renamed config field breaks this split at - * compile time instead of silently rerouting through the wrong endpoint. + * create-then-update, and its create requires one of the two, so the type holds no empty body and + * createBody() is the only mint. Picks over the wire payload, so a renamed config field breaks this + * split at compile time instead of silently rerouting through the wrong endpoint. */ -type PagesCreateBody = Pick; +type PagesCreateBody = + | (Required> & Pick) + | (Required> & Pick); + +/** The create body a declaration fills; undefined when it declares neither field GitHub enables a site from. */ +function createBody(payload: PagesWirePayload): PagesCreateBody | undefined { + const { build_type: buildType, source } = payload; + if (buildType !== undefined) { + return source === undefined ? { build_type: buildType } : { build_type: buildType, source }; + } + return source === undefined ? undefined : { source }; +} // A mapping only: PagesConfig accepts null as the declared "Pages off", so a null 200 would otherwise read back as a declaration that DISABLES the site. const LiveSite = z.looseObject({}); @@ -56,15 +68,18 @@ const PUBLIC_VISIBILITY_NOTE = type PagesPlan = SectionPlan>; +// A 404 is ambiguous: no Pages site, or a fine-grained token without the Pages permission. A plan that +// creates stays loud either way (the POST would fail); a plan with no write must say so itself. +const CONCEALED_SITE = + "A fine-grained token missing the Pages permission gets the same answer; if this repo does have a Pages site, grant the token Pages read and write"; + /** `pages: null` disables a live site; with no site the note says what else the 404 may mean. */ function disablePlan(missing: boolean): PagesPlan { if (missing) { - // A 404 is ambiguous: no Pages site, or a fine-grained token without the Pages permission. - // The non-null path stays loud either way (the POST would fail); this no-op path must say so. return { ops: [], notes: [ - "pages: declared null and GitHub reports no Pages site, so there is nothing to disable. A fine-grained token missing the Pages permission gets the same answer; if this repo does have a Pages site, grant the token Pages read and write", + `pages: declared null and GitHub reports no Pages site, so there is nothing to disable. ${CONCEALED_SITE}`, ], drift: [], }; @@ -116,16 +131,25 @@ function updatePlan(payload: PagesWirePayload, live: Record): P return plan; } -/** Enabling a site is the create POST, which takes build_type and source alone, then the update PUT for whatever else is declared. */ +/** + * Enabling a site is the create POST, which takes build_type and source alone, then the update PUT for + * whatever else is declared. With neither declared there is no create GitHub accepts, and the update + * answers 404 without a site, so the plan is drift no operation fixes. + */ function enablePlan(payload: PagesWirePayload): PagesPlan { - const plan: PagesPlan = { ops: [], notes: [], drift: [] }; - const create: PagesCreateBody = {}; - if (payload.build_type !== undefined) { - create.build_type = payload.build_type; - } - if (payload.source !== undefined) { - create.source = payload.source; + const create = createBody(payload); + if (create === undefined) { + return { + ops: [], + notes: [`pages: GitHub reports no Pages site. ${CONCEALED_SITE}`], + drift: [ + "pages: declared in the settings file but GitHub Pages is not enabled on the repo, and GitHub enables a site only from build_type or source, " + + "so apply changes nothing - declare build_type or source to enable the site, or remove the pages section", + ], + }; } + + const plan: PagesPlan = { ops: [], notes: [], drift: [] }; plan.ops.push({ role: "create", payload: create, diff --git a/test/sections/interaction_limits/interaction_limits.test.ts b/test/sections/interaction_limits/interaction_limits.test.ts index e983271d8..92001b602 100644 --- a/test/sections/interaction_limits/interaction_limits.test.ts +++ b/test/sections/interaction_limits/interaction_limits.test.ts @@ -2,12 +2,18 @@ import { describe, expect, test } from "bun:test"; import { executePlan } from "../../../src/engine/execute.js"; import type { GitHubClient } from "../../../src/github/api.js"; import type { SectionInput } from "../../../src/sections/contract/declared.js"; -import { type PlannedOp, planContext } from "../../../src/sections/contract/plan.js"; +import { sectionGrant } from "../../../src/sections/contract/operations.js"; +import { + type OnMissingPermission, + type PlannedOp, + planContext, + snapshotContext, +} from "../../../src/sections/contract/plan.js"; import { interactionLimitsSection } from "../../../src/sections/interaction_limits/index.js"; import type { InteractionLimitsConfig } from "../../../src/sections/interaction_limits/schema.js"; import { MockApi, withListing } from "../../mock-api.js"; import { provePlanIdempotent } from "../plan-idempotence.js"; -import { deniedDetail, REPO, SectionFailed, unwrap } from "../section-run.js"; +import { deniedDetail, REPO, rejectsDenied, SectionFailed, unwrap } from "../section-run.js"; import { validatedInput } from "../validated-input.js"; const BASE = "/repos/o/r/interaction-limits"; @@ -17,6 +23,9 @@ const BYPASS_GET = `GET ${BASE}/pulls/bypass-list`; const LIVE = { limit: "existing_users", origin: "repository", expires_at: "2026-01-02T00:00:00Z" }; const CAP_LIVE = { enabled: false, max_open_pull_requests: 1 }; const CAP_405 = { error: { status: 405, message: "Method Not Allowed", body: "" } } as const; +const DENIED_403 = { + error: { status: 403, message: "Resource not accessible by personal access token", body: "" }, +} as const; const CONFLICT = { error: { status: 409, message: "Conflict", body: "" } } as const; const TOOLS = { resolveSecret: () => "" }; @@ -408,3 +417,43 @@ describe("interaction_limits pull request creation bypass list", () => { expect(api.calls.map((c) => `${c.method} ${c.path}`)).toEqual([GET]); }); }); + +describe("interaction_limits snapshot", () => { + const snapshot = async (api: GitHubClient, policy: OnMissingPermission) => + unwrap( + await interactionLimitsSection.snapshot( + snapshotContext(interactionLimitsSection, api, REPO, policy), + ), + ); + const EXPIRY_NOTE = + "interaction_limits.expiry: GitHub reports only the computed expires_at, so the declared duration cannot be read back; " + + "apply re-arms the limit with GitHub's default (one_day) unless you declare expiry"; + const leftOut = (key: string, request: string, hint = "") => + `interaction_limits.${key}: left out of the snapshot - the token was denied ${request}: 403 Resource not accessible by personal access token. ` + + `To fix, ${sectionGrant(interactionLimitsSection)}${hint}`; + + test("a read-only grant is denied the write-gated cap and bypass reads: under warn each key is left out by name and the readable limit still reads back", async () => { + // Without this, one denied sub-read drops the whole section, the limit the token could read included. + const api = new MockApi({ + [GET]: { data: { limit: "contributors_only", origin: "repository" } }, + [CAP_GET]: DENIED_403, + [BYPASS_GET]: DENIED_403, + }); + expect(await snapshot(api, "warn")).toEqual({ + value: { limit: "contributors_only" }, + notes: [ + EXPIRY_NOTE, + leftOut("pull_request_creation_cap", `${CAP_GET} (reading the pull request creation cap)`), + leftOut( + "pull_request_creation_bypass", + BYPASS_GET, + ". Note: a 403 or 404 here can also mean the pull request creation cap is not available on this repository", + ), + ], + }); + // The policy is the run's: under fail the same denial still fails the section, with the grant to add. + expect(await rejectsDenied(snapshot(api, "fail"))).toContain( + `the token was denied ${CAP_GET} (reading the pull request creation cap): 403`, + ); + }); +}); diff --git a/test/sections/interaction_limits/scenarios/interaction-limits-snapshot-cap-denied-warn.yml b/test/sections/interaction_limits/scenarios/interaction-limits-snapshot-cap-denied-warn.yml new file mode 100644 index 000000000..86a753692 --- /dev/null +++ b/test/sections/interaction_limits/scenarios/interaction-limits-snapshot-cap-denied-warn.yml @@ -0,0 +1,47 @@ +# GitHub gates the creation-cap and bypass-list GETs at write, so a read-only +# Administration grant reads the base limit and is denied those two. Under +# on-missing-permission: warn each denied key is a notice naming it and the +# grant, the limit still reads back, and the file is written with result +# snapshot (a noted key is not a skipped section, so the run is not partial). +name: interaction-limits-snapshot-cap-denied-warn +settings: {} +live_state: + interaction_limits: + limit: contributors_only + origin: repository + expires_at: 2027-01-02T00:00:00Z + pull_creation_cap: + enabled: true + max_open_pull_requests: 5 + pull_bypass_list: + - login: octocat +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: interaction_limits + on_missing_permission: warn +denial_style: 403 +token_permissions: + administration: read +expect: + exit_code: 0 + result: snapshot + skipped_sections: [] + outcomes: + interaction_limits: snapshot + snapshot: + interaction_limits: + limit: contributors_only + stdout_contains: + - "::notice::interaction_limits.pull_request_creation_cap: left out of the snapshot - the token was denied GET /repos/e2e-owner/e2e-repo/interaction-limits/pulls/creation-cap" + - "::notice::interaction_limits.pull_request_creation_bypass: left out of the snapshot - the token was denied GET /repos/e2e-owner/e2e-repo/interaction-limits/pulls/bypass-list" + - 'grant "Administration" (read and write)' + - snapshot written to snapshot.yml + stdout_lacks: + - "::error::" + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/test/sections/interaction_limits/scenarios/interaction-limits-snapshot-roundtrip.yml b/test/sections/interaction_limits/scenarios/interaction-limits-snapshot-roundtrip.yml index 32f1316d3..95abe71fa 100644 --- a/test/sections/interaction_limits/scenarios/interaction-limits-snapshot-roundtrip.yml +++ b/test/sections/interaction_limits/scenarios/interaction-limits-snapshot-roundtrip.yml @@ -13,6 +13,7 @@ live_state: pull_creation_cap: enabled: true max_open_pull_requests: 5 + include_drafts: true pull_bypass_list: - login: octocat inputs: @@ -30,6 +31,7 @@ expect: pull_request_creation_cap: enabled: true max_open_pull_requests: 5 + include_drafts: true pull_request_creation_bypass: - octocat never: diff --git a/test/sections/interaction_limits/snapshot-row.ts b/test/sections/interaction_limits/snapshot-row.ts index dfb09176e..d8b7afa77 100644 --- a/test/sections/interaction_limits/snapshot-row.ts +++ b/test/sections/interaction_limits/snapshot-row.ts @@ -9,13 +9,14 @@ export const row: Row = { origin: "repository", expires_at: "2027-01-02T00:00:00Z", }, - pull_creation_cap: { enabled: true, max_open_pull_requests: 5 }, + pull_creation_cap: { enabled: true, max_open_pull_requests: 5, include_drafts: true }, pull_bypass_list: [{ login: "octocat" }], }, expected: { value: { limit: "collaborators_only", - pull_request_creation_cap: { enabled: true, max_open_pull_requests: 5 }, + // Every field GitHub documents on the cap reads back, include_drafts beside the two the PATCH has always carried. + pull_request_creation_cap: { enabled: true, max_open_pull_requests: 5, include_drafts: true }, pull_request_creation_bypass: ["octocat"], }, notes: [ diff --git a/test/sections/pages/pages.test.ts b/test/sections/pages/pages.test.ts index ee816f98b..8092a97c7 100644 --- a/test/sections/pages/pages.test.ts +++ b/test/sections/pages/pages.test.ts @@ -156,6 +156,29 @@ describe("pages", () => { expect(api.calls.map((c) => `${c.method} ${c.path}`)).toEqual([GET]); }); + test.each([ + ["https_enforced", { https_enforced: true }], + ["cname", { cname: "docs.example.com" }], + ["public", { public: false }], + ] as const)( + "no live site and only %s declared: the create requires build_type or source, so the plan is drift naming the fix and no operation", + async (_key, declared) => { + // Without this, the plan carries a create whose body is {}, which GitHub rejects, and then an update of a site that does not exist. + const api = new MockApi({}); // GET /pages 404s + expect(await plan(api, declared)).toEqual({ + ops: [], + notes: [ + "pages: GitHub reports no Pages site. A fine-grained token missing the Pages permission gets the same answer; if this repo does have a Pages site, grant the token Pages read and write", + ], + drift: [ + "pages: declared in the settings file but GitHub Pages is not enabled on the repo, and GitHub enables a site only from build_type or source, " + + "so apply changes nothing - declare build_type or source to enable the site, or remove the pages section", + ], + }); + expect(api.calls.map((c) => `${c.method} ${c.path}`)).toEqual([GET]); + }, + ); + test("a live site is updated in place only where it diverges", async () => { const api = new MockApi({ [GET]: { data: { build_type: "legacy", cname: null } } }); const result = await plan(api, { build_type: "workflow" }); diff --git a/test/sections/pages/scenarios/pages-enable-needs-build-type-or-source.yml b/test/sections/pages/scenarios/pages-enable-needs-build-type-or-source.yml new file mode 100644 index 000000000..0658e948c --- /dev/null +++ b/test/sections/pages/scenarios/pages-enable-needs-build-type-or-source.yml @@ -0,0 +1,24 @@ +# GitHub's Pages create takes build_type and source alone and requires one of +# them. A settings file declaring neither, against a repository with no Pages +# site, has no create GitHub accepts and no site to update: apply sends no +# write and names what to declare, with the 404's other reading beside it. +name: pages-enable-needs-build-type-or-source +settings: + pages: + https_enforced: true +live_state: + pages: null +expect: + exit_code: 0 + result: applied + outcomes: + pages: applied + never: + - "POST /repos/{repo}/pages" + - "PUT /repos/{repo}/pages" + stdout_contains: + - "::notice::pages: pages: declared in the settings file but GitHub Pages is not enabled on the repo, and GitHub enables a site only from build_type or source, so apply changes nothing" + - "declare build_type or source to enable the site, or remove the pages section" + - "A fine-grained token missing the Pages permission gets the same answer" + stdout_lacks: + - "enabled GitHub Pages"