diff --git a/.github/scripts/gen-webhook-events.ts b/.github/scripts/gen-webhook-events.ts deleted file mode 100644 index 84c0dbbe..00000000 --- a/.github/scripts/gen-webhook-events.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Renders src/sections/webhooks/events.ts from GitHub's webhooks OpenAPI description as @octokit/openapi-webhooks - * ships it: the events whose supported-webhook-types names "repository", spelled as the wire event name. - * bun .github/scripts/gen-webhook-events.ts -> rewrites the file - * bun run build:check -> regenerates and fails on drift, so a package bump that adds or drops - * a repository event fails until the file is regenerated and committed - * test/scripts/gen-webhook-events.test.ts -> pins the committed file to a fresh render - */ - -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; - -const ROOT = join(import.meta.dir, "..", ".."); -export const EVENTS_PATH = "src/sections/webhooks/events.ts"; -const DESCRIPTOR = "@octokit/openapi-webhooks/generated/api.github.com.json"; - -/** The slice of the descriptor the derivation reads; the rest of each webhook entry is its payload schema. */ -export interface WebhooksDescriptor { - readonly webhooks: Readonly< - Record< - string, - { - readonly post?: { - readonly externalDocs?: { readonly url?: string }; - readonly "x-github"?: { - readonly subcategory?: string; - readonly "supported-webhook-types"?: readonly string[]; - }; - }; - } - > - >; -} - -export interface WebhookVocabulary { - /** Sorted wire event names, without the "*" wildcard (the schema adds it). */ - readonly events: readonly string[]; - /** GitHub's reference page, the externalDocs url every entry points at, minus its anchor. */ - readonly reference: string; -} - -/** A descriptor entry missing a field the derivation reads is refused, not skipped: a skipped entry would drop an event - * GitHub still delivers and the parser would start refusing a valid file. */ -function required(value: T | undefined, path: string): T { - if (value === undefined) { - throw new Error(`${DESCRIPTOR}: webhooks.${path} is missing; the descriptor shape changed`); - } - return value; -} - -export function repositoryWebhookVocabulary(doc: WebhooksDescriptor): WebhookVocabulary { - const events = new Set(); - const references = new Set(); - for (const [hook, entry] of Object.entries(doc.webhooks)) { - const post = required(entry.post, `${hook}.post`); - const github = required(post["x-github"], `${hook}.post.x-github`); - const scopes = required( - github["supported-webhook-types"], - `${hook}.post.x-github.supported-webhook-types`, - ); - const slug = required(github.subcategory, `${hook}.post.x-github.subcategory`); - const url = required(post.externalDocs?.url, `${hook}.post.externalDocs.url`); - if (!scopes.includes("repository")) { - continue; - } - // The subcategory is the docs slug (custom-property-values, issue-dependencies, sub-issues); the wire name GitHub - // accepts on a hook and sends in X-GitHub-Event is snake_case, the same rewrite @octokit/webhooks applies. - events.add(slug.replaceAll("-", "_")); - references.add(url.split("#")[0] as string); - } - if (events.size === 0) { - throw new Error( - `${DESCRIPTOR}: no webhook names "repository" among its supported-webhook-types; the scope vocabulary changed`, - ); - } - if (references.size !== 1) { - throw new Error( - `${DESCRIPTOR}: repository webhooks point at ${references.size} reference pages, expected one: ${[...references].join(", ")}`, - ); - } - return { events: [...events].sort(), reference: [...references][0] as string }; -} - -/** biome's lineWidth is 100: the render matches what `biome check --write` would produce, or build:check would see drift. */ -function renderReference(reference: string): string { - const inline = `export const WEBHOOK_EVENTS_REFERENCE = ${JSON.stringify(reference)};`; - if (inline.length <= 100) { - return inline; - } - return `export const WEBHOOK_EVENTS_REFERENCE =\n ${JSON.stringify(reference)};`; -} - -export function renderWebhookEvents(vocabulary: WebhookVocabulary): string { - return `/** - * GENERATED by gen-webhook-events.ts - do not edit. The events GitHub delivers to repository webhooks, from - * GitHub's webhooks OpenAPI description as @octokit/openapi-webhooks ships it (generated/api.github.com.json): - * every webhook whose supported-webhook-types names "repository", spelled as the wire event name. Regenerate - * with \`bun .github/scripts/gen-webhook-events.ts\` after a package bump. - */ - -/** GitHub's reference page for the events, the externalDocs url the descriptor's entries point at. */ -${renderReference(vocabulary.reference)} - -/** Sorted, without the "*" wildcard, which the schema adds. */ -export const REPOSITORY_WEBHOOK_EVENTS = [ -${vocabulary.events.map((event) => ` ${JSON.stringify(event)},`).join("\n")} -] as const; -`; -} - -/** The one dot-com file of the eight the package ships; its index would parse every GHES descriptor too. */ -export async function readDescriptor(): Promise { - const module = await import("@octokit/openapi-webhooks/generated/api.github.com.json", { - with: { type: "json" }, - }); - return module.default as WebhooksDescriptor; -} - -export async function regenerateWebhookEvents(): Promise { - const vocabulary = repositoryWebhookVocabulary(await readDescriptor()); - writeFileSync(join(ROOT, EVENTS_PATH), renderWebhookEvents(vocabulary)); - return vocabulary.events.length; -} - -if (import.meta.main) { - console.log(`wrote ${EVENTS_PATH} (${await regenerateWebhookEvents()} events)`); -} diff --git a/.github/scripts/generated.ts b/.github/scripts/generated.ts index 83b94956..0af850b7 100644 --- a/.github/scripts/generated.ts +++ b/.github/scripts/generated.ts @@ -7,7 +7,6 @@ import { join } from "node:path"; import { GENERATED_REGIONS } from "./gen-action-docs.js"; import { COVERAGE_PATH, PAGE_REGIONS } from "./gen-docs.js"; import { INDEX_PATH } from "./gen-gaps-index.js"; -import { EVENTS_PATH } from "./gen-webhook-events.js"; const ROOT = join(import.meta.dir, "..", ".."); @@ -24,10 +23,8 @@ function regions(generator: string, paths: readonly string[]): GeneratedOutput[] return paths.map((path) => ({ path, generator, kind: "regions" })); } -/** A page two generators write into (docs/reference/inputs.md) has one row per generator. Table order is run order: - * the webhook events feed the schema, so they render first, or a package bump would leave the two inconsistent for a run. */ +/** A page two generators write into (docs/reference/inputs.md) has one row per generator. Table order is run order. */ export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [ - { path: EVENTS_PATH, generator: ".github/scripts/gen-webhook-events.ts", kind: "file" }, { path: "lib/settings.schema.json", generator: ".github/scripts/gen-settings-schema.ts", diff --git a/.github/workflows/auto-fix.yml b/.github/workflows/auto-fix.yml index 44884020..fb58fba1 100644 --- a/.github/workflows/auto-fix.yml +++ b/.github/workflows/auto-fix.yml @@ -1,7 +1,5 @@ # The commit-back fixes a same-repo PR can need, pushed to its branch; an already-clean tree gets no commit. A PR # with unrelated type errors fails the build job by design: the graduation script refuses to half-fix a red build. -# src/sections/webhooks/events.ts -> build:events (a Dependabot @octokit/openapi-webhooks bump changes the event list on a -# branch nobody builds) # lib/settings.schema.json -> build:schema (a Dependabot generator bump changes its bytes on a branch nobody builds) # README, action.yml, docs/ regions -> build:docs, build:action-docs # src/upstream-gaps/ -> graduate-upstream-gaps.ts retires the gap files @octokit/types caught up with; @@ -43,7 +41,6 @@ on: - "bun.lock" - "tsconfig.json" - ".bun-version" - - ".github/scripts/gen-webhook-events.ts" - ".github/scripts/gen-settings-schema.ts" - ".github/scripts/graduate-upstream-gaps.ts" - ".github/scripts/gen-gaps-index.ts" @@ -85,13 +82,12 @@ jobs: - name: Graduate upstream gaps octokit now ships shell: bash run: bun .github/scripts/graduate-upstream-gaps.ts - - name: Regenerate the webhook events, schema, docs, and gaps index and stage the fix patch + - name: Regenerate the schema, docs, and gaps index and stage the fix patch id: rebuild shell: bash run: | - # One line per generator, in .github/scripts/generated.ts table order: the schema enum reads the events - # file, so the events render first. test/scripts/auto-fix-allowlist.test.ts pins the list and the order. - bun run build:events + # One line per generator, in .github/scripts/generated.ts table order; test/scripts/auto-fix-allowlist.test.ts + # pins the list and the order. bun run build:schema bun run build:docs bun run build:action-docs @@ -99,13 +95,13 @@ jobs: # Anything the earlier steps left staged is not this workflow's fix: start from an empty index so the # patch holds exactly the allowed paths. git reset -q - git add -A -- src/sections/webhooks/events.ts lib/settings.schema.json README.md action.yml \ + git add -A -- lib/settings.schema.json README.md action.yml \ docs/reference/coverage.md docs/reference/undeclared-policy.md docs/reference/permissions.md \ docs/operate/check-mode.md docs/reference/sections.md docs/reference/inputs.md \ docs/reference/architecture.md docs/start/getting-started.md src/upstream-gaps/ git diff --cached --binary > "$RUNNER_TEMP/autofix.patch" if [ ! -s "$RUNNER_TEMP/autofix.patch" ]; then - echo "webhook events, schema, docs, and upstream gaps already fresh" + echo "schema, docs, and upstream gaps already fresh" echo "changed=false" >> "$GITHUB_OUTPUT" echo "pruned=false" >> "$GITHUB_OUTPUT" else @@ -172,13 +168,12 @@ jobs: # of a protected path cannot hide behind an allowed destination. while IFS= read -r -d '' path; do case "$path" in - src/sections/webhooks/events.ts | lib/settings.schema.json | README.md | action.yml | \ + lib/settings.schema.json | README.md | action.yml | \ docs/reference/coverage.md | docs/reference/undeclared-policy.md | docs/reference/permissions.md | \ docs/operate/check-mode.md | docs/reference/sections.md | docs/reference/inputs.md | \ docs/reference/architecture.md | docs/start/getting-started.md | src/upstream-gaps/*) ;; *) - echo "::error::the fix patch staged '$path', outside src/sections/webhooks/events.ts," \ - "lib/settings.schema.json, the generated docs" \ + echo "::error::the fix patch staged '$path', outside lib/settings.schema.json, the generated docs" \ "(README.md, action.yml, docs/reference/coverage.md, docs/reference/undeclared-policy.md," \ "docs/reference/permissions.md, docs/operate/check-mode.md, docs/reference/sections.md," \ "docs/reference/inputs.md, docs/reference/architecture.md, docs/start/getting-started.md)," \ @@ -197,10 +192,7 @@ jobs: if ! git diff --cached --quiet -- lib/settings.schema.json; then subject="build: regenerate settings schema" fi - if ! git diff --cached --quiet -- src/sections/webhooks/events.ts; then - subject="build: regenerate webhook events and settings schema" - fi - if git diff --cached --quiet -- src/sections/webhooks/events.ts lib/settings.schema.json src/upstream-gaps/; then + if git diff --cached --quiet -- lib/settings.schema.json src/upstream-gaps/; then subject="docs: regenerate generated docs" fi if [ "$PRUNED" = "true" ]; then diff --git a/docs/upgrading/v2-to-v3.md b/docs/upgrading/v2-to-v3.md index 78fcff25..a9a6ac9a 100644 --- a/docs/upgrading/v2-to-v3.md +++ b/docs/upgrading/v2-to-v3.md @@ -1020,7 +1020,7 @@ v3 webhooks[0].config.url: "hooks.example.com/ci" is not an absolute URL (the webhooks[0].events[1]: "pushes" is not an event GitHub delivers to repository webhooks ("*" means every event); the accepted names are GitHub's list at https://docs.github.com/webhooks/webhook-events-and-payloads, read from @octokit/openapi-webhooks, so an event GitHub added since arrives in the release that bumps that package ``` -Fix: an absolute URL, `json` or `form`, `"0"` or `"1"`, and event names from GitHub's repository list. The list is generated from `@octokit/openapi-webhooks`, so an event GitHub adds later is refused until the release that bumps that package. +Fix: an absolute URL, `json` or `form`, `"0"` or `"1"`, and event names from GitHub's repository list. The list is pinned to `@octokit/openapi-webhooks`, so an event GitHub adds later is refused until the release that bumps that package. ## 48. Secret scanning patterns must compile diff --git a/package.json b/package.json index e88611f5..d45cb49a 100644 --- a/package.json +++ b/package.json @@ -51,10 +51,9 @@ "test:artifacts": "bun .github/scripts/trim-openapi.ts --when-stale && bun .github/scripts/fetch-graphql-schema.ts --when-stale", "test:e2e": "bun run test:artifacts && bun test/e2e/run.ts", "fuzz": "bun run test:artifacts && bun test/e2e/fuzz.ts", - "build": "bun run build:events && bun run build:bundle && bun run build:lib && bun run build:schema && bun run build:docs && bun run build:action-docs", + "build": "bun run build:bundle && bun run build:lib && bun run build:schema && bun run build:docs && bun run build:action-docs", "build:bundle": "bun build src/main.ts --target=node --outfile lib/index.js", "build:lib": "bun x tsdown", - "build:events": "bun .github/scripts/gen-webhook-events.ts", "build:schema": "bun .github/scripts/gen-settings-schema.ts", "build:docs": "bun run test:artifacts && bun .github/scripts/gen-docs.ts", "build:action-docs": "bun .github/scripts/gen-action-docs.ts", diff --git a/src/sections/webhooks/events.ts b/src/sections/webhooks/events.ts index 48c13dde..f3f2cd4b 100644 --- a/src/sections/webhooks/events.ts +++ b/src/sections/webhooks/events.ts @@ -1,8 +1,9 @@ /** - * GENERATED by gen-webhook-events.ts - do not edit. The events GitHub delivers to repository webhooks, from - * GitHub's webhooks OpenAPI description as @octokit/openapi-webhooks ships it (generated/api.github.com.json): - * every webhook whose supported-webhook-types names "repository", spelled as the wire event name. Regenerate - * with `bun .github/scripts/gen-webhook-events.ts` after a package bump. + * The events GitHub delivers to repository webhooks, spelled as the wire event name: every webhook in GitHub's + * webhooks OpenAPI description, as @octokit/openapi-webhooks ships it (generated/api.github.com.json), whose + * supported-webhook-types names "repository". The list is committed, not imported: bundling the descriptor would + * double lib/index.js. test/sections/webhooks-events.test.ts recomputes it from the package and fails with the + * names added and dropped when a bump moves the list; the fix is to edit this file to match. */ /** GitHub's reference page for the events, the externalDocs url the descriptor's entries point at. */ diff --git a/test/scripts/gen-webhook-events.test.ts b/test/scripts/gen-webhook-events.test.ts deleted file mode 100644 index 2a622e24..00000000 --- a/test/scripts/gen-webhook-events.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - repositoryWebhookVocabulary, - type WebhooksDescriptor, -} from "../../.github/scripts/gen-webhook-events.js"; - -const REFERENCE = "https://docs.github.com/webhooks/webhook-events-and-payloads"; - -function hook( - slug: string, - scopes: readonly string[], - overrides: Partial> = {}, -): WebhooksDescriptor["webhooks"][string] { - return { - post: { - externalDocs: { url: `${REFERENCE}#${slug}` }, - "x-github": { subcategory: slug, "supported-webhook-types": scopes }, - ...overrides, - }, - }; -} - -describe("the derivation", () => { - test("keeps repository-scoped events once each across their actions, sorted, in wire spelling, and drops every other scope", () => { - const doc: WebhooksDescriptor = { - webhooks: { - "sub-issues-parent-issue-added": hook("sub-issues", ["repository", "organization", "app"]), - "sub-issues-parent-issue-removed": hook("sub-issues", [ - "repository", - "organization", - "app", - ]), - // Every hyphen becomes an underscore, not the first alone. - "custom-property-values-updated": hook("custom-property-values", ["repository"]), - push: hook("push", ["repository", "organization", "app"]), - "installation-created": hook("installation", ["app"]), - "projects-v2-created": hook("projects_v2", ["organization"]), - sponsorship: hook("sponsorship", ["sponsors_listing"]), - }, - }; - expect(repositoryWebhookVocabulary(doc)).toEqual({ - events: ["custom_property_values", "push", "sub_issues"], - reference: REFERENCE, - }); - }); - - // A shape change in the descriptor refuses instead of skipping: a skipped entry would shorten the list and the parser - // would start refusing an event GitHub still delivers. - test.each<[label: string, doc: WebhooksDescriptor, message: string]>([ - [ - "an entry without supported-webhook-types", - { webhooks: { push: hook("push", ["repository"], { "x-github": { subcategory: "push" } }) } }, - "webhooks.push.post.x-github.supported-webhook-types is missing", - ], - [ - "no repository-scoped webhook at all (a renamed scope would otherwise empty the list)", - { webhooks: { push: hook("push", ["repo"]) } }, - 'no webhook names "repository"', - ], - ])("refuses %s instead of rendering a shorter list", (_label, doc, message) => { - expect(() => repositoryWebhookVocabulary(doc)).toThrow(message); - }); -}); diff --git a/test/scripts/generated.test.ts b/test/scripts/generated.test.ts index 86f56ab7..35df0f9a 100644 --- a/test/scripts/generated.test.ts +++ b/test/scripts/generated.test.ts @@ -14,11 +14,7 @@ import { withTempDir } from "../temp-dir.js"; /** Only a file type with a marker syntax carries a region; a marker string anywhere else is test or script text. */ const regionFile = (path: string): boolean => extname(path) in SYNTAX_BY_EXTENSION; /** The outputs the marker scan cannot see: whole generated files. */ -const WHOLE_FILES = [ - "lib/settings.schema.json", - "src/sections/webhooks/events.ts", - "src/upstream-gaps/index.ts", -]; +const WHOLE_FILES = ["lib/settings.schema.json", "src/upstream-gaps/index.ts"]; const tracked = execFileSync("git", ["ls-files", "-z"], { cwd: ROOT, encoding: "utf8" }) .split("\0") diff --git a/test/sections/webhooks-events.test.ts b/test/sections/webhooks-events.test.ts new file mode 100644 index 00000000..365aa2b5 --- /dev/null +++ b/test/sections/webhooks-events.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { + REPOSITORY_WEBHOOK_EVENTS, + WEBHOOK_EVENTS_REFERENCE, +} from "../../src/sections/webhooks/events.js"; + +/** + * Pins the committed event list to GitHub's webhooks OpenAPI description as @octokit/openapi-webhooks ships it. The + * descriptor is too large to bundle, so src/ never imports it; this test recomputes the list from the package, and a + * bump that adds or drops a repository event fails here naming the difference to apply to events.ts. + */ + +/** The slice of the descriptor the derivation reads; the rest of each webhook entry is its payload schema. */ +interface WebhooksDescriptor { + readonly webhooks: Readonly< + Record< + string, + { + readonly post?: { + readonly externalDocs?: { readonly url?: string }; + readonly "x-github"?: { + readonly subcategory?: string; + readonly "supported-webhook-types"?: readonly string[]; + }; + }; + } + > + >; +} + +interface WebhookVocabulary { + /** Sorted wire event names, without the "*" wildcard (the schema adds it). */ + readonly events: readonly string[]; + /** The reference pages the entries' externalDocs point at, minus their anchors; GitHub keeps them on one page. */ + readonly references: readonly string[]; + /** Entries lacking a field the derivation reads, as `hook.path`. A missing field is reported, not skipped: a + * skipped entry would drop an event GitHub still delivers and the parser would start refusing a valid file. */ + readonly missing: readonly string[]; +} + +function repositoryWebhookVocabulary(doc: WebhooksDescriptor): WebhookVocabulary { + const events = new Set(); + const references = new Set(); + const missing: string[] = []; + for (const [hook, entry] of Object.entries(doc.webhooks)) { + const github = entry.post?.["x-github"]; + const fields = { + "post.x-github.supported-webhook-types": github?.["supported-webhook-types"], + "post.x-github.subcategory": github?.subcategory, + "post.externalDocs.url": entry.post?.externalDocs?.url, + }; + for (const [path, value] of Object.entries(fields)) { + if (value === undefined) { + missing.push(`${hook}.${path}`); + } + } + const scopes = fields["post.x-github.supported-webhook-types"]; + const slug = fields["post.x-github.subcategory"]; + const url = fields["post.externalDocs.url"]; + if (scopes === undefined || slug === undefined || url === undefined) { + continue; + } + if (!scopes.includes("repository")) { + continue; + } + // The subcategory is the docs slug (custom-property-values, issue-dependencies, sub-issues); the wire name GitHub + // accepts on a hook and sends in X-GitHub-Event is snake_case, the same rewrite @octokit/webhooks applies. + events.add(slug.replaceAll("-", "_")); + references.add(url.split("#")[0] as string); + } + return { events: [...events].sort(), references: [...references].sort(), missing }; +} + +const REFERENCE = "https://docs.github.com/webhooks/webhook-events-and-payloads"; + +function hook( + slug: string, + scopes: readonly string[], + overrides: Partial> = {}, +): WebhooksDescriptor["webhooks"][string] { + return { + post: { + externalDocs: { url: `${REFERENCE}#${slug}` }, + "x-github": { subcategory: slug, "supported-webhook-types": scopes }, + ...overrides, + }, + }; +} + +describe("the derivation", () => { + test("keeps repository-scoped events once each across their actions, sorted, in wire spelling, and drops every other scope", () => { + const doc: WebhooksDescriptor = { + webhooks: { + "sub-issues-parent-issue-added": hook("sub-issues", ["repository", "organization", "app"]), + "sub-issues-parent-issue-removed": hook("sub-issues", [ + "repository", + "organization", + "app", + ]), + // Every hyphen becomes an underscore, not the first alone. + "custom-property-values-updated": hook("custom-property-values", ["repository"]), + push: hook("push", ["repository", "organization", "app"]), + "installation-created": hook("installation", ["app"]), + "projects-v2-created": hook("projects_v2", ["organization"]), + sponsorship: hook("sponsorship", ["sponsors_listing"]), + }, + }; + expect(repositoryWebhookVocabulary(doc)).toEqual({ + events: ["custom_property_values", "push", "sub_issues"], + references: [REFERENCE], + missing: [], + }); + }); + + test("names each entry lacking a field it reads instead of skipping the entry", () => { + const doc: WebhooksDescriptor = { + webhooks: { + push: hook("push", ["repository"], { "x-github": { subcategory: "push" } }), + fork: hook("fork", ["repository"], { externalDocs: {} }), + }, + }; + expect(repositoryWebhookVocabulary(doc).missing).toEqual([ + "push.post.x-github.supported-webhook-types", + "fork.post.externalDocs.url", + ]); + }); +}); + +describe("the committed list", () => { + test("is the descriptor's repository events under its one reference page; a bump that moves it fails here with the names to apply to events.ts", async () => { + // The one dot-com file of the eight the package ships; its index would load every GHES descriptor too. + const module = await import("@octokit/openapi-webhooks/generated/api.github.com.json", { + with: { type: "json" }, + }); + const vocabulary = repositoryWebhookVocabulary(module.default as WebhooksDescriptor); + expect(vocabulary.missing).toEqual([]); + expect(vocabulary.references).toEqual([WEBHOOK_EVENTS_REFERENCE]); + expect(REPOSITORY_WEBHOOK_EVENTS).toEqual(vocabulary.events); + }); +});