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
149 changes: 149 additions & 0 deletions .github/scripts/package-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* The package smoke, the gate behind the npm library build: build lib/pkg/,
* judge the package shape (publint, attw), pack a tarball, install it into a
* fresh consumer project, import it under Node (the entry and the schema
* subpath), and compile a TypeScript consumer against the bundled index.d.ts
* with skipLibCheck off - a declaration that leaks a devDependency type, or a
* type the emitter could not name, fails here instead of on a consumer's
* machine.
*
* Usage: `bun .github/scripts/package-smoke.ts` from anywhere; every temp
* directory is removed on every path, failure included.
*/

import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

/** This script lives at .github/scripts/, two levels below the repository root. */
const REPO_ROOT = join(import.meta.dir, "..", "..");

const PACKAGE = "@vivswan/github-settings-as-code";

/** The consumer's runtime import: the entry names and the schema subpath, each asserted. */
const NODE_CONSUMER = `import { SECTION_KEYS, validateSettings } from "${PACKAGE}";
import schema from "${PACKAGE}/settings.schema.json" with { type: "json" };
const result = validateSettings({ labels: [] });
if (result.isErr()) throw new Error("validateSettings rejected an empty labels list: " + result.error.code);
if (!SECTION_KEYS.includes("labels")) throw new Error("SECTION_KEYS lacks labels");
if (typeof schema.$schema !== "string") throw new Error("the schema subpath did not resolve to the JSON Schema");
console.log("imported " + SECTION_KEYS.length + " section keys and the schema");
`;

/** The consumer compiled against index.d.ts: a value and a type from the entry, both used. */
const TS_CONSUMER = `import { SECTION_KEYS, type SectionKey, validateSettings } from "${PACKAGE}";
const first: SectionKey | undefined = SECTION_KEYS[0];
const result = validateSettings({ labels: [] });
export const ok: boolean = result.isOk() && first === "repository";
`;

/** Run a command to completion in `cwd`, streaming its output; a non-zero exit throws. */
function run(command: string, args: string[], cwd: string): void {
console.log(`$ ${[command, ...args].join(" ")}`);
execFileSync(command, args, { cwd, stdio: "inherit" });
}

/** Run a command and return its stdout. */
function capture(command: string, args: string[], cwd: string): string {
console.log(`$ ${[command, ...args].join(" ")}`);
return execFileSync(command, args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
});
}

/**
* Give `body` fresh temp directories under `prefix` and remove every one of
* them when it returns or throws.
*/
export async function withTempDirs<T>(
prefix: string,
count: number,
body: (dirs: string[]) => Promise<T> | T,
): Promise<T> {
// Allocated inside the try, so a failing second mkdtemp still removes the first.
const dirs: string[] = [];
try {
for (let i = 0; i < count; i++) {
dirs.push(mkdtempSync(join(tmpdir(), prefix)));
}
return await body(dirs);
} finally {
for (const dir of dirs) {
rmSync(dir, { recursive: true, force: true });
}
}
}

/**
* The tarball `npm pack --json` reports, as an absolute path. npm 11 prints a
* one-element array, npm 12 an object keyed by package name; both are read.
*/
export function packedTarball(packJson: string, destination: string): string {
const parsed = JSON.parse(packJson) as unknown;
const entries = Array.isArray(parsed)
? (parsed as Array<{ filename?: string }>)
: Object.values((parsed ?? {}) as Record<string, { filename?: string }>);
const filename = entries[0]?.filename;
if (entries.length !== 1 || typeof filename !== "string") {
throw new Error(`npm pack --json must report exactly one tarball, got: ${packJson.trim()}`);
}
return join(destination, filename);
}

async function main(): Promise<void> {
run("bun", ["run", "build:lib"], REPO_ROOT);
run("bun", ["run", "lint:package"], REPO_ROOT);
await withTempDirs("gsac-smoke-", 2, ([packDir, consumerDir]) => {
if (packDir === undefined || consumerDir === undefined) {
throw new Error("withTempDirs handed out fewer directories than asked");
}
// --ignore-scripts keeps the prepare hook's output out of the JSON stdout.
const tarball = packedTarball(
capture(
"npm",
["pack", "--json", "--ignore-scripts", "--pack-destination", packDir],
REPO_ROOT,
),
packDir,
);
writeFileSync(
join(consumerDir, "package.json"),
`${JSON.stringify({ name: "smoke-consumer", private: true, type: "module" }, null, 2)}\n`,
);
run("npm", ["install", tarball, "--no-audit", "--no-fund"], consumerDir);
writeFileSync(join(consumerDir, "main.mjs"), NODE_CONSUMER);
run("node", ["main.mjs"], consumerDir);
writeFileSync(join(consumerDir, "main.ts"), TS_CONSUMER);
run(
join(REPO_ROOT, "node_modules", ".bin", "tsc"),
[
"--noEmit",
"--strict",
"--skipLibCheck",
"false",
"--module",
"nodenext",
"--moduleResolution",
"nodenext",
"--target",
"es2022",
"main.ts",
],
consumerDir,
);
});
console.log("package smoke: ok");
}

if (import.meta.main) {
try {
await main();
} catch (error) {
// The failing command already streamed its own output; one line names it.
console.error(`package smoke: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
20 changes: 20 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ jobs:
- name: Regenerate the schema and compare
run: bun run build:check

# The npm library gate: build lib/pkg/ (tsdown), judge the package shape
# (publint, attw), then install the packed tarball into a fresh consumer
# project and import it under Node and under tsc with skipLibCheck off, so
# a declaration leaking a devDependency type fails here, not on a
# consumer's machine. The consumer runs under node, so node is installed.
package-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build, pack, install, import, and typecheck the library
run: bun .github/scripts/package-smoke.ts

# Self-test: run the action from this checkout in check mode against this
# repository. The bundle is not committed, so build it first. Drift or
# permission skips are fine (the default token cannot read admin
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,7 @@ dist

# Finder metadata, kept ignored even if the managed macOS block above changes.
.DS_Store

# The library build (bun run build:lib, tsdown) is built, not committed, like
# lib/index.js above; the packaged branch and npm carry it.
lib/pkg/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ GitHub Settings as Code: GitHub Action applying declarative repository settings:
<!-- END REPO-PLATFORM MANAGED -->

- `lib/settings.schema.json` is the COMMITTED JSON Schema for settings.yml, generated from the zod slices in `src/sections/<key>/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(<slice>)` 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 `<key>.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).
- `lib/pkg/` is the npm library build: `bun run build:lib` (tsdown, configured in `tsdown.config.ts`) bundles `src/index.ts` into one ESM `index.js` plus one `index.d.ts` with the runtime dependencies external; like `lib/index.js` it is built, never committed. `package.json` publishes it as `@vivswan/github-settings-as-code` through its `exports` map (`.` to the build, `./settings.schema.json` to the committed schema, `./package.json` to itself; `files` ships only those plus LICENSE.md and README.md), and `test/package-json.test.ts` pins the manifest's field set and holds its `version` equal to `.release-please-manifest.json` (release-please's json extra-file rewrites it). The package-smoke job in `checks.yml` (`bun .github/scripts/package-smoke.ts`) builds the library, runs publint and attw, installs the packed tarball into a fresh consumer, and imports it under Node and under tsc with skipLibCheck off, so a declaration that leaks a devDependency type or a type the emitter cannot name fails there; a type a section module exposes must therefore be exported from its home module. The library reference is docs/reference/library.md.
- 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/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/<key>/`: `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), `<key>.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, <key>: []}` 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/<key>/` with the six pieces above plus its `<key>.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).
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ The [getting started guide](docs/start/getting-started.md) walks the same steps
- v2 activates settings keys that were inert on v1: `actions.oidc_customization_sub`, `actions.fork_pr_contributor_approval`, `actions.fork_pr_workflows_private_repos`, and `branches[].protection.required_signatures`. Audit them in your files before moving a `@v1` pin; a stale `required_signatures: false` would remove a hand-enabled requirement.
- Only the latest release is supported; fixes are not backported (see [SECURITY.md](.github/SECURITY.md)). Each major has an [upgrade guide](docs/upgrading/README.md).

## Library

The same engine is the npm package `@vivswan/github-settings-as-code` (ESM, Node 22.14 or newer): validate, merge, check, and apply from your own code. It is built from this repository with `bun run build:lib`; the [library reference](docs/reference/library.md) has the API by group.

## Docs

| Goal | Read |
Expand All @@ -80,6 +84,7 @@ The [getting started guide](docs/start/getting-started.md) walks the same steps
| Read a failing run | [Troubleshooting](docs/operate/troubleshooting.md) |
| Move a pin to a new major | [Upgrading](docs/upgrading/README.md) |
| See how the code is laid out | [Architecture](docs/reference/architecture.md) |
| Use the engine from your own code | [Library](docs/reference/library.md) |

## Contributing

Expand Down
Loading
Loading