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
150 changes: 150 additions & 0 deletions .github/workflows/_dotnet.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Reusable .NET / C# gate — INTERNAL. Call it via the `gate.yml` facade
# (`uses: CMaintz/foundry/.github/workflows/gate.yml@v2` with `stack: dotnet`), not directly.
#
# Runs the same six verbs a developer runs locally (`mise run gate`). Toolchain (the
# .NET SDK) comes from the repo's mise.toml (`dotnet = "8.0"`). There is no habit-hooks
# C# sensor, so structural analysis comes from Roslyn analyzers in `typecheck`
# (`dotnet build -warnaserror`); habit-hooks contributes only file-length (+ opt-in jscpd).
#
# Input names are snake_case (kebab parses as a subtraction and fails at startup).
name: dotnet

on:
workflow_call:
inputs:
mise_version:
type: string
default: "2026.9.2"
working_directory:
description: Directory containing mise.toml and the solution/project + .habit-hooks/.
type: string
default: "."
habit_hooks:
description: Run the structural-smell sensors (file-length + opt-in duplication).
type: boolean
default: true
habit_hooks_plugin:
type: string
default: "habit-hooks-generic"

permissions:
contents: read

jobs:
gate:
name: Deterministic gate
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.working_directory }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
with:
version: ${{ inputs.mise_version }}
cache: true
# `mise run gate` (lint -> typecheck -> test -> audit), one verb per step so a
# failure reddens the exact verb and gets a targeted fix. Devs run the composite
# `mise run gate` locally.
- name: 'gate: lint'
id: lint
run: mise run lint
- name: How to fix (lint)
if: failure() && steps.lint.outcome == 'failure'
run: |
{
echo "## ❌ Gate failed at \`lint\` — formatting / analyzers (\`dotnet format --verify-no-changes\`)"
echo ""
echo "**Formatting** is mechanical — run it, commit, push:"
echo '```'
echo "mise run fix # = dotnet format"
echo '```'
echo "Anything \`format\` leaves is an analyzer/style issue — the rule + file:line are in the **gate: lint** step log above."
} >> "$GITHUB_STEP_SUMMARY"
- name: 'gate: typecheck'
id: typecheck
run: mise run typecheck
- name: How to fix (typecheck)
if: failure() && steps.typecheck.outcome == 'failure'
run: |
{
echo "## ❌ Gate failed at \`typecheck\` — build (\`dotnet build -warnaserror\`)"
echo ""
echo "A compile error or a Roslyn analyzer warning (treated as an error). Not auto-fixable — the file:line is in the **gate: typecheck** step log above; reproduce with \`mise run typecheck\`."
} >> "$GITHUB_STEP_SUMMARY"
- name: 'gate: test'
id: test
run: mise run test
- name: How to fix (test)
if: failure() && steps.test.outcome == 'failure'
run: |
{
echo "## ❌ Gate failed at \`test\`"
echo ""
echo "A test failed or coverage fell below the floor. Reproduce with \`mise run test\`; the failing test is in the **gate: test** step log above."
} >> "$GITHUB_STEP_SUMMARY"
- name: 'gate: audit'
id: audit
run: mise run audit
- name: How to fix (audit)
if: failure() && steps.audit.outcome == 'failure'
run: |
{
echo "## ❌ Gate failed at \`audit\` — vulnerable dependency (\`dotnet list package --vulnerable\`)"
echo ""
echo "Bump the flagged package (or add a version override) to a patched release. If it's a false positive or unfixable now, accept it deliberately — never silence the whole check."
} >> "$GITHUB_STEP_SUMMARY"

habits:
name: Structural smells
if: inputs.habit_hooks
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.working_directory }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
- name: Baseline present?
id: baseline
run: |
if [ -f .habit-hooks/snooze.json ]; then
echo "have=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::No .habit-hooks/snooze.json yet — run bootstrap.yml once. Skipping."
echo "have=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5
if: steps.baseline.outputs.have == 'true'
with:
python-version: '3.12'
- name: Install habit-hooks
if: steps.baseline.outputs.have == 'true'
run: pip install --disable-pip-version-check habit-hooks ${{ inputs.habit_hooks_plugin }}
# --branch diffs against `main`; a PR checkout is detached, so point a local `main`
# at the PR base (in history from fetch-depth: 0). habit-hooks errors loudly if the
# base ref is missing — never a silent pass.
- name: Make base ref resolvable for --branch
if: steps.baseline.outputs.have == 'true' && github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: git branch -f main "$BASE_SHA"
- name: Smells (fails only on smells beyond the snooze baseline)
id: smells
if: steps.baseline.outputs.have == 'true'
run: habit-hooks --branch
- name: How to read these smells
if: failure() && steps.smells.outcome == 'failure'
run: |
{
echo "## 📖 Structural smells"
echo ""
echo "For C#, most structural checks are Roslyn analyzers in \`typecheck\`; habit-hooks here flags **oversized-file** (>300 lines) and, if enabled, **duplication** (jscpd)."
echo "| Smell | Fix toward |"
echo "|---|---|"
echo "| \`oversized-file\` | split by concern into cohesive units |"
echo "| \`duplication\` | extract one shared method/type |"
echo ""
echo "Findings are in the **Smells** step log above. Full rationale: \`presets/agent/code-standards.md\`."
} >> "$GITHUB_STEP_SUMMARY"
18 changes: 13 additions & 5 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,19 @@ jobs:
working_directory: ${{ inputs.working_directory }}
mise_version: ${{ inputs.mise_version }}
habit_hooks: ${{ inputs.habit_hooks }}
dotnet:
if: inputs.stack == 'dotnet'
uses: ./.github/workflows/_dotnet.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]
needs: [java, ts, php, dotnet]
runs-on: ubuntu-latest
steps:
- name: Verify the dispatched stack succeeded
Expand All @@ -86,14 +93,15 @@ jobs:
R_JAVA: ${{ needs.java.result }}
R_TS: ${{ needs.ts.result }}
R_PHP: ${{ needs.php.result }}
R_DOTNET: ${{ needs.dotnet.result }}
run: |
set -euo pipefail
echo "stack=$STACK java=$R_JAVA ts=$R_TS php=$R_PHP"
echo "stack=$STACK java=$R_JAVA ts=$R_TS php=$R_PHP dotnet=$R_DOTNET"
case "$STACK" in
java|ts|php) : ;;
*) echo "::error::unknown stack '$STACK' (expected java|ts|php)"; exit 1 ;;
java|ts|php|dotnet) : ;;
*) echo "::error::unknown stack '$STACK' (expected java|ts|php|dotnet)"; exit 1 ;;
esac
for r in "$R_JAVA" "$R_TS" "$R_PHP"; do
for r in "$R_JAVA" "$R_TS" "$R_PHP" "$R_DOTNET"; do
if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
echo "::error::the $STACK gate did not pass"; exit 1
fi
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/).

### Bug Fixes

* **docs:** 'criticals' -> 'critical' in the gate demo ([d4bbe15](https://github.com/CMaintz/foundry/commit/d4bbe15e38fddc615940ae4e3f4be236f4da9533))
* **docs:** 'critical' -> 'critical' in the gate demo ([d4bbe15](https://github.com/CMaintz/foundry/commit/d4bbe15e38fddc615940ae4e3f4be236f4da9533))
* **java preset:** disable jscpd (Node CLI) — keep generic's file-length only ([c9198b2](https://github.com/CMaintz/foundry/commit/c9198b2bc7aa0368c283237203999597a1627a30))

## [Unreleased]
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
gate:
uses: CMaintz/foundry/.github/workflows/gate.yml@v2
with:
stack: java # ts | java | php
stack: java # ts | java | php | dotnet
working_directory: "." # monorepo? call this job once per package
```

Expand All @@ -62,7 +62,7 @@ Pin **these**, whatever the stack. They dispatch internally to the per-stack wor

| 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` |
| [`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/dotnet), `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):
Expand All @@ -74,7 +74,7 @@ Auxiliary reusables you call directly (not behind a facade):
| `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.
> Internals are `_`-prefixed (`_java.yml`, `_ts.yml`, `_php.yml`, `_dotnet.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).

Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ mechanism* (not just an example) → a per-language file (`mise/<lang>.toml`,

| 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`). |
| `gate.yml` | Dispatches by `stack` (ts/java/php/dotnet) 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).
**Internal reusables** (`_`-prefixed — implementation the facades call via nested local `uses:`; not the API): `_ts.yml` · `_java.yml` · `_php.yml` · `_dotnet.yml` (per-stack gates) · `_guards.yml` (secrets + ruleset-guard) · `_semgrep.yml` (SAST).

**Auxiliary reusables** (called directly, not behind a facade):

Expand Down
14 changes: 11 additions & 3 deletions presets/habit-hooks/dotnet.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copy to <repo>/.habit-hooks/config.toml
# There's no habit-hooks .NET sensor yet, so structural smells come from Roslyn
# analyzers in `mise run lint` / the build (with -warnaserror). This preset runs
# only the language-independent `generic` (jscpd) duplication check.
# There's no habit-hooks .NET sensor yet, so structural analysis comes from Roslyn
# analyzers in `typecheck` (`dotnet build -warnaserror`). habit-hooks contributes only
# the language-independent `generic` plugin — file-length (below) and, opt-in, jscpd.
plugins = ["generic"]

# Tests are not smell-scanned (language-agnostic principle — see README).
Expand All @@ -13,3 +13,11 @@ files = ["**/*.cs", "!**/*Tests/**", "!**/*Tests.cs", "!**/*.Test.cs"]
# are already excluded by the `files` globs; keep big prompts in their own resource file.
[sensors.line-count]
args = ["--max", "300"]

# jscpd (the generic plugin's duplication detector) is a Node CLI. Disabled by default so
# the habits job stays Node-free (it runs only the pure-Python line-count). To turn on C#
# duplication detection: install Node + `npm i -g jscpd` in the smells job, copy
# ../jscpd.json to <repo>/.jscpd.json (jscpd ignores the `files` list above), delete this
# block, then re-seed the snooze baseline.
[sensors.jscpd]
disabled = true
3 changes: 2 additions & 1 deletion scripts/foundry-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ case "$STACK" in
ts) PLUGIN="habit-hooks-typescript"; CI="ts.yml"; HH="typescript" ;;
java) PLUGIN="habit-hooks-java"; CI="java.yml"; HH="java" ;;
php) PLUGIN="habit-hooks-php"; CI="php.yml"; HH="php" ;;
kotlin|dotnet|python) PLUGIN=""; CI=""; HH="$STACK" ;; # mise template only; inline gate
dotnet) PLUGIN="habit-hooks-generic"; CI="dotnet.yml"; HH="dotnet" ;; # facade gate, stack=dotnet
kotlin|python) PLUGIN=""; CI=""; HH="$STACK" ;; # mise template only; inline gate
*) echo "unknown stack: $STACK" >&2; exit 2 ;;
esac

Expand Down
Loading