Shared reusable GitHub Actions workflows for cleancoders repos.
Runs seven scanners. Hard-fail (block the caller): clj-kondo,
shellcheck, gitleaks, actionlint. Advisory by default (report, never
block): clj-watson, semgrep, zizmor — each can be made blocking
per-consumer via the clj-watson-blocking / semgrep-blocking /
zizmor-blocking inputs.
semgrep is the only Clojure detection engine. It carries the 16 cleancoders
cc-* rules in security-rules/semgrep/ and reads .clj, .cljs, and
.cljc.
clj-holmes was removed. It read only .clj — silently skipping .cljs and
.cljc, and rejecting reader conditionals outright — had been unmaintained
since October 2022, and produced three failures in its first week: exiting 3 on
zero findings under -t sarif, writing findings to a file so a red build gave
no reason, and crashing on a progress-bar integer overflow that blocked a
production deploy. Its unique detections are now cc-read-string,
cc-clojure-xml-xxe, cc-weak-crypto, and cc-insecure-tls, which catch 8 of
8 fixture cases against its 6 — it shipped rules for Blowfish and DESede and
matched neither. Rationale: docs/superpowers/specs/2026-07-27-cwe-owasp-coverage-design.md
(Revision 2).
Pin the moving @v1 tag:
name: Security
on:
pull_request: {}
workflow_call: {}
jobs:
security:
uses: cleancoders/github-actions/.github/workflows/security.yml@v1
with:
shellcheck-dir: "./bin" # optional; default "./bin"
# src-paths: "src" # optional; default "src/clj src/cljs src/cljc"
secrets: inherit| input | default | purpose |
|---|---|---|
src-paths |
"src/clj src/cljs src/cljc" |
Space-separated clj-kondo lint targets. Nonexistent paths are filtered out, so the default is safe for repos missing a source root. |
shellcheck-dir |
"./bin" |
shellcheck scandir. The job self-skips when the directory is absent or empty (e.g. library repos with no bin/). |
clj-watson-blocking |
false |
When true, clj-watson dependency-CVE findings fail the workflow. Default false = advisory (reported, never blocks). |
semgrep-blocking |
false |
When true, semgrep findings fail the workflow. Default false = advisory (reported, never blocks). |
zizmor-blocking |
false |
When true, zizmor Actions-security findings fail the workflow. Advisory by default because zizmor's defaults light up existing repos. |
extra-rules-dir |
".security-rules" |
Consumer-supplied semgrep rules, added as an extra --config. Self-skips when the directory is absent. |
rules-ref |
"v1" |
Ref of this repo to source the cc-* rules from. Must match the ref you consume the workflow at — a reusable workflow cannot determine its own ref, so consuming @v2 or a SHA without setting this gets you v1 rules. |
ignored-paths |
"" |
Paths semgrep must skip, e.g. deliberately-vulnerable fixtures. |
Both are optional; the jobs that use them self-skip or degrade when they are unset.
| secret | purpose |
|---|---|
private-git-ssh-key |
Deploy key for cloning private git dependencies while building the classpath. Only clj-kondo and clj-watson use it, and they skip SSH setup when it is unset. |
gh-api-token |
API token for zizmor's online audits, which resolve every repository a workflow references with uses:. Defaults to GITHUB_TOKEN, which reaches only the repository being scanned — so if a workflow calls a private reusable workflow, zizmor cannot resolve it and the job degrades to offline audits with a warning. Supply a token that can read every referenced repository to keep the online audits running. |
Scanner rows below are generated from rule metadata by
bin/gen-coverage-matrix.sh and checked in CI, so the table cannot claim a rule
that no longer exists. The manual-review rows — access control, insecure design,
and everything else no scanner reaches — live in the clojure-security plugin's
class index, because those two halves are maintained in different repos and each
is authoritative for its own.
| rule | class | CWE | OWASP 2025 | blocking |
|---|---|---|---|---|
cc-cljs-eval |
cljs-dom-xss |
94 | A05 | yes |
cc-cljs-innerhtml |
cljs-dom-xss |
79 | A05 | yes |
cc-clojure-xml-xxe |
xxe |
611 | A02 | no (triage) |
cc-dangerously-set-html |
cljs-dom-xss |
79 | A05 | yes |
cc-explain-data-response |
spec-malli-leak |
209 | A10 | yes |
cc-generic-catch |
fail-open |
636, 396 | A10 | no (triage) |
cc-hiccup-raw |
hiccup-injection |
79 | A05 | yes |
cc-insecure-tls |
insecure-tls-verification |
295 | A07 | yes |
cc-load-string |
dynamic-eval |
94 | A05 | yes |
cc-nippy-thaw |
java-deserialization |
502 | A08 | yes |
cc-path-traversal |
path-traversal |
22 | A01 | no (triage) |
cc-read-string |
read-string-rce |
94 | A05 | yes |
cc-shell-exec |
command-injection |
78, 77 | A05 | yes |
cc-snakeyaml-unsafe |
java-deserialization |
502 | A08 | yes |
cc-sql-string-concat |
sql-injection |
89 | A05 | yes |
cc-weak-crypto |
weak-crypto |
327, 328 | A04 | yes |
A coverage table that overstates is worse than none, so:
- No taint analysis anywhere. Every scanned row is pattern matching. Neither semgrep OSS nor clj-holmes tracks dataflow, so a sink reached by an unusual path is missed. The table says "we look for this shape," not "we would catch this bug."
- semgrep cannot resolve namespace aliases. Each rule enumerates the aliases
it expects (
hu/,html/,hiccup.util/, …). An unusual alias is a silent miss.spec-fixtures/exercises more than one alias per sink andbin/test-rules.shfails if any stops matching, so the enumeration is test-guarded rather than aspirational — but it is still enumeration. cc-weak-cryptoandcc-insecure-tlsmatch algorithm names textually (pattern-regex), because semgrep does not bind metavariables inside Clojure string literals. They can therefore fire inside a comment or an unrelated string. Weaker than the structural rules, but the thing being checked is a literal.- OWASP A06 Insecure Design is uncovered. It is a threat-modeling category.
- 10 of 19 applicable CWE Top 25 entries depend on a manual
/security-auditrun — access control above all. CI cannot invoke it. Expected cadence is once per release; nothing enforces that. cc-path-traversal,cc-generic-catch, andcc-clojure-xml-xxedo not block (severity: WARNING). Without dataflow they cannot be precise enough to gate a build —cc-clojure-xml-xxein particular fires on every XML parse because verifying that the factory was hardened requires tracking the object.- Rules track
rules-ref, defaultv1. Consuming another ref without setting it gets v1 rules.
Scans full history and honors a repo-local .gitleaksignore. Generate a baseline
per repo to suppress the pre-existing backlog (accepted risk); newly introduced
secrets fail CI.
Consumers pin @v1, a moving tag pointing at the latest good release. Retag v1
to publish an update to all consumers at once. Third-party actions inside the
workflow are SHA-pinned.
Release policy for libraries published to Clojars: gates a publish on the commit's
CI result, keeps deploy to CI, and tags only after a successful publish. Nothing
in it is specific to any one library — a consumer supplies its own group, artifact
name, and CI workflow as data.
Onboarding a library takes three things: the deps.edn alias below, a
release.yml, and a clojars environment. The alias alone gets you working local
commands but no way to release — the environment is what authorizes one.
A single-artifact library needs no build script — declare what it is as data:
;; deps.edn
:build {:extra-deps {io.github.cleancoders/github-actions
{:git/sha "<full 40-char sha>" :deps/root "clj"}}
:ns-default cleancoders.build.api
:exec-args {:group "com.cleancoders.c3kit"
:lib-name "bucket"
:repo "cleancoders/c3kit-bucket"
:ci-workflow "test.yml"
:license-url "https://github.com/cleancoders/c3kit-bucket/blob/master/LICENSE"}}That gives you clj -T:build clean, pom, jar, install, deploy, and
emergency-publish. tools.build and pomegranate arrive transitively.
Pin a full :git/sha, never the moving v1 tag. v1 moves so the reusable
workflows can be consumed that way; pointing release logic at a moving ref would let
a change here silently alter how four libraries publish.
:exec-args key |
Required | Default |
|---|---|---|
:group |
yes | — |
:lib-name |
yes | — |
:repo |
yes | — |
:ci-workflow |
yes | — |
:license-url |
yes | — |
:version-file |
no | VERSION |
:emergency-var |
no | EMERGENCY_RELEASE |
Missing or blank required keys abort before anything is built. So does an unrecognized key, so a typo in an optional one is loud rather than silently ignored.
Copy this into the consumer as .github/workflows/release.yml, changing only the
:ci-workflow filename in the actions: read comment and the setup-clojure pin
if that repo's CI already uses a different one. Keep the pins SHA-locked with a
trailing version comment.
name: Release
# Authorization comes from the `clojars` environment, not from this file.
# workflow_dispatch cannot be restricted by permission level, so anyone with
# write access can press Run workflow; the environment's required reviewers
# decide whether it proceeds, and its master-only deployment branch policy means
# a modified copy of this file on another ref cannot reach the secrets. Do not
# add an actor allowlist here -- a gate in a versioned file can be edited by
# anyone who can merge to master, and would read as protection while providing
# none.
on: workflow_dispatch
permissions:
contents: write # push the release tag
actions: read # verify-ci! reads the CI workflow's run history
jobs:
release:
runs-on: ubuntu-latest
environment: clojars
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0 # assert-untagged! and tag! need tag history
- name: Set up JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
java-version: 21
distribution: 'temurin'
- name: Install Clojure CLI
uses: DeLaGuardo/setup-clojure@3fe9b3ae632c6758d0b7757b0838606ef4287b08 # 13.4
with:
cli: 'latest'
- name: Build and publish
# Use `clojure`, not `clj` -- `clj` wraps rlwrap, which GitHub runners
# don't have installed, and fails with "Please install rlwrap for
# command editing or use \"clojure\" instead."
run: clojure -T:build deploy
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CLOJARS_USERNAME: ${{ secrets.CLOJARS_USERNAME }}
CLOJARS_PASSWORD: ${{ secrets.CLOJARS_PASSWORD }}Four details in there are load-bearing, not incidental:
fetch-depth: 0— the default shallow clone has no tags, soassert-untagged!would see none andtag!would push into a history it cannot see.clojure, notclj— thecljwrapper needsrlwrap, which GitHub runners lack.clj -T:build deployfails there withPlease install rlwrap for command editing or use "clojure" instead.and exit 1.environment: clojarsat job level — this is what makes the approval gate cover every step. The Clojars secrets are scoped to that environment, so no other workflow in the repo can read them.- No actor allowlist.
workflow_dispatchcannot be restricted by permission level, so anyone with write access can press Run workflow. The environment decides whether it proceeds. Anif: github.actor == …here would read as protection while providing none, because whoever can merge to master can edit it.
The workflow is inert without this — it is where release authority actually lives.
It needs three things, which you can set up in the repo's
Settings → Environments → New environment, named clojars:
| Setting | Why |
|---|---|
| Required reviewers | Who may authorize a release. This is the actual access-control decision; the workflow file cannot make it. |
| Deployment branch policy, limited to your release branch | A modified copy of release.yml on another ref cannot reach the secrets. |
Secrets CLOJARS_USERNAME and CLOJARS_PASSWORD, added to the environment |
Scoped to this environment, so no other workflow in the repo can read them. |
Use a Clojars deploy token scoped to the artifact, generated at https://clojars.org/tokens — not an account password.
Two properties are worth checking rather than assuming, because getting either wrong silently removes the gate:
REPO=<owner>/<repo>
# Secrets must be on the environment, not the repo. Repo-level secrets are
# readable by every workflow, which defeats the whole arrangement.
gh api /repos/$REPO/environments/clojars/secrets --jq '.secrets[].name'
gh secret list --repo $REPO # must NOT list the CLOJARS_* names
# The branch policy must be present and limited to your release branch.
gh api /repos/$REPO/environments/clojars/deployment-branch-policies --jq '.branch_policies[].name'If you would rather script the setup than click through Settings, the same
configuration goes through gh api -X PUT /repos/$REPO/environments/clojars with a
reviewers array of {"type": "User", "id": N} entries; resolve a login to its id
with gh api /users/<login> --jq .id.
One choice to make deliberately: GitHub's prevent_self_review decides whether the
person who dispatched a release may also approve it. Leaving it off gives one-click
releases at the cost of a single account being able to complete one alone; turning it
on requires a second person for every release.
- Open a PR bumping the version file and
CHANGES.md. - Merge to
masterand wait for CI to go green. Because the version bump is part of the merged commit, the commit CI validated is the commit that gets released. - Actions → Release → Run workflow.
- Approve the
clojarsdeployment when prompted.
The job verifies CI succeeded for that exact commit, refuses a version that is already tagged, builds, publishes, and only then pushes the tag — so a failed publish leaves no tag. The current version in each repo is already tagged, so the first release from a newly onboarded library must bump the version file.
Publishing more than one artifact, or needing a non-default basis, means writing
your own build script and pointing :ns-default at it, consuming
cleancoders.build.jar and cleancoders.build.release as ordinary libraries.
c3kit-wire does exactly this: it ships two jars whose source sets and bases
differ.
This is the supported alternative, not a workaround — which is why
cleancoders.build.api stays small. The answer to a requirement it does not
express is a local build script, not another config key.
A local build script gets the same gates api uses, by calling
cleancoders.build.release directly with its own jar and publish logic:
| entry point | gates, in order |
|---|---|
(deploy! {:repo :ci-workflow :version :jar! :publish!}) |
assert-ci! → verify-ci! → assert-untagged! → jar! → publish! → tag! |
(emergency-deploy! {:version :jar! :publish! :emergency-var}) |
break glass; skips verify-ci!; requires the break-glass variable to name the exact version |
:jar! and :publish! are zero-arg thunks. That is how a consumer with two
artifacts reuses every gate: one call to deploy!, whose :publish! thunk
deploys both jars, so the gates run once for the release as a whole and release
never learns how a jar gets built.
verify-ci! asks gh for the newest run of the named CI workflow at the
current commit and requires completed + success. It is scoped to a named
workflow rather than the commit's check-runs on purpose: the release run
registers its own check-run against that same commit, so an all-check-runs-green
query would observe itself as in_progress and deadlock every release. It
needs actions: read and a GH_TOKEN in the environment it runs in.
c3kit-wire is the live example of a consumer using this escape hatch.