Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/scripts/auto-fix-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { dispatch } from "./lib/entry.js";
import { configureBotIdentity, leasePush } from "./lib/pr-branch.js";
import { capture, requireEnv, run, setOutput, status } from "./lib/workflow-step.js";

/** In generated.ts table order, each generator after its sources: the descriptor-derived files and the gaps index are imported through src/, and the inputs table reads action.yml. */
/** In generated.ts table order, each generator after its sources: the descriptor-derived files and the gaps index are imported through src/, and the inputs table reads INPUT_DECLS from src/flows/inputs.ts. */
export const GENERATORS = [
"build:openapi",
"build:gaps-index",
Expand Down
22 changes: 17 additions & 5 deletions .github/scripts/package-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,16 +157,28 @@ export function packedTarball(packJson: string, destination: string): string {
return join(destination, filename);
}

/**
* `npm pack` arguments under which stdout is the JSON alone. --ignore-scripts skips the package's `prepare` script
* from npm 11 on; npm 10 runs it regardless, in the foreground by default, so --foreground-scripts=false is what
* keeps the script's output (lefthook's sync line, in a checkout with the hooks installed) off stdout there.
*/
export function packArguments(destination: string): string[] {
return [
"pack",
"--json",
"--ignore-scripts",
"--foreground-scripts=false",
"--pack-destination",
destination,
];
}

async function main(): Promise<void> {
run("bun", ["run", "build:schema"], REPO_ROOT);
run("bun", ["run", "build:lib"], REPO_ROOT);
run("bun", ["run", "lint:package"], REPO_ROOT);
await withSmokeDirs("gsac-smoke-", ({ pack, consumer }) => {
// --ignore-scripts keeps the prepare hook's output out of the JSON stdout.
const tarball = packedTarball(
capture("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", pack], REPO_ROOT),
pack,
);
const tarball = packedTarball(capture("npm", packArguments(pack), REPO_ROOT), pack);
writeFileSync(
join(consumer, "package.json"),
`${JSON.stringify({ name: "smoke-consumer", private: true, type: "module" }, null, 2)}\n`,
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/auto-fix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ on:
- "architecture.yml"
- "docs/operate/check-mode.md"
- "docs/start/getting-started.md"
- "docs/sections/*.docs.yml"
- "docs/schema.docs.yml"
- ".github/scripts/gen-action-docs.ts"
- ".github/scripts/gen-inputs-table.ts"
- "docs/reference/coverage.md"
Expand All @@ -55,6 +57,11 @@ on:
- ".github/scripts/endpoint-docs.ts"
- ".github/scripts/endpoint-docs.yml"
- "test/e2e/openapi/validate.ts"
- "test/e2e/openapi/paths.ts"
# The formatter's config: every generator but the gaps index pipes its output through biome. Listed by hand: the
# trigger test (test/scripts/auto-fix-steps.test.ts) follows what a generator imports and loads, not what a subprocess opens.
- "biome.json"
- "biome.local.jsonc"

permissions:
contents: read
Expand Down
1 change: 0 additions & 1 deletion architecture.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,3 @@ edges:
report: [discovery, engine, github, io, private, problem, schema, sections, types]
problem: [plain-data, schema, text]
schema: [sections, types]
upstream-gaps: [types]
1 change: 0 additions & 1 deletion docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,6 @@ graph TD
problem --> text
schema --> sections
schema --> types
upstream_gaps --> types
```
<!-- END GENERATED: architecture-map -->

Expand Down
14 changes: 13 additions & 1 deletion src/upstream-gaps/gap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { Endpoints } from "@octokit/types";

/**
* Routes the pinned @octokit/types does not carry yet. The gap file carries a MustBeNever tripwire that
* Routes the pinned @octokit/types does not carry yet. The gap file carries an OctokitLacks tripwire that
* fails typecheck when octokit ships a route; the graduate script then deletes the file
* (documentedInSpec: true) or rewrites it spec-only (documentedInSpec: false). `documentedInSpec` is false
* only for features GitHub's OpenAPI descriptor ALSO lags; those are excluded from the descriptor slice the
Expand Down Expand Up @@ -48,6 +48,18 @@ export function defineGap<const G extends Omit<OctokitGap, "kind">>(
return { ...gap, kind: "octokit" };
}

/**
* The tripwire a defineGap file declares over its own GAP: `type _X = OctokitLacks<typeof GAP>`. The constraint
* admits only routes the pinned @octokit/types lacks, so once octokit ships one, TS2344 fires inside that gap
* file: the one diagnostic the graduate script acts on.
*
* A gap file imports nothing but this file, so no layer edge in architecture.yml hangs on which gap files
* exist, and a graduation cannot leave one stale.
*/
export type OctokitLacks<
G extends { readonly routes: readonly Exclude<G["routes"][number], keyof Endpoints>[] },
> = G;

/**
* Routes are constrained to keyof Endpoints (octokit already ships them), so a typo or a not-yet-shipped
* route fails here instead of as a missing SupplementalRoute downstream.
Expand Down
8 changes: 2 additions & 6 deletions src/upstream-gaps/lfs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type { Endpoints } from "@octokit/types";
import type { MustBeNever } from "../types.js";
import { defineGap } from "./gap.js";
import { defineGap, type OctokitLacks } from "./gap.js";

/** GitHub shipped the Git LFS repository toggle; @octokit/types does not carry these routes yet, nor does the published OpenAPI description. */
export const GAP = defineGap({
Expand All @@ -9,6 +7,4 @@ export const GAP = defineGap({
});

/** Fires when @octokit/types gains any of these routes: the graduate script retires this file per its documentedInSpec lifecycle. */
type _GraduateThisFileOnceOctokitShipsIt = MustBeNever<
Extract<(typeof GAP.routes)[number], keyof Endpoints>
>;
type _GraduateThisFileOnceOctokitShipsIt = OctokitLacks<typeof GAP>;
43 changes: 41 additions & 2 deletions test/library/package-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { describe, expect, test } from "bun:test";
import { existsSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { packedTarball, withSmokeDirs } from "../../.github/scripts/package-smoke.js";
import {
packArguments,
packedTarball,
withSmokeDirs,
} from "../../.github/scripts/package-smoke.js";
import { withTempDir } from "../temp-dir.js";

/** Room for the `npm pack` below: 1 to 3 s as a rule, and past bun's 5 s default on a slow runner. */
const NPM_PACK_TIMEOUT = 30_000;

describe("package smoke helpers", () => {
test("withSmokeDirs hands out pack/ and consumer/ under one root and removes the root after the body", async () => {
Expand Down Expand Up @@ -40,6 +49,36 @@ describe("package smoke helpers", () => {
).toBe("/dest/vivswan-github-settings-as-code-2.0.0.tgz");
});

test(
"npm pack under the smoke's arguments prints the JSON alone for a package whose prepare script prints",
() =>
withTempDir("gsac-pack-test-", (dir) => {
// Without --foreground-scripts=false an npm that runs `prepare` under --ignore-scripts (npm 10) puts what the
// script prints ahead of the JSON. CI packs with npm 11, which skips prepare, so its smoke job cannot see it.
const pkg = join(dir, "pkg");
const destination = join(dir, "out");
mkdirSync(pkg);
mkdirSync(destination);
writeFileSync(
join(pkg, "package.json"),
JSON.stringify({
name: "prints-on-prepare",
version: "1.0.0",
scripts: { prepare: "echo sync hooks" },
}),
);
const stdout = execFileSync("npm", packArguments(destination), {
cwd: pkg,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const tarball = packedTarball(stdout, destination);
expect(tarball).toBe(join(destination, "prints-on-prepare-1.0.0.tgz"));
expect(existsSync(tarball)).toBe(true);
}),
NPM_PACK_TIMEOUT,
);

test("packedTarball rejects anything but exactly one tarball", () => {
for (const output of [
"[]",
Expand Down
39 changes: 31 additions & 8 deletions test/scripts/auto-fix-steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { describe, expect, setDefaultTimeout, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { join, relative, resolve } from "node:path";
import { parse } from "yaml";
import {
FIX_OWNED_PATHS,
Expand All @@ -21,6 +21,7 @@ import {
generatorEntryPoint,
generatorScripts,
} from "../../.github/scripts/generated.js";
import { SCHEMA_DESCRIPTIONS } from "../../src/sections/docs-registry.js";
import { ROOT } from "../root.js";
import {
clone,
Expand Down Expand Up @@ -62,18 +63,40 @@ describe("the fix-owned paths and generators track the generated-output table",
expect(generatorScripts()).toEqual([...GENERATORS]);
});

const generatorFiles = generatorScripts().map((name) =>
generatorEntryPoint(packageScripts[name] ?? ""),
);
const triggersTheFix = (path: string): boolean =>
workflow.on.pull_request.paths.some((pattern) => new Bun.Glob(pattern).match(path));

test("a hand edit to a generated output or its generator triggers the fix", () => {
const files = generatorScripts().map((name) => generatorEntryPoint(packageScripts[name] ?? ""));
for (const file of files) {
for (const file of generatorFiles) {
expect(file).not.toBeNull();
}
for (const path of [...generatedPaths(), ...files.map((file) => file ?? "")]) {
expect(
workflow.on.pull_request.paths.some((pattern) => new Bun.Glob(pattern).match(path)),
`on.paths: ${path}`,
).toBe(true);
for (const path of [...generatedPaths(), ...generatorFiles.map((file) => file ?? "")]) {
expect(triggersTheFix(path), `on.paths: ${path}`).toBe(true);
}
});

test("an edit to a module a generator runs or to a docs file the registry loads triggers the fix", async () => {
// Without this, a generator input outside src/ changes with no fix run: a docs-only pull request leaves the
// generated page stale and fails on the drift test. The bundler names the modules whose code reaches a generator.
const bundle = await Bun.build({
entrypoints: generatorFiles.map((file) => join(ROOT, file ?? "")),
root: ROOT,
target: "bun",
packages: "external",
metafile: true,
});
const modules = Object.keys(bundle.metafile?.inputs ?? {}).map((path) =>
relative(ROOT, resolve(path)),
);
const docs = [...new Set(SCHEMA_DESCRIPTIONS.map((entry) => entry.source))];
// Controls: a census that came back empty or partial would pass with nothing checked.
expect(modules).toContain("src/sections/labels/index.ts");
expect(docs).toContain("docs/sections/labels.docs.yml");
expect([...modules, ...docs].filter((path) => !triggersTheFix(path))).toEqual([]);
});
});

const GENERATOR_ENV: Record<(typeof GENERATORS)[number], string> = {
Expand Down
Loading
Loading