From bc3577fd6a5cf13b5b92f00f3b125bedae145cca Mon Sep 17 00:00:00 2001 From: CMaintz Date: Sun, 27 Sep 2026 00:06:52 +0200 Subject: [PATCH] =?UTF-8?q?feat!:=20facade=20API=20=E2=80=94=20gate.yml/se?= =?UTF-8?q?curity.yml=20front=20the=20per-stack=20internals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumers pin two stable facades instead of per-stack files: gate: uses gate.yml with {stack, working_directory} # -> _java/_ts/_php security: uses security.yml # -> _guards + _semgrep The per-stack + guards + semgrep workflows are _-prefixed to mark them internal; facades reach them via nested local 'uses: ./.github/workflows/_X.yml', which GitHub resolves to foundry at the consumer's pinned ref (no cross-repo pin). Each facade exposes one stable *-ok aggregate check, so required-check names don't churn when a stack is added or an internal renamed. foundry-init emits facade-callers; README/FEATURES/OVERVIEW/DESIGN updated. BREAKING CHANGE: the CI API is now gate.yml + security.yml. Direct references to java.yml/ts.yml/php.yml/tier0.yml/semgrep.yml must move to the facades. @v1 keeps the old names frozen; adopt the facades at @v2. --- .github/workflows/{tier0.yml => _guards.yml} | 4 +- .github/workflows/{java.yml => _java.yml} | 2 +- .github/workflows/{php.yml => _php.yml} | 2 +- .../workflows/{semgrep.yml => _semgrep.yml} | 2 +- .github/workflows/{ts.yml => _ts.yml} | 2 +- .github/workflows/gate.yml | 101 ++++++++++++++++++ .github/workflows/security.yml | 70 ++++++++++++ README.md | 23 ++-- docs/DESIGN.md | 4 +- docs/FEATURES.md | 16 ++- docs/OVERVIEW.md | 23 ++-- scripts/foundry-init.sh | 23 ++-- 12 files changed, 230 insertions(+), 42 deletions(-) rename .github/workflows/{tier0.yml => _guards.yml} (99%) rename .github/workflows/{java.yml => _java.yml} (99%) rename .github/workflows/{php.yml => _php.yml} (99%) rename .github/workflows/{semgrep.yml => _semgrep.yml} (96%) rename .github/workflows/{ts.yml => _ts.yml} (99%) create mode 100644 .github/workflows/gate.yml create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/tier0.yml b/.github/workflows/_guards.yml similarity index 99% rename from .github/workflows/tier0.yml rename to .github/workflows/_guards.yml index a58054e..5a0c8dc 100644 --- a/.github/workflows/tier0.yml +++ b/.github/workflows/_guards.yml @@ -2,10 +2,10 @@ # # jobs: # tier0: -# uses: CMaintz/foundry/.github/workflows/tier0.yml@v1 +# uses: CMaintz/foundry/.github/workflows/_guards.yml@v1 # # Nothing here knows what language the repo is written in. -name: tier0 +name: guards on: workflow_call: diff --git a/.github/workflows/java.yml b/.github/workflows/_java.yml similarity index 99% rename from .github/workflows/java.yml rename to .github/workflows/_java.yml index 8fbbc62..4361687 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/_java.yml @@ -2,7 +2,7 @@ # # jobs: # gate: -# uses: CMaintz/foundry/.github/workflows/java.yml@ +# uses: CMaintz/foundry/.github/workflows/_java.yml@ # with: # spotbugs: true # opt-in bytecode analysis # diff --git a/.github/workflows/php.yml b/.github/workflows/_php.yml similarity index 99% rename from .github/workflows/php.yml rename to .github/workflows/_php.yml index b375ca9..5f83aeb 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/_php.yml @@ -2,7 +2,7 @@ # # jobs: # gate: -# uses: CMaintz/foundry/.github/workflows/php.yml@ +# uses: CMaintz/foundry/.github/workflows/_php.yml@ # # Runs the six verbs a developer runs locally (`mise run gate`). PHP toolchain # comes from the repo's mise.toml; Composer is preinstalled on the runner. diff --git a/.github/workflows/semgrep.yml b/.github/workflows/_semgrep.yml similarity index 96% rename from .github/workflows/semgrep.yml rename to .github/workflows/_semgrep.yml index 916b718..08d9ea3 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/_semgrep.yml @@ -2,7 +2,7 @@ # # jobs: # sast: -# uses: CMaintz/foundry/.github/workflows/semgrep.yml@ +# uses: CMaintz/foundry/.github/workflows/_semgrep.yml@ # # CodeQL is the GitHub-native alternative but is free only on PUBLIC repos; # Semgrep is free and works on private repos too, so it's the portable default. diff --git a/.github/workflows/ts.yml b/.github/workflows/_ts.yml similarity index 99% rename from .github/workflows/ts.yml rename to .github/workflows/_ts.yml index 62e24e8..6dd6a6c 100644 --- a/.github/workflows/ts.yml +++ b/.github/workflows/_ts.yml @@ -2,7 +2,7 @@ # # jobs: # gate: -# uses: CMaintz/foundry/.github/workflows/ts.yml@v1 +# uses: CMaintz/foundry/.github/workflows/_ts.yml@v1 # # Runs the same six verbs a developer runs locally. If this and `mise run gate` # on a laptop ever disagree, that is a bug in the setup, not in the code. diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 0000000..f68d3ed --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,101 @@ +# The public gate FACADE — the one stable entrypoint a consumer pins for the language +# gate + structural smells, whatever the stack. Consumers pin THIS, not the per-stack +# files; the `_`-prefixed internals (_java/_ts/_php) are implementation and may change. +# +# jobs: +# gate: +# uses: CMaintz/foundry/.github/workflows/gate.yml@v2 +# with: { stack: java, working_directory: backend } # monorepo: call once per package +# +# Dispatches by `stack` to the internal per-stack workflow via a nested local `uses:` +# (`./…/_java.yml`), which GitHub resolves to THIS repo at the ref the consumer pinned — +# so there is no cross-repo pin to keep in sync. Require the `gate-ok` check in branch +# protection: its name is stable regardless of which stack ran. +name: gate + +on: + workflow_call: + inputs: + stack: + description: "java | ts | php — selects the per-stack gate." + type: string + required: true + working_directory: + type: string + default: "." + mise_version: + type: string + default: "2026.9.2" + habit_hooks: + type: boolean + default: true + # Java-only (ignored for other stacks): + spotbugs: + type: boolean + default: false + spotbugs_task: + type: string + default: "spotbugsMain" + osv_scanner_version: + type: string + default: "2.5.1" + pmd_version: + type: string + default: "7.27.0" + +permissions: + contents: read + +jobs: + java: + if: inputs.stack == 'java' + uses: ./.github/workflows/_java.yml + with: + working_directory: ${{ inputs.working_directory }} + mise_version: ${{ inputs.mise_version }} + habit_hooks: ${{ inputs.habit_hooks }} + spotbugs: ${{ inputs.spotbugs }} + spotbugs_task: ${{ inputs.spotbugs_task }} + osv_scanner_version: ${{ inputs.osv_scanner_version }} + pmd_version: ${{ inputs.pmd_version }} + ts: + if: inputs.stack == 'ts' + uses: ./.github/workflows/_ts.yml + with: + working_directory: ${{ inputs.working_directory }} + mise_version: ${{ inputs.mise_version }} + habit_hooks: ${{ inputs.habit_hooks }} + php: + if: inputs.stack == 'php' + uses: ./.github/workflows/_php.yml + with: + working_directory: ${{ inputs.working_directory }} + mise_version: ${{ inputs.mise_version }} + habit_hooks: ${{ inputs.habit_hooks }} + + # Stable required check: the dispatched stack passed (skipped stacks are fine), and the + # `stack` value was actually one we handle (a typo must fail, not silently pass green). + gate-ok: + if: always() + needs: [java, ts, php] + runs-on: ubuntu-latest + steps: + - name: Verify the dispatched stack succeeded + env: + STACK: ${{ inputs.stack }} + R_JAVA: ${{ needs.java.result }} + R_TS: ${{ needs.ts.result }} + R_PHP: ${{ needs.php.result }} + run: | + set -euo pipefail + echo "stack=$STACK java=$R_JAVA ts=$R_TS php=$R_PHP" + case "$STACK" in + java|ts|php) : ;; + *) echo "::error::unknown stack '$STACK' (expected java|ts|php)"; exit 1 ;; + esac + for r in "$R_JAVA" "$R_TS" "$R_PHP"; do + if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then + echo "::error::the $STACK gate did not pass"; exit 1 + fi + done + echo "gate ok" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..337d8ba --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,70 @@ +# The public security FACADE — one stable entrypoint for the language-agnostic checks: +# secret scan + ruleset-guard (via internal _guards.yml) and SAST (via internal +# _semgrep.yml). Consumers pin THIS; the `_`-prefixed internals are implementation. +# +# jobs: +# security: +# uses: CMaintz/foundry/.github/workflows/security.yml@v2 +# with: { ruleset_paths: '^(mise\.toml|backend/.habit-hooks/|…)' } +# +# Require the `security-ok` check in branch protection. +name: security + +on: + workflow_call: + inputs: + ruleset_paths: + description: "Extended regex of gate-defining paths (ruleset-guard)." + type: string + default: '^(eslint\.config\.(js|mjs|cjs)|eslint-suppressions\.json|mise\.toml|vite\.config\.ts|\.habit-hooks/|\.jscpd\.json|\.github/workflows/|ruff\.toml|phpstan\.neon|detekt\.yml)' + source_paths: + type: string + default: '^(src|app|lib)/' + test_paths: + type: string + default: '\.(test|spec)\.[jt]sx?$' + ruleset_label: + type: string + default: "ruleset-change" + semgrep_config: + type: string + default: "p/ci" + semgrep_version: + type: string + default: "" + +permissions: + contents: read + +jobs: + guards: + uses: ./.github/workflows/_guards.yml + with: + ruleset_paths: ${{ inputs.ruleset_paths }} + source_paths: ${{ inputs.source_paths }} + test_paths: ${{ inputs.test_paths }} + ruleset_label: ${{ inputs.ruleset_label }} + sast: + uses: ./.github/workflows/_semgrep.yml + with: + config: ${{ inputs.semgrep_config }} + semgrep_version: ${{ inputs.semgrep_version }} + + security-ok: + if: always() + needs: [guards, sast] + runs-on: ubuntu-latest + steps: + - name: Verify the security checks passed + env: + R_GUARDS: ${{ needs.guards.result }} + R_SAST: ${{ needs.sast.result }} + run: | + set -euo pipefail + echo "guards=$R_GUARDS sast=$R_SAST" + for r in "$R_GUARDS" "$R_SAST"; do + if [ "$r" != "success" ] && [ "$r" != "skipped" ]; then + echo "::error::a security check did not pass"; exit 1 + fi + done + echo "security ok" diff --git a/README.md b/README.md index 5a1ebec..fc74b95 100644 --- a/README.md +++ b/README.md @@ -44,27 +44,38 @@ curl -fsSL https://raw.githubusercontent.com/CMaintz/foundry/main/scripts/foundr bash foundry-init.sh java # stacks: ts | java | php | kotlin | dotnet | python ``` -Or wire it by hand — a `mise.toml` with the six verbs (see [templates/](./mise/)) plus a caller workflow per concern: +Or wire it by hand — a `mise.toml` with the six verbs (see [templates/](./mise/)) plus a caller that pins the **facades** and passes your stack: ```yaml # .github/workflows/gate.yml jobs: gate: - uses: CMaintz/foundry/.github/workflows/java.yml@v1 + uses: CMaintz/foundry/.github/workflows/gate.yml@v2 + with: + stack: java # ts | java | php + working_directory: "." # monorepo? call this job once per package ``` -### Reusable workflows +### The public API — two facades + +Pin **these**, whatever the stack. They dispatch internally to the per-stack workflows, so the files you pin never change when a stack is added or an internal is renamed. Require their `*-ok` aggregate checks in branch protection. + +| Facade | What it runs | Key inputs | +|---|---|---| +| [`gate.yml`](./.github/workflows/gate.yml) | the language gate (six verbs, one-per-step with fix summaries) + structural smells; Java adds an opt-in `spotbugs` job | `stack` (ts/java/php), `working_directory`, `spotbugs` | +| [`security.yml`](./.github/workflows/security.yml) | language-agnostic: secret scan + `ruleset-guard` + diff-aware SAST | `ruleset_paths`, `source_paths`, … | + +Auxiliary reusables you call directly (not behind a facade): | Workflow | What it runs | |---|---| -| `ts.yml` · `java.yml` · `php.yml` | the language gate (six verbs, decomposed one-per-step with targeted fix summaries) + structural smells (which print a per-smell "fix toward" legend on failure). `java` adds opt-in `spotbugs` / `no_var` jobs | -| `tier0.yml` | language-agnostic: secret scan + `ruleset-guard` | -| `semgrep.yml` | SAST, diff-aware (only new findings fail) | | `web.yml` | max-file-length gate for HTML/CSS | | `bootstrap.yml` | regenerate the habit-hooks snooze baseline on Linux, open a PR | | `ratchet-report.yml` | PR comment showing how the accepted-debt baselines moved | | `autofix.yml` | add an `autofix` label to a PR → runs `mise run fix`, commits + pushes the result | +> Internals are `_`-prefixed (`_java.yml`, `_ts.yml`, `_php.yml`, `_guards.yml`, `_semgrep.yml`) — the facades' implementation. Don't pin them directly; they can change between minor versions. + Split them across `gate.yml` / `quality.yml` / `security.yml` / `bootstrap.yml` (see [OVERVIEW.md](./docs/OVERVIEW.md) §13). ### Presets diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f3ed0eb..0926c7d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -307,7 +307,7 @@ cadences, which is what justifies the split. C:\Users\akash\Projects\_foundry\ ← leading underscore: sorts above the IDE dirs, │ reads as "not an application" ├── foundry/ → github.com/CMaintz/foundry - │ ├── .github/workflows/ reusable workflow_call: tier0.yml, ts.yml, jvm.yml, php.yml + │ ├── .github/workflows/ reusable workflow_call: guards.yml, ts.yml, jvm.yml, php.yml │ ├── mise/ shared task templates per stack │ ├── presets/ eslint / tsconfig / ruff / phpstan base configs │ ├── habit-hooks/ config.toml presets per stack @@ -323,7 +323,7 @@ C:\Users\akash\Projects\_foundry\ ← leading underscore: sorts above the └── CONTRACT.md ◀── same file, kept in sync ``` -- `foundry` is consumed **by repos**, via `uses: CMaintz/foundry/.github/workflows/ts.yml@v1`. +- `foundry` is consumed **by repos**, via `uses: CMaintz/foundry/.github/workflows/gate.yml@v2`. - `cmaintz-skills` is consumed **by the agent**, via `/plugin marketplace add CMaintz/cmaintz-skills`. **The seam is `CONTRACT.md`** — the verb interface of §3, copied verbatim into both. As long as both diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c4b62d3..08b98fb 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -21,16 +21,24 @@ mechanism* (not just an example) → a per-language file (`mise/.toml`, ## Reusable workflows (`.github/workflows/`) +**Public facades** (the CI API — pin these; they dispatch to the internals and expose one stable `*-ok` check each): + +| Facade | Purpose | +|---|---| +| `gate.yml` | Dispatches by `stack` (ts/java/php) to the per-stack language gate — the six verbs decomposed one-per-step + a structural-smells job (per-smell "what it means / fix toward" legend). Java adds an opt-in `spotbugs` job (no-`var` is folded into `lint`). | +| `security.yml` | Language-agnostic: secret scan (gitleaks) + `ruleset-guard` + diff-aware SAST (semgrep). | + +**Internal reusables** (`_`-prefixed — implementation the facades call via nested local `uses:`; not the API): `_ts.yml` · `_java.yml` · `_php.yml` (per-stack gates) · `_guards.yml` (secrets + ruleset-guard) · `_semgrep.yml` (SAST). + +**Auxiliary reusables** (called directly, not behind a facade): + | Workflow | Purpose | |---|---| -| `ts.yml` · `java.yml` · `php.yml` | Language gate — the six verbs, decomposed one-per-step with targeted failure summaries, + a structural-smells job (which prints a per-smell "what it means / fix toward" legend on failure). `java` adds opt-in `spotbugs` / `no_var`. node_modules / vendor / Gradle caching. | -| `tier0.yml` | Language-agnostic: secret scan (gitleaks) + `ruleset-guard`, both with remediation step-summaries. Merge-base–scoped. | -| `semgrep.yml` | SAST, diff-aware (`--baseline-commit`), pip-cached, pinnable. | | `web.yml` | Max-file-length gate for HTML/CSS. | | `bootstrap.yml` | Regenerate the habit-hooks snooze baseline on Linux (`--prune` to shrink), open a PR. | | `ratchet-report.yml` | PR comment showing how the accepted-debt baselines moved. | | `autofix.yml` | Label a PR `autofix` → runs `mise run fix`, commits + pushes the result. | -| `lint-workflows.yml` | actionlint over foundry's own workflows. | +| `lint-workflows.yml` | actionlint + shellcheck + typos over foundry's own repo (a trigger, not reusable). | ## mise verb templates (`mise/`) diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 881eb2a..cf8bda9 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -90,7 +90,7 @@ Three probabilistic *roles* are kept deliberately separate (people mush them): ## 5. The gate, job by job -The CI gate (`gate.yml`, or foundry's reusable `ts.yml` + `tier0.yml`) is four +The CI gate (`gate.yml`, or foundry's reusable `ts.yml` + `_guards.yml`) is four jobs. CI is **100% deterministic on purpose** — no model, no API key, no secrets beyond the default token. @@ -164,7 +164,7 @@ lets `mise run fix` prune the baseline and ship that in the same PR as the fix. ### What counts as a gate-defining file — and who watches it Governance is split across three enforcement points by *when* they run — a -client-side hook (cmaintz-skills), the CI guard (`tier0.yml`), and the regenerator +client-side hook (cmaintz-skills), the CI guard (`_guards.yml`), and the regenerator (`bootstrap.yml`). This table is the single map of what's protected and how: | File | Controls | Protected by | @@ -173,7 +173,7 @@ client-side hook (cmaintz-skills), the CI guard (`tier0.yml`), and the regenerat | `eslint-suppressions.json` | lint baseline | same three | | `.jscpd.json` | which paths the duplication sensor scans | **ruleset-guard** — widening the ignore list alongside source needs the label | | `pmd/ruleset.xml`, thresholds, `mise.toml`, workflows | rule definitions / gate wiring | **ruleset-guard** — direction can't be proven, so any change alongside source needs the label | -| `ruleset_paths` regex | *which* files ruleset-guard treats as gate-defining (this table's first column) | the `tier0` input default, or a consumer's override — **keep it in sync when you add a guarded file** | +| `ruleset_paths` regex | *which* files ruleset-guard treats as gate-defining (this table's first column) | the `guards` input default, or a consumer's override — **keep it in sync when you add a guarded file** | The recurring failure mode: a *new* gate-defining file (like `.jscpd.json`) isn't added to `ruleset_paths`, so widening it slips past the guard. When you introduce @@ -316,23 +316,22 @@ frontend + a browser extension) surfaced patterns worth codifying: A consumer doesn't put everything in one `ci.yml`. Split by *cadence and blast radius* into separate workflow files, each composing foundry's reusable pieces: -| File | Contains | Trigger | +| File | Calls (facade) | Trigger | |---|---|---| -| `gate.yml` | the deterministic gate(s) — `ts.yml` / language gate per package | PR + push | -| `quality.yml` | structural smells (habit-hooks), ratchet checks | PR + push | -| `security.yml` | `tier0.yml` (secrets + ruleset-guard) + `semgrep.yml` | PR + push | +| `gate.yml` | `gate.yml` facade — `with: { stack, working_directory }`, once per package; includes structural smells | PR + push | +| `security.yml` | `security.yml` facade — secrets + ruleset-guard + SAST in one call | PR + push | | `bootstrap.yml` | `bootstrap.yml` — baseline refresh | `workflow_dispatch` | | `deploy.yml` | image publish / release — **push-to-main only** | push | ```yaml -# .github/workflows/security.yml +# .github/workflows/security.yml — pin the facade; it runs secrets + ruleset-guard + SAST name: security on: { pull_request: {}, push: { branches: [main] } } jobs: - tier0: - uses: CMaintz/foundry/.github/workflows/tier0.yml@ - sast: - uses: CMaintz/foundry/.github/workflows/semgrep.yml@ + security: + uses: CMaintz/foundry/.github/workflows/security.yml@v2 + with: + ruleset_paths: '^(mise\.toml|backend/\.habit-hooks/|frontend/\.habit-hooks/|\.github/workflows/)' ``` Why split, not one file: `needs:` can't cross workflow files, so unrelated jobs diff --git a/scripts/foundry-init.sh b/scripts/foundry-init.sh index 096a3c0..2f0ed3c 100644 --- a/scripts/foundry-init.sh +++ b/scripts/foundry-init.sh @@ -106,8 +106,9 @@ on: { pull_request: {}, push: { branches: [main] } } concurrency: { group: gate-\${{ github.ref }}, cancel-in-progress: true } jobs: gate: - uses: $REPO/.github/workflows/$CI@$REF + uses: $REPO/.github/workflows/gate.yml@$REF # the public facade — pin this, not the per-stack files with: + stack: "$STACK" working_directory: "$WD" YAML else @@ -134,10 +135,8 @@ name: security on: { pull_request: {}, push: { branches: [main] } } concurrency: { group: security-\${{ github.ref }}, cancel-in-progress: true } jobs: - tier0: - uses: $REPO/.github/workflows/tier0.yml@$REF - sast: - uses: $REPO/.github/workflows/semgrep.yml@$REF + security: + uses: $REPO/.github/workflows/security.yml@$REF # facade: secret scan + ruleset-guard + SAST YAML write ".github/workflows/ratchet.yml" < Branches), require these checks: - - Deterministic gate (gate.yml) - - Secret scan, Ruleset guard (security.yml / tier0) - - SAST (security.yml / semgrep) - - Structural smells (once a baseline exists) - Gotchas: required-check names must match the job name EXACTLY; do NOT require - the workflow_dispatch `bootstrap` job; enable "require branches up to date". +4. Branch protection on `main` (Settings -> Branches), require these two checks: + - gate / gate-ok (the language gate + structural smells) + - security / security-ok (secret scan + ruleset-guard + SAST) + The facades expose ONE stable aggregate check each, so required-check names DON'T + change when a stack is added or an internal workflow is renamed. Gotchas: the name + must match EXACTLY (`gate / gate-ok`); do NOT require the `bootstrap` job; enable + "require branches up to date". 5. Commit, open a PR, and confirm the gate is green from a clean tree. NEXT echo "Done."