From 1e0113c05fbc3d986e4343eadb03bd331ec92b9c Mon Sep 17 00:00:00 2001 From: James Johnson Date: Sun, 18 Oct 2026 21:31:47 +0100 Subject: [PATCH] feat: reusable secret scanning, SAST, dependency and lint workflows --- .forgejo/workflows/deps.yml | 26 +++++++++ .forgejo/workflows/lint.yml | 103 +++++++++++++++++++++++++++++++++ .forgejo/workflows/probe.yml | 11 ---- .forgejo/workflows/sast.yml | 28 +++++++++ .forgejo/workflows/secrets.yml | 34 +++++++++++ README.md | 66 +++++++++++++++++++++ 6 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 .forgejo/workflows/deps.yml create mode 100644 .forgejo/workflows/lint.yml delete mode 100644 .forgejo/workflows/probe.yml create mode 100644 .forgejo/workflows/sast.yml create mode 100644 .forgejo/workflows/secrets.yml create mode 100644 README.md diff --git a/.forgejo/workflows/deps.yml b/.forgejo/workflows/deps.yml new file mode 100644 index 0000000..f68d94d --- /dev/null +++ b/.forgejo/workflows/deps.yml @@ -0,0 +1,26 @@ +# Known-vulnerable dependencies, via osv-scanner against the OSV database. +# +# Reads every lockfile and manifest it recognises (pnpm/npm/yarn, pip/uv/poetry, +# go.mod, NuGet, Cargo and more). Accepted vulnerabilities go in an +# osv-scanner.toml in the calling repo as [[IgnoredVulns]] entries with a +# reason and an ignoreUntil date, so an exception expires instead of +# silently living forever. +on: + workflow_call: + +jobs: + osv-scanner: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: osv-scanner + run: | + osv-scanner --version | head -1 + rc=0 + osv-scanner scan source --recursive . || rc=$? + # 128 means no lockfile or manifest was found: nothing to check, not a failure. + if [ "$rc" -eq 128 ]; then + echo "No supported lockfiles or manifests found; nothing to scan." + exit 0 + fi + exit "$rc" diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml new file mode 100644 index 0000000..bd370a2 --- /dev/null +++ b/.forgejo/workflows/lint.yml @@ -0,0 +1,103 @@ +# Lint gates, each switched on by the files present in the calling repo. +# +# shell scripts shellcheck (*.sh, *.bash) +# Python ruff check (repo's ruff/pyproject config if any) +# Dockerfiles hadolint (repo's .hadolint.yaml if any) +# YAML yamllint (repo's .yamllint if any, else relaxed) +# CI workflows actionlint (.forgejo/ and .github/ workflows) +# OpenTofu/HCL tofu fmt + tflint (repo's .tflint.hcl if any) +# +# Every linter runs even if an earlier one failed, so one push shows every +# problem; the job fails if any of them did. Turn one off with its input. +on: + workflow_call: + inputs: + shellcheck: + type: boolean + default: true + shellcheck-severity: + description: Lowest shellcheck severity that fails the job (error, warning, info, style). + type: string + default: warning + ruff: + type: boolean + default: true + ruff-format: + description: Also require `ruff format --check` to pass. + type: boolean + default: false + hadolint: + type: boolean + default: true + yamllint: + type: boolean + default: true + actionlint: + type: boolean + default: true + terraform: + type: boolean + default: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: shellcheck + if: ${{ !cancelled() && inputs.shellcheck }} + env: + SEVERITY: ${{ inputs.shellcheck-severity }} + run: | + if [ -z "$(git ls-files '*.sh' '*.bash')" ]; then echo "No shell scripts; skipping."; exit 0; fi + git ls-files -z '*.sh' '*.bash' | xargs -0 shellcheck --severity="$SEVERITY" + + - name: ruff + if: ${{ !cancelled() && inputs.ruff }} + env: + FORMAT: ${{ inputs.ruff-format }} + run: | + if [ -z "$(git ls-files '*.py' '*.pyi')" ]; then echo "No Python; skipping."; exit 0; fi + ruff --version + ruff check --output-format=full . + if [ "$FORMAT" = "true" ]; then ruff format --check .; fi + + - name: hadolint + if: ${{ !cancelled() && inputs.hadolint }} + run: | + set -- 'Dockerfile' '*/Dockerfile' 'Dockerfile.*' '*/Dockerfile.*' '*.Dockerfile' 'Containerfile' '*/Containerfile' + if [ -z "$(git ls-files "$@")" ]; then echo "No Dockerfiles; skipping."; exit 0; fi + git ls-files -z "$@" | xargs -0 hadolint --failure-threshold warning + + - name: yamllint + if: ${{ !cancelled() && inputs.yamllint }} + run: | + if [ -z "$(git ls-files '*.yml' '*.yaml')" ]; then echo "No YAML; skipping."; exit 0; fi + cfg="" + for f in .yamllint .yamllint.yml .yamllint.yaml; do if [ -f "$f" ]; then cfg="$f"; fi; done + # Without a repo config: relaxed rules, and no line-length check, + # which mostly flags long URLs and Kubernetes annotations. + if [ -n "$cfg" ]; then set -- -c "$cfg" + else set -- -d '{extends: relaxed, rules: {line-length: disable}}'; fi + git ls-files -z '*.yml' '*.yaml' | xargs -0 yamllint -f parsable "$@" + + - name: actionlint + if: ${{ !cancelled() && inputs.actionlint }} + run: | + set -- '.forgejo/workflows/*.yml' '.forgejo/workflows/*.yaml' '.github/workflows/*.yml' '.github/workflows/*.yaml' + if [ -z "$(git ls-files "$@")" ]; then echo "No workflow files; skipping."; exit 0; fi + # Forgejo needs runs-on on a job that calls a cross-repo reusable + # workflow (the runner expands it, not the server), which GitHub + # forbids. Ignore that one GitHub-only rule. + git ls-files -z "$@" | xargs -0 actionlint -shellcheck= -ignore '"runs-on" is not available' + + - name: OpenTofu fmt and tflint + if: ${{ !cancelled() && inputs.terraform }} + run: | + if [ -z "$(git ls-files '*.tf')" ]; then echo "No .tf files; skipping."; exit 0; fi + rc=0 + tofu fmt -check -recursive -diff || rc=1 + if [ -f .tflint.hcl ]; then tflint --init; fi + tflint --recursive --format compact || rc=1 + exit "$rc" diff --git a/.forgejo/workflows/probe.yml b/.forgejo/workflows/probe.yml deleted file mode 100644 index 1c03601..0000000 --- a/.forgejo/workflows/probe.yml +++ /dev/null @@ -1,11 +0,0 @@ -on: - workflow_call: - inputs: - msg: - type: string - default: hello -jobs: - probe: - runs-on: ubuntu-latest - steps: - - run: echo "reusable probe ran in ${GITHUB_REPOSITORY} msg=${{ inputs.msg }}" diff --git a/.forgejo/workflows/sast.yml b/.forgejo/workflows/sast.yml new file mode 100644 index 0000000..0f46e9c --- /dev/null +++ b/.forgejo/workflows/sast.yml @@ -0,0 +1,28 @@ +# Static analysis with semgrep. +# +# Defaults to the curated p/default ruleset rather than `--config auto`, because +# auto requires sending metrics to semgrep.dev. Paths are excluded with a +# .semgrepignore in the calling repo; a single finding can be suppressed inline +# with a `nosemgrep: ` comment. +on: + workflow_call: + inputs: + config: + description: Space-separated semgrep configs (registry packs or paths in the repo). + type: string + default: p/default + +jobs: + semgrep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: semgrep + env: + CONFIGS: ${{ inputs.config }} + SEMGREP_SEND_METRICS: "off" + run: | + semgrep --version + set -- scan --error --metrics=off --disable-version-check + for c in $CONFIGS; do set -- "$@" --config "$c"; done + semgrep "$@" diff --git a/.forgejo/workflows/secrets.yml b/.forgejo/workflows/secrets.yml new file mode 100644 index 0000000..bf4fd84 --- /dev/null +++ b/.forgejo/workflows/secrets.yml @@ -0,0 +1,34 @@ +# Secret scanning with gitleaks over the repository's full git history. +# +# Full history rather than the pushed range: a push range is unreliable on +# force-pushes and first pushes, and these repos are small enough that a full +# scan takes seconds. A secret that was committed and later deleted is still +# exposed, so history is exactly what needs scanning. +# +# The calling repo controls what counts as a finding: +# .gitleaks.toml extra rules or allowlists, picked up automatically +# .gitleaksignore fingerprints of accepted findings, one per line, as +# printed in the "Fingerprint:" line of this job's output +on: + workflow_call: + inputs: + log-opts: + description: Extra `git log` options to narrow the scan, e.g. a commit range. Empty scans all history. + type: string + default: "" + +jobs: + gitleaks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: gitleaks + env: + LOG_OPTS: ${{ inputs.log-opts }} + run: | + gitleaks version + set -- git --redact --verbose --no-banner --exit-code 1 + if [ -n "$LOG_OPTS" ]; then set -- "$@" --log-opts "$LOG_OPTS"; fi + gitleaks "$@" . diff --git a/README.md b/README.md new file mode 100644 index 0000000..d732b27 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# ci-workflows + +Shared reusable Forgejo Actions workflows for every repo on git.capybara.network. + +| Workflow | Tool | Fails on | +|---|---|---| +| `secrets.yml` | gitleaks, full git history | any secret not listed in `.gitleaksignore` | +| `sast.yml` | semgrep, `p/default` | any finding not suppressed with `nosemgrep` or `.semgrepignore` | +| `deps.yml` | osv-scanner | a known vulnerability in a lockfile or manifest, unless ignored in `osv-scanner.toml` | +| `lint.yml` | shellcheck, ruff, hadolint, yamllint, actionlint, `tofu fmt` + tflint | lint errors; each linter runs only if matching files exist | + +The tools are baked into the runner image (`flux-talos`, +`clusters/talos/apps/forgejo-runner/image/Dockerfile`); these workflows install +nothing at run time. + +## Using them + +Add `.forgejo/workflows/checks.yml` to a repo: + +```yaml +name: checks +on: + push: + branches: [main] + pull_request: + +jobs: + secrets: + runs-on: ubuntu-latest + uses: capyadmin/ci-workflows/.forgejo/workflows/secrets.yml@v1 + sast: + runs-on: ubuntu-latest + uses: capyadmin/ci-workflows/.forgejo/workflows/sast.yml@v1 + deps: + runs-on: ubuntu-latest + uses: capyadmin/ci-workflows/.forgejo/workflows/deps.yml@v1 + lint: + runs-on: ubuntu-latest + uses: capyadmin/ci-workflows/.forgejo/workflows/lint.yml@v1 +``` + +Two Forgejo 11 constraints shape this, both verified on this instance on 18 Oct 2026: + +- **This repo must stay public.** The runner fetches a cross-repo reusable + workflow anonymously; with the repo private the calling job fails. Never put + anything secret here. +- **The calling job needs `runs-on`.** Forgejo 11 expands the reusable workflow + in the runner, not the server, so the caller has to be scheduled onto one. + GitHub forbids this combination; `lint.yml` tells actionlint to ignore it. + +Inputs (all optional) are documented at the top of each workflow, e.g.: + +```yaml + lint: + runs-on: ubuntu-latest + uses: capyadmin/ci-workflows/.forgejo/workflows/lint.yml@v1 + with: + ruff-format: true + shellcheck-severity: error +``` + +## Versioning + +`v1` is a moving tag on the latest compatible commit. A change that could +newly fail existing callers (a stricter default, a new linter switched on by +default) goes out as `v2`.