Skip to content
Merged
  •  
  •  
  •  
25 changes: 7 additions & 18 deletions .github/scripts/arch-lint.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
/**
* The architecture lint (bun run lint:arch): the imports between the layers
* of src/ are exactly the edges architecture.yml declares - an undeclared
* edge and a stale allowance both fail. gen-docs.ts renders the same
* declaration as the module map. dependency-cruiser was the intended tool but
* cannot cruise this repository: it needs the TypeScript compiler API, which
* the pinned typescript 7 no longer ships, so it resolves nothing.
*
* An edge is any way one file names another: the runtime loads the
* changed-sections scanner reads, plus type-only imports, re-exports, and
* `import("./x.js").T` and `import X = require("./x.js")` in type positions.
* The architecture lint (bun run lint:arch): src/ imports between layers must be exactly the edges architecture.yml
* declares; an undeclared edge and a stale allowance both fail. dependency-cruiser was the intended tool, but it
* needs the TypeScript compiler API, which the pinned typescript 7 no longer ships, so it resolves nothing here.
* runtime loads (what the changed-sections scanner reads) -> edges
* type-only imports, re-exports -> edges too
* `import("./x.js").T`, `import X = require("./x.js")` in type positions -> edges too
*/

import { readdirSync, readFileSync } from "node:fs";
Expand All @@ -31,7 +27,6 @@ export function readArchitecture(root: string): Architecture {
return parseYaml(readFileSync(join(root, ARCHITECTURE_PATH), "utf8")) as Architecture;
}

/** The layer owning a repo-relative path, or undefined. */
function layerOf(arch: Architecture, path: string): string | undefined {
return Object.entries(arch.layers).find(([, paths]) =>
paths.some((owned) => (owned.endsWith("/") ? path.startsWith(owned) : path === owned)),
Expand All @@ -53,7 +48,6 @@ function* nodesOf(value: unknown): Generator<Node> {
}
}

/** Every relative specifier `text` names, runtime and type-level alike. */
export function importSpecifiers(text: string, file: string): string[] {
const { program, module } = parseSync(file, text);
const typeLevel = [...nodesOf(program)].flatMap((node) => {
Expand All @@ -76,11 +70,6 @@ export function importSpecifiers(text: string, file: string): string[] {
return [...all].filter((specifier) => /^\.\.?\//.test(specifier));
}

/**
* The lint verdict for `root`: an import between layers the declaration lacks
* (naming both files), a declared edge no file draws, or a cruised file outside
* every layer. Empty means the declaration is exactly the tree.
*/
export function lintArchitecture(root: string, arch = readArchitecture(root)): string[] {
const excluded = arch.exclude.map((pattern) => new Bun.Glob(pattern));
const drawn = new Map<string, string[]>();
Expand Down Expand Up @@ -129,7 +118,7 @@ export function lintArchitecture(root: string, arch = readArchitecture(root)): s
return problems;
}

/** The module map over the DECLARED edges; a hyphen in a layer name is edge syntax to mermaid. */
/** A hyphen in a layer name is edge syntax to mermaid, so ids swap it for an underscore. */
export function renderArchitectureMermaid(arch: Architecture): string {
const id = (layer: string): string => layer.replace(/-/g, "_");
return [
Expand Down
183 changes: 40 additions & 143 deletions .github/scripts/changed-sections.ts

Large diffs are not rendered by default.

39 changes: 7 additions & 32 deletions .github/scripts/check-endpoint-coverage.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
/**
* Endpoint-coverage tripwire for the nightly e2e run. Every "section.role" key
* in allEndpoints() AND allGraphqlOps() must be exercised at least once by the
* curated corpus; a cold route means the harness has a blind spot, an endpoint
* the action can call that no scenario reaches, so a regression there would
* ship unnoticed.
* Endpoint-coverage tripwire: every "section.role" key in allEndpoints() AND allGraphqlOps() must be reached by the
* curated corpus at least once. A cold route is a blind spot, an endpoint the action can call that no scenario
* reaches, so a regression there would ship unnoticed.
*
* This runs the full corpus through runScenario and reads each report's request
* log (ScenarioReport.requests, the snapshot the runner takes before teardown),
* attributing every logged request to a registered route via matchesTemplate
* (the same matcher the mock routes with) - or, for GraphQL, via the
* operationName the mock dispatched. It then fails naming any route no
* request reached.
*
* Usage: `bun .github/scripts/check-endpoint-coverage.ts`. Exit 0 when every
* route was hit; exit 1 naming the cold routes otherwise.
* `bun .github/scripts/check-endpoint-coverage.ts` fails on any cold route, naming it.
*/

import {
Expand All @@ -26,19 +16,10 @@ import type { LoggedRequest } from "../../test/e2e/mock/contract.js";
import { runScenario } from "../../test/e2e/runner.js";
import { loadScenarios, scenarioRoots } from "../../test/e2e/schema.js";

/**
* One registered route, discriminated by how a hit is attributed: a REST
* route matches by method + path template, a GraphQL operation by the
* operationName the mock logged for the request.
*/
type Route =
| { kind: "rest"; key: string; method: string; path: string }
| { kind: "graphql"; key: string; opName: string };

/**
* The registered routes - every REST endpoint plus every GraphQL operation -
* so a cold GraphQL op fails the sweep exactly like a cold REST route.
*/
export function registeredRoutes(): Route[] {
return [
...Object.entries(allEndpoints()).map(
Expand All @@ -55,7 +36,6 @@ export function registeredRoutes(): Route[] {
];
}

/** Record every route each request hit into `hit` (mutated in place). */
export function recordHits(
requests: readonly LoggedRequest[],
routes: Route[],
Expand All @@ -77,7 +57,6 @@ export function recordHits(
}
}

/** The registered route keys no request reached, sorted. */
export function coldRoutes(hit: ReadonlySet<string>, routes: Route[]): string[] {
return routes
.filter((route) => !hit.has(route.key))
Expand All @@ -96,13 +75,9 @@ async function main(): Promise<number> {
return 1;
}
for (const scenario of scenarios) {
// A scenario's own pass/fail is the corpus job's concern; here we only
// aggregate which routes it reached, so a throw does not stop the sweep. A
// fault-injection scenario can drop the connection mid-run and make
// runScenario throw; runScenario snapshots its request log into the report
// and only returns on success, so a throw yields no report and its routes
// go unattributed. That could turn a real hit into a false cold route, so
// NAME the scenario loudly rather than swallow the throw.
// A scenario's own pass/fail is the corpus job's concern; here only its reached routes matter. runScenario
// returns a report (with the request log) even for a failing scenario, so a THROW is the one path that loses the
// routes it did reach and can turn a real hit into a cold route: name the scenario loudly, never swallow it.
try {
const report = await runScenario(scenario);
recordHits(report.requests, routes, hit);
Expand Down
8 changes: 3 additions & 5 deletions .github/scripts/coverage-data.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* The authored half of COVERAGE.md (rendered by gen-docs.ts): the prose no declaration can
* derive, because it enumerates what does NOT exist - the repo-scoped gaps, the settings with no
* public API, and the account-scoped surface. The data is coverage-data.yml beside this file;
* this module is its shape and loader. The Supported table renders from each section's <key>.docs.yml.
* The authored half of COVERAGE.md (rendered by gen-docs.ts): the prose no declaration can derive, because it
* enumerates what does NOT exist. The data is coverage-data.yml beside this file; the Supported table renders from
* each section's <key>.docs.yml.
*/

import { join } from "node:path";
Expand Down Expand Up @@ -41,7 +40,6 @@ const CoverageGaps = z.union([
.readonly(),
]);

/** A non-empty list of authored bullets. */
const Items = z.tuple([z.string().min(1)], z.string().min(1)).readonly();

export const CoverageData = z
Expand Down
43 changes: 11 additions & 32 deletions .github/scripts/fetch-graphql-schema.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,49 @@
/**
* Fetch GitHub's public GraphQL schema and write it to disk, the GraphQL
* sibling of trim-openapi.ts. The query-validation unit test
* (test/sections/graphql-queries.test.ts) loads the schema from disk - never
* the network - so test runs stay hermetic and fast; this script is the ONLY
* thing that touches the network. The output is a FETCHED, gitignored
* artifact (a multi-MB generated blob kept out of history): local devs run
* this once, and CI restores it from actions/cache or re-fetches on a miss.
* Fetches GitHub's public GraphQL schema to disk, the GraphQL sibling of trim-openapi.ts. This script is the ONLY
* thing that touches the network; the output is a fetched, gitignored artifact.
* test/sections/graphql-queries.test.ts -> loads it from disk
* local dev -> runs this once
* CI -> restores it from cache, re-fetches on a miss
* UPSTREAM_REF -> PINNED to a github/docs commit, so two runs months apart fetch byte-identical text
*
* Run: `bun .github/scripts/fetch-graphql-schema.ts` (writes the schema in
* place). Re-run to adopt a newer upstream ref.
*
* The upstream ref is PINNED to a github/docs commit SHA (the docs pipeline
* publishes the schema at src/graphql/data/fpt/schema.docs.graphql and
* updates it continuously on main), so two runs months apart fetch
* byte-identical schema text - the same reproducibility contract as
* trim-openapi's UPSTREAM_REF.
* Run: `bun .github/scripts/fetch-graphql-schema.ts`; re-run after bumping UPSTREAM_REF.
*/

import { mkdirSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { buildSchema } from "graphql";
import { fetchTextWithRetry } from "./lib/fetch-retry.js";

/**
* The github/docs commit the schema is fetched at. Bump this (and re-run) to
* adopt upstream schema changes; pinning to a SHA keeps the output
* reproducible.
*/
const UPSTREAM_REF = "01f2174e1ab5d15d4946cfe96ef7dfb5c9a8b889";

/** The free-tier (github.com) schema, the flavor the action targets. */
/** fpt is the free-tier (github.com) flavor, the one the action targets. */
const SCHEMA_URL =
`https://raw.githubusercontent.com/github/docs/${UPSTREAM_REF}` +
"/src/graphql/data/fpt/schema.docs.graphql";

const OUT_PATH = join(import.meta.dir, "..", "..", "test", "e2e", "graphql", "schema.docs.graphql");

/** Abandon a fetch attempt if the (large) schema has not arrived in this long. */
const FETCH_TIMEOUT_MS = 60_000;

async function main(): Promise<number> {
console.log(`fetching ${SCHEMA_URL}`);
// Per-attempt timeout plus bounded retry (lib/fetch-retry.ts): a hung
// connection or a transient blip - even mid-download - fails loudly with
// advice instead of the script stalling forever, or one blip failing the
// whole CI gate.
const fetched = await fetchTextWithRetry("GraphQL schema", SCHEMA_URL, FETCH_TIMEOUT_MS);
if (!fetched.ok) {
throw new Error(
`failed to fetch the GraphQL schema: ${fetched.status} ${fetched.statusText} for ${SCHEMA_URL}. Check UPSTREAM_REF and the schema path`,
);
}
const text = fetched.text;
// Integrity at generation, the assertRefFree analog: a truncated download
// or a moved upstream file must fail HERE, not as an opaque parse error in
// the disk-only consumer.
// A truncated download or a moved upstream file must fail HERE, not as an opaque parse error in the disk-only consumer.
try {
buildSchema(text);
} catch (error) {
throw new Error(
`the fetched GraphQL schema from ${SCHEMA_URL} failed to parse: ${error instanceof Error ? error.message : String(error)}. The download may be truncated (re-run), or the upstream file changed shape (check UPSTREAM_REF)`,
);
}
// Atomic write: serialize to a temp file, then rename over the target, so
// an aborted run leaves the previously written schema intact. The directory
// holds no tracked file, so a fresh checkout must create it first.
// Temp file then rename, so an aborted run leaves the previously written schema intact. The directory holds no
// tracked file, so a fresh checkout must create it first.
mkdirSync(dirname(OUT_PATH), { recursive: true });
const tmpPath = `${OUT_PATH}.tmp`;
writeFileSync(tmpPath, text);
Expand Down
Loading
Loading