Skip to content
Open
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/workflows/tier0.yml → .github/workflows/_guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/java.yml → .github/workflows/_java.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# jobs:
# gate:
# uses: CMaintz/foundry/.github/workflows/java.yml@<sha>
# uses: CMaintz/foundry/.github/workflows/_java.yml@<sha>
# with:
# spotbugs: true # opt-in bytecode analysis
#
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/php.yml → .github/workflows/_php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# jobs:
# gate:
# uses: CMaintz/foundry/.github/workflows/php.yml@<sha>
# uses: CMaintz/foundry/.github/workflows/_php.yml@<sha>
#
# 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# jobs:
# sast:
# uses: CMaintz/foundry/.github/workflows/semgrep.yml@<sha>
# uses: CMaintz/foundry/.github/workflows/_semgrep.yml@<sha>
#
# 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.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ts.yml → .github/workflows/_ts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 101 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
@@ -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"
70 changes: 70 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -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"
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 12 additions & 4 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,24 @@ mechanism* (not just an example) → a per-language file (`mise/<lang>.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/`)

Expand Down
23 changes: 11 additions & 12 deletions docs/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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@<sha>
sast:
uses: CMaintz/foundry/.github/workflows/semgrep.yml@<sha>
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
Expand Down
Loading
Loading