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
4 changes: 2 additions & 2 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ This action holds a repository-admin token and writes repository settings, so th
- Token handling. The token travels only in the Authorization header and is never printed, not even in debug traces. Any path that puts it in logs, annotations, the step summary, or outputs is a vulnerability.
- Workflow-command injection. API responses and settings-file content are echoed into annotations and the step summary, escaped for workflow commands (%, CR, LF) and for summary tables (pipes, backslashes). Input that breaks out of that escaping and injects commands or forged log lines is a vulnerability.
- Settings escalation. A crafted settings file must never touch a repository or setting it does not declare, nor bypass the preflight barrier or the required-sections policy.
- Supply chain. A packaged commit whose bundle a rebuild of its parent (its source commit) does not reproduce, or whose tree is not that source's plus the bundle and library build, minus `package.json`'s preparation scripts (`prepare` and the install hooks, which would make npm rebuild a `github:` install), is a vulnerability. The next section says what each ref points at and how to verify it.
- Supply chain. A packaged commit whose bundle a rebuild of its parent (its source commit) does not reproduce, or whose tree is not that source's plus the bundle, the schema, and the library build, minus `package.json`'s preparation scripts (`prepare` and the install hooks, which would make npm rebuild a `github:` install), is a vulnerability. The next section says what each ref points at and how to verify it.
- npm provenance. Every CI-published version of `@vivswan/github-settings-as-code` carries an npm provenance attestation naming this repository and workflow, which `npm audit signatures` checks in a project that installs it. A version without one, or whose attestation names another repository or workflow, is a vulnerability: every version on the registry is CI-published, and no exception exists.

## Verifying a release

What a `uses:` pin points at:

- The `vX.Y.Z` tags, the moving major, and `latest` point at packaged commits: each the child of its source commit on `main`, carrying that tree plus the bundle and library built from it, minus `package.json`'s preparation scripts, by the workflow run its message names when CI minted it (a package minted by hand in the release recovery names none; see below). `main` carries no executable bundle.
- The `vX.Y.Z` tags, the moving major, and `latest` point at packaged commits: each the child of its source commit on `main`, carrying that tree plus the bundle, the schema, and the library built from it, minus `package.json`'s preparation scripts, by the workflow run its message names when CI minted it (a package minted by hand in the release recovery names none; see below). `main` carries no executable bundle.
- The tags up to v2.0.0 point at release commits on `main` from when `main` still committed the bundle. Nothing re-verifies them; the release-tags ruleset is what keeps them where they are.
- The release-tags ruleset freezes version tags for everything except deliberate repository-admin repair. The release workflow never moves a version tag; a rerun verifies the existing one byte-for-byte.
- npm publishes through trusted publishing (OIDC) from `ci.yml`. The package disallows tokens, so no registry token exists anywhere.
Expand Down
10 changes: 6 additions & 4 deletions .github/scripts/gen-settings-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
* defaulted keys -> OPTIONAL: io: "input" describes the file, not the parsed output, so a key the slice
* fills at parse (a ruleset's target) stays out of required and keeps its default keyword
* root layout -> zod's own, passed through verbatim
* $id -> stamped (SCHEMA_ID); definitions sorted so the committed file diffs deterministically
* $id -> stamped (SCHEMA_ID); definitions sorted so the built file is deterministic
*/

import { writeFileSync } from "node:fs";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { z } from "zod";
import { SettingsFile } from "../../src/schema.js";
Expand Down Expand Up @@ -93,13 +93,15 @@ function encodeRefs(node: unknown): void {
}
encodeRefs(generated);

// No layout assumption is guarded here: a future zod's shape change surfaces as schema-check drift, and a broken
// emission fails the published-schema tests (ajv compile plus fixture round-trips).
// No layout assumption is guarded here: a future zod's shape change or a broken emission fails the
// published-schema tests (ajv compile plus fixture round-trips), which load the file this script writes.
const { definitions, ...rest } = generated;
const sortedDefinitions = Object.fromEntries(
Object.entries(definitions ?? {}).sort(([a], [b]) => (a < b ? -1 : 1)),
);

// A fresh checkout has no lib/: nothing under it is committed.
mkdirSync(join(ROOT, "lib"), { recursive: true });
const schemaPath = join(ROOT, "lib", "settings.schema.json");
writeFileSync(
schemaPath,
Expand Down
3 changes: 1 addition & 2 deletions .github/scripts/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ function regions(generator: string, paths: readonly string[]): GeneratedOutput[]
}

/** A page two generators write into (docs/reference/inputs.md) has one row per generator. Table order is run order:
* the schema, docs, and action.yml generators import the gaps index through src/, and action.yml feeds the inputs table, so each renders first, or a new gap file or a bump would leave a run stale. */
* the docs and action.yml generators import the gaps index through src/, and action.yml feeds the inputs table, so each renders first, or a new gap file or a bump would leave a run stale. */
export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [
{ path: INDEX_PATH, generator: "build:gaps-index", kind: "file" },
{ path: "lib/settings.schema.json", generator: "build:schema", kind: "file" },
...regions("build:docs", [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]),
...regions("build:action-docs", Object.keys(GENERATED_REGIONS)),
{ path: INPUTS_PAGE_PATH, generator: "build:inputs-table", kind: "regions" },
Expand Down
5 changes: 3 additions & 2 deletions .github/scripts/package-smoke.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* 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
* The package smoke, the gate behind the npm library build: build lib/pkg/
* and the schema, judge the package shape (publint, attw), pack a tarball, install it into a
* fresh consumer project, import it under Node (both entries and the schema
* subpath), and compile a TypeScript consumer against the bundled index.d.ts
* and internal.d.ts with skipLibCheck off - a declaration that leaks a devDependency type, or a
Expand Down Expand Up @@ -168,6 +168,7 @@ export function packedTarball(packJson: string, destination: string): string {
}

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 }) => {
Expand Down
13 changes: 9 additions & 4 deletions .github/scripts/release-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* The release pipeline's git topology. main stays source-only, no version tag ever lands on it, and every ref a
* consumer names points at a packaged commit: the child of one main commit, carrying that commit's build.
*
* packaged commit = parent: the main commit; tree: its tree + lib/index.js + lib/pkg/, package.json minus its preparation scripts
* packaged commit = parent: the main commit; tree: its tree + lib/index.js + lib/settings.schema.json + lib/pkg/,
* package.json minus its preparation scripts
* refs/tags/build/<pos>.<sha7> -> the packaged commit of the main commit at first-parent position <pos>; created once, never moved; the ten newest kept
* refs/tags/latest -> the packaged commit of the newest main commit
* refs/tags/vX.Y.Z -> the packaged commit of the release's merge commit; never moved
Expand Down Expand Up @@ -42,10 +43,14 @@ const MANIFEST_FILE = ".release-please-manifest.json";
const CONFIG_FILE = "release-please-config.json";
const MANIFEST = "package.json";
/** What a packaged commit carries beyond its source (a directory entry stages every file under it). */
const PACKAGED_PATHS = ["lib/index.js", "lib/pkg/"] as const;
const PACKAGED_PATHS = ["lib/index.js", "lib/settings.schema.json", "lib/pkg/"] as const;
/** What every packaged commit must carry as non-empty regular files. */
const REQUIRED_BUILT_FILES = ["lib/index.js", "lib/pkg/index.js"] as const;
const PACKAGED = "lib/index.js and lib/pkg/";
const REQUIRED_BUILT_FILES = [
"lib/index.js",
"lib/settings.schema.json",
"lib/pkg/index.js",
] as const;
const PACKAGED = "lib/index.js, lib/settings.schema.json, and lib/pkg/";
const LATEST_REF = "refs/tags/latest";
const BUILD_TAG_PREFIX = "refs/tags/build/";
const BUILD_TAG = /^refs\/tags\/build\/([1-9]\d*)\.[0-9a-f]{7}$/;
Expand Down
23 changes: 8 additions & 15 deletions .github/workflows/auto-fix.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
# 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.
# 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, build:inputs-table (action-docs renders
# the inputs table from action.yml)
# src/upstream-gaps/ -> graduate-upstream-gaps.ts retires the gap files @octokit/types caught up with;
# gen-gaps-index.ts re-renders index.ts
# lib/index.js -> never: main carries no bundle
# lib/ -> never: main carries no bundle, library, or schema
#
# Regenerating the schema executes PR-controlled code, so the fix is computed in an unprivileged job and crosses to
# Regenerating the generated files executes PR-controlled code, so the fix is computed in an unprivileged job and crosses to
# the push job as a git patch over the fix-owned paths only; applying a patch executes nothing, and the push job
# re-checks the applied paths before committing.
# --ignore-scripts on the build job's install -> lefthook and dependency lifecycle scripts never run
Expand All @@ -25,7 +24,6 @@ on:
pull_request:
paths:
- "src/**"
- "lib/settings.schema.json"
- "README.md"
- "action.yml"
- "docs/reference/undeclared-policy.md"
Expand All @@ -43,7 +41,6 @@ on:
- "bun.lock"
- "tsconfig.json"
- ".bun-version"
- ".github/scripts/gen-settings-schema.ts"
- ".github/scripts/graduate-upstream-gaps.ts"
- ".github/scripts/gen-gaps-index.ts"
- ".github/scripts/gen-docs.ts"
Expand Down Expand Up @@ -82,28 +79,27 @@ jobs:
- name: Graduate upstream gaps octokit now ships
shell: bash
run: bun .github/scripts/graduate-upstream-gaps.ts
- name: Regenerate the gaps index, schema, docs, and inputs table and stage the fix patch
- name: Regenerate the gaps index, docs, and inputs table and stage the fix patch
id: rebuild
shell: bash
run: |
# One line per generator, in .github/scripts/generated.ts table order: the later generators import the
# gaps index through src/, and the inputs table reads action.yml, so each source renders first.
# test/scripts/auto-fix-allowlist.test.ts pins the list and the order.
bun run build:gaps-index
bun run build:schema
bun run build:docs
bun run build:action-docs
bun run build:inputs-table
# 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 -- lib/settings.schema.json README.md action.yml \
git add -A -- 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 "schema, docs, and upstream gaps already fresh"
echo "docs and upstream gaps already fresh"
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "pruned=false" >> "$GITHUB_OUTPUT"
else
Expand Down Expand Up @@ -169,12 +165,12 @@ jobs:
# of a protected path cannot hide behind an allowed destination.
while IFS= read -r -d '' path; do
case "$path" in
lib/settings.schema.json | README.md | action.yml | \
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 lib/settings.schema.json, the generated docs" \
echo "::error::the fix patch staged '$path', outside 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)," \
Expand All @@ -190,10 +186,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
subject="build: regenerate generated files"
if ! git diff --cached --quiet -- lib/settings.schema.json; then
subject="build: regenerate settings schema"
fi
if git diff --cached --quiet -- lib/settings.schema.json src/upstream-gaps/; then
if git diff --cached --quiet -- src/upstream-gaps/; then
subject="docs: regenerate generated docs"
fi
if [ "$PRUNED" = "true" ]; then
Expand Down
14 changes: 5 additions & 9 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
run: bun run typecheck
- name: Compat markers
run: bun run check:compat
# The built, gitignored lib/settings.schema.json bun test loads is what
# `bun run test` builds first, so a failing generator fails the Test step.
- name: Test
run: bun run test

Expand Down Expand Up @@ -61,15 +63,6 @@ jobs:
- name: Require the release PR to carry this cycle's anchor
run: bun .github/scripts/release-pipeline.ts anchor-check

schema-check:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup
- name: Regenerate the schema and compare
run: bun run build:check

# The packed tarball is imported from a fresh consumer project 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 on the engines floor package.json advertises and on current node; tsdown needs a newer node
Expand Down Expand Up @@ -132,6 +125,9 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
# The fuzzer validates every drawn document against the built, gitignored schema.
- name: Build the schema
run: bun run build:schema
- name: Run scenarios and fuzz
run: |
bun test/e2e/run.ts
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/nightly-fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ jobs:
with:
node-version: 24

# The fuzzer validates every drawn document against the built, gitignored schema.
- name: Build the schema
run: bun run build:schema

- name: Fuzz
env:
SEED: ${{ inputs.seed }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/post-green.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ jobs:
rm -f probe.err
- uses: ./.github/actions/setup
if: steps.token.outputs.proceed == 'true'
- name: Build the bundle and the library
- name: Build the bundle, the schema, and the library
if: steps.token.outputs.proceed == 'true'
run: |
bun run build:bundle
bun run build:schema
bun run build:lib
- name: Package this commit under its build tag, prune the window, and point latest at the newest main source
if: steps.token.outputs.proceed == 'true'
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/update-release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,10 @@ jobs:
echo "::error::npm $(npm --version) cannot publish through OIDC; trusted publishing needs npm $floor or newer."
exit 1
fi
# The tarball's files list names the schema beside lib/pkg/.
- name: Build the library
if: steps.oidc.outputs.proceed == 'true'
run: bun run build:lib
run: bun run build:schema && bun run build:lib
# scripts.prepare is dropped from the published manifest: it installs
# lefthook, a devDependency the tarball does not carry, and npm blocks
# install scripts from a provenance-attested package anyway.
Expand Down
13 changes: 5 additions & 8 deletions .github/workflows/update-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# (the topology is in .github/scripts/release-pipeline.ts; release-please-config.json sets `draft` and pins
# `force-tag-creation` off).
#
# resolve the merge commit from the draft's target commitish -> rebuild the bundle, verify the committed schema byte-for-byte
# resolve the merge commit from the draft's target commitish -> build the bundle, the schema, and the library
# package -> find or mint the merge commit's packaged commit under its build tag, mint vX.Y.Z there ONCE, move latest forward
# retag-major -> move vX there (forward only), then upload bundle and schema to the draft (a published release freezes its assets)
# verify-release -> confirm the draft's assets; the managed publish stage then attests and flips the release live
Expand Down Expand Up @@ -68,12 +68,8 @@ jobs:
# new version tag names the build tag by the commit's position; a shallow checkout can do none of that.
fetch-depth: 0
- uses: ./.github/actions/setup
- name: Build the bundle and the schema
run: |
bun run build
# The schema asset and the raw URLs at the tags must serve the bytes committed at the merge commit; a
# divergent regeneration (generator drift, or a compromised generator) stops the release here.
git diff --exit-code lib/settings.schema.json
- name: Build the bundle, the schema, and the library
run: bun run build
# Both paths (mint, or byte-verify on a rerun) leave the worktree bundle equal to the tagged one, which the
# upload below relies on.
- name: Create or verify the version tag on the build commit
Expand Down Expand Up @@ -162,8 +158,9 @@ jobs:
echo "::error::npm $(npm --version) cannot publish through OIDC; trusted publishing needs npm $floor or newer."
exit 1
fi
# The tarball's files list names the schema beside lib/pkg/.
- name: Build the library
run: bun run build:lib
run: bun run build:schema && bun run build:lib
# scripts.prepare is dropped from the published manifest, as in
# update-release-pr.yml's publish-next.
# The verdict holds the built package.json to the tag and reads the
Expand Down
Loading
Loading