Shared reusable GitHub Actions workflows for Peaceful Studio repos.
github-actions is currently developed and maintained by Peaceful Studio
OÜ (Estonia, VAT EE102232996). The project is licensed under Apache-2.0
with the explicit intent of community ownership: if and when adoption
warrants neutral governance, Peaceful Studio commits to transferring this
repository to a community-led organisation under the same license terms.
Contributions welcome from anywhere in the GitHub Actions / CI ecosystem;
no CLA required.
Lints every workflow file in .github/workflows/ with
actionlint. Catches YAML schema
errors, shell-quoting bugs, undefined contexts, and other static issues
before they ship.
Inputs:
| Input | Default | Description |
|---|---|---|
runs-on |
(by visibility) | Runner label(s) for the job. A plain label (ubuntu-latest, hetzner) or a JSON array string ('["self-hosted", "hetzner"]') to require multiple labels. Honoured only on workflow_call. When empty, defaults by repo visibility (public → ubuntu-latest; private/internal → ["self-hosted","hetzner"]). |
Required secrets: none.
Required permissions: none.
Consumer .github/workflows/lint.yaml:
name: Lint workflows
on:
push:
branches: [dev]
paths: ['.github/workflows/**']
pull_request:
branches: [dev]
paths: ['.github/workflows/**']
jobs:
lint:
uses: peacefulstudio/github-actions/.github/workflows/build-and-test.yaml@v1Discovers every go.mod under the configured paths and, for each module, runs:
go build ./...go test -race -covermode=atomic -coverprofile=...- Cobertura conversion + a markdown coverage report (posted as a sticky PR comment, also written to the job summary)
- Cyclomatic-complexity augmentation (per-package average + top-10 production functions, annotated with their coverage)
golangci-lint run ./...
If no modules are discovered the build/test/lint steps are skipped, so the workflow stays green on repos that haven't added Go code yet.
Inputs:
| Input | Default | Description |
|---|---|---|
go-version |
stable |
Passed to actions/setup-go. |
module-paths |
go cli |
Space-separated directories to search for go.mod. Missing directories are silently ignored. |
golangci-lint-version |
latest |
Version selector for go install. Tags starting with v2 install from github.com/golangci/golangci-lint/v2/cmd/golangci-lint (needed for v2 config files); anything else uses the v1 module path. Pin a tag (e.g. v1.61.0 or v2.12.2) for reproducible runs. |
runs-on |
(by visibility) | Runner label(s) for all jobs. A plain label (ubuntu-latest, hetzner) or a JSON array string ('["self-hosted", "hetzner"]') to require multiple labels. When empty, defaults by repo visibility (public → ubuntu-latest; private/internal → ["self-hosted","hetzner"]). |
Required secrets: none. PR comments use the default GITHUB_TOKEN.
Required permissions: declared per-job inside the workflow (contents: read, plus pull-requests: write for the sticky coverage comment) — no caller-side setup needed.
Consumer .github/workflows/go-ci.yaml:
name: Go CI
on:
push:
branches: [dev]
paths:
- 'go/**'
- 'cli/**'
- '.github/workflows/go-ci.yaml'
pull_request:
branches: [dev]
paths:
- 'go/**'
- 'cli/**'
- '.github/workflows/go-ci.yaml'
jobs:
go-ci:
uses: peacefulstudio/github-actions/.github/workflows/go-ci.yaml@v1To pin Go and lint versions, or to point at a non-default module layout:
jobs:
go-ci:
uses: peacefulstudio/github-actions/.github/workflows/go-ci.yaml@v1
with:
go-version: '1.26'
module-paths: 'services tools'
golangci-lint-version: 'v1.61.0'Runs dotnet restore, dotnet build, dotnet test (with coverage), and
optionally dotnet pack against the workspace at working-directory.
Produces a Cobertura coverage report, a markdown summary, and a sticky
PR comment with per-project coverage. Uploads .nupkg artifacts when
pack: true.
A configurable matrix runs build + test across one or several runners. The
simple os-list form takes a JSON array of runner labels (Linux / macOS /
Windows) and runs coverage on the ubuntu-latest shard. For finer control —
mixing self-hosted and hosted runners, array-valued runner labels, or
choosing which shard carries coverage — use build-matrix instead (see
Selecting runners).
Inputs (selected — see the workflow file for the full set):
| Input | Default | Description |
|---|---|---|
dotnet-version |
(empty) | Explicit SDK override for actions/setup-dotnet. Empty resolves the SDK from the caller's global.json under working-directory (the file must exist). |
working-directory |
. |
Path of the .NET workspace. |
os-list |
(by visibility) | JSON array of runner labels for the build-and-test matrix. Ignored when build-matrix is set. Empty + empty build-matrix → matrix by repo visibility (public → ubuntu/windows/macos; private/internal → Hetzner). |
build-matrix |
(empty) | JSON array of { name, runner, coverage } shards. Overrides os-list. See Selecting runners. |
matrix-mode |
(empty → CI_MATRIX_MODE) |
on private/internal repos, cheap collapses the matrix to a single free self-hosted Hetzner shard, overriding os-list / build-matrix (ignored with a warning on public repos); full forces the normal matrix; empty defers to the org/repo variable CI_MATRIX_MODE. See Cheap matrix mode. |
test-project |
(empty) | Specific test project / solution path; empty runs the workspace default .sln / .slnx. |
test-filter |
(empty) | Forwarded to dotnet test --filter. |
coverage-pr-comment-header |
csharp-coverage |
Sticky-comment header (make unique per workspace if a repo calls this workflow more than once). |
pack |
false |
When true, also runs dotnet pack and uploads .nupkg artifacts. |
pack-project |
(empty) | Project to pack. Required when pack: true. |
Optional secrets:
BOT_GITHUB_TOKEN— PAT or GitHub App token withread:packagesfordotnet restoreagainst private NuGet feeds (e.g. GitHub Packages). Omit for public-only restores. Pass viasecrets: inheritor an explicitsecrets:block on the caller.
Consumer .github/workflows/csharp-ci.yaml:
name: CSharp CI
on:
push:
branches: [dev]
pull_request:
branches: [dev]
jobs:
csharp-ci:
uses: peacefulstudio/github-actions/.github/workflows/csharp-ci.yaml@v1
secrets: inheritThis workflow runs tests end-to-end on xUnit v3 + Microsoft.Testing.Platform (MTP). Callers still on xUnit v2 + coverlet cannot pin to this workflow — stay on a previous SHA / tag until you've migrated the items below.
-
global.jsonatworking-directorypinning the .NET SDK version — required unless the caller passes an explicitdotnet-versioninput. Bumping to a new SDK line is then a caller-sideglobal.jsonchange; no release of this workflow is needed. -
Directory.Packages.propspinning:xunit.v3—3.2.2Microsoft.Testing.Extensions.CodeCoverage— required: the workflow resolves thedotnet-coveragemerge-tool version from this pin (scanning everyDirectory.Packages.propsunderworking-directory, nested files included), so the two are aligned automatically. A missing pin, an MSBuild-property version, or conflicting versions across files fails the coverage shard loud — like a missingglobal.json. The version must be a literal (e.g.18.8.0). Seecanton-ledger-api-csharp#79for the MTP 1.x / 2.x compatibility rationale: do not bumpCodeCoveragepast 18.0.x untilxunit.v3ships an MTP 2.x build — newer 19.x lines have produced empty Cobertura output in this pipeline.
-
tests/Directory.Build.props(or equivalent) must enable MTP:<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner> <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport> <TestingPlatformCommandLineArguments>--coverage --coverage-output-format cobertura --settings $(MSBuildThisFileDirectory)../coverage.settings.xml</TestingPlatformCommandLineArguments>
The exact arguments live in the caller — what matters is that MTP runs in-process and emits
*.cobertura.xmlfiles somewhere undertests/. -
Multi-TFM test projects must set a per-TFM Cobertura filename to avoid silent overwrites between target frameworks:
<TestingPlatformCommandLineArguments>... --coverage-output $(MSBuildProjectName)_$(TargetFramework).cobertura.xml</TestingPlatformCommandLineArguments>
Single-TFM projects can omit this.
-
coverage.settings.xmlat the repo root (or whereverTestingPlatformCommandLineArgumentspoints), tuning the coverage collector. Prefer exclude-only<ModulePaths>(e.g. exclude test assemblies, third-party DLLs) rather than an include-list — an include-only<ModulePaths>silently drops any new production assembly that isn't listed and produces drifting coverage numbers with no error. -
Coverlet removed from every test
.csproj— MTP'sMicrosoft.Testing.Extensions.CodeCoveragereplaces it. Mixing the two produces duplicate or empty Cobertura reports.
Runs sbt clean coverage test coverageReport against the workspace at
working-directory, producing a Cobertura coverage report via
sbt-scoverage. Posts a
sticky PR comment + job summary with the rendered coverage table and
uploads the raw Cobertura XML as a build artifact.
A configurable matrix runs build + test across one or several runners. The
simple os-list form takes a JSON array of runner labels and runs coverage,
the sticky PR comment, the job summary, and the artifact upload on the
ubuntu-latest shard. For finer control — mixing self-hosted and hosted
runners, array-valued runner labels, or choosing which shard carries
coverage — use build-matrix instead (see
Selecting runners).
Inputs:
| Input | Default | Description |
|---|---|---|
working-directory |
. |
Path of the sbt/Scala workspace. Also used as the prefix for cobertura-path and when hashing sbt files for the cache key. |
java-version |
21 |
Passed to actions/setup-java. |
java-distribution |
temurin |
Passed to actions/setup-java. |
os-list |
(by visibility) | JSON array of runner labels for the build-and-test matrix. Ignored when build-matrix is set. Empty + empty build-matrix → matrix by repo visibility (public → ubuntu/windows/macos; private/internal → Hetzner). |
build-matrix |
(empty) | JSON array of { name, runner, coverage } shards. Overrides os-list. See Selecting runners. |
matrix-mode |
(empty → CI_MATRIX_MODE) |
on private/internal repos, cheap collapses the matrix to a single free self-hosted Hetzner shard, overriding os-list / build-matrix (ignored with a warning on public repos); full forces the normal matrix; empty defers to the org/repo variable CI_MATRIX_MODE. See Cheap matrix mode. |
cobertura-path |
target/scala-2.13/coverage-report/cobertura.xml |
Path (relative to working-directory) of the Cobertura XML emitted by sbt coverageReport. Override for non-2.13 Scala majors. |
coverage-pr-comment-header |
scala-coverage |
Hidden HTML-comment dedup key for the sticky PR comment. Make unique per language / per repo if multiple coverage comments coexist. |
coverage-artifact-name |
scala-coverage |
Name of the uploaded Cobertura artifact. Must not collide with other coverage artifacts uploaded by sibling jobs in the same run. |
coverage-title |
Scala coverage |
Rendered as a markdown H2 prepended to the coverage report so readers can tell the language at a glance. |
sbt-command |
-batch -no-colors 'clean; coverage; test; coverageReport' |
Arguments passed to sbt for the build/test/coverage step. Run via eval, so callers must be trusted (workflow file, not user input). |
Required secrets: none. PR comments use the default GITHUB_TOKEN.
Required permissions: declared per-job inside the workflow (contents: read, pull-requests: write for the sticky coverage comment) — no caller-side setup needed.
Consumer .github/workflows/scala-ci.yaml:
name: Scala CI
on:
push:
branches: [dev]
pull_request:
branches: [dev]
jobs:
scala-ci:
uses: peacefulstudio/github-actions/.github/workflows/scala-ci.yaml@v1
with:
working-directory: jvm-helper-
sbt project rooted at
working-directory—build.sbt,project/build.properties, andproject/plugins.sbtare expected at that path (they're hashed into the sbt cache key). -
sbt-scoverageplugin declared in<working-directory>/project/plugins.sbt. Without it,sbt coverageandsbt coverageReportfail at task-lookup and the workflow exits non-zero before any Cobertura file is produced. -
Cobertura XML must land at
<working-directory>/target/scala-<scala-major>/coverage-report/cobertura.xmlaftersbt coverageReport— this is the path the workflow stages, summarises, and uploads. The defaultcobertura-pathinput targetsscala-2.13; Scala 3 and other-major callers MUST overridecobertura-path(e.g.target/scala-3/coverage-report/cobertura.xml), otherwise the staging step fails with a missing-file error. -
JDK compatible with the project's Scala / sbt versions. Defaults to Temurin 21 — override
java-versionandjava-distributionfor projects pinned to an older or alternative JDK. -
sbt-commandiseval'd in the runner shell, so the value MUST come from a trusted source (the caller's workflow file). Do not wire this input to webhook orworkflow_dispatchpayloads.
Runs terraform fmt -check -recursive, then discovers Terraform modules
under the configured workspace and runs terraform init -backend=false
terraform validateagainst each. If any.tftest.hclfiles exist, it also runsterraform test -verbosefrom the appropriate module root(s) and posts a sticky PR comment + job summary with the last 60 lines of the test output.
Module discovery (in order):
- Explicit list via
module-paths(relative toworking-directory). - Every direct subdirectory of
modules/if that directory exists. - Every directory (up to depth 3) containing
main.tf,versions.tf, orterraform.tf, excludingtests/,examples/, and.terraform/. The fallback is best-effort — setmodule-pathsexplicitly on any non-trivial layout.
Test discovery: every directory that contains a .tftest.hcl file
(excluding .terraform/). When tests live anywhere under a tests/
subdirectory (Terraform 1.6+ supports nested test directories), the
workflow runs terraform test from the parent module so the module's
configuration is loaded — matching the Terraform CLI's convention.
Runner requirement. Discovery uses
find -printf, which is GNU-only. On GitHub-hostedubuntu-latest(the public-repo default) this is fine. Private and internal repos default to the self-hosted Hetzner pool — those runners must have GNU findutils installed, or pinruns-on: ubuntu-latest. On anymacos-*or self-hosted runner without GNU findutils, module/test discovery silently returns empty and the job goes green without validating anything.
Inputs:
| Input | Default | Description |
|---|---|---|
terraform-version |
~> 1.9 |
Version constraint passed to hashicorp/setup-terraform. |
working-directory |
terraform |
Path (relative to the repo root) where the Terraform workspace lives. Use . for repos with .tf files at the root. |
module-paths |
(empty) | Optional space-separated list of module directories (relative to working-directory). Missing entries emit a ::warning:: and are skipped. |
pr-comment-header |
terraform-tests |
Sticky-comment header. Set per-workspace if a repo calls this workflow more than once on the same PR. |
runs-on |
(by visibility) | Runner label(s) for the job. A plain label (ubuntu-latest, hetzner) or a JSON array string ('["self-hosted", "hetzner"]') to require multiple labels. When empty, defaults by repo visibility (public → ubuntu-latest; private/internal → ["self-hosted","hetzner"]). |
Required secrets: none. PR comments use the default GITHUB_TOKEN.
Required permissions: declared inside the workflow (contents: read, pull-requests: write for the sticky test-result comment) — no caller-side setup needed.
Consumer .github/workflows/terraform-ci.yaml for the default terraform/-subdir layout:
name: Terraform CI
on:
push:
branches: [dev]
paths:
- 'terraform/**'
- '.github/workflows/terraform-ci.yaml'
pull_request:
branches: [dev]
paths:
- 'terraform/**'
- '.github/workflows/terraform-ci.yaml'
jobs:
terraform-ci:
uses: peacefulstudio/github-actions/.github/workflows/terraform-ci.yaml@v1For a repo that keeps Terraform at the root with explicit module paths:
jobs:
terraform-ci:
uses: peacefulstudio/github-actions/.github/workflows/terraform-ci.yaml@v1
with:
terraform-version: '1.14.0'
working-directory: '.'
module-paths: 'deployments/deployment/internal deployments/deployment/customer modules/canton-node modules/postgres'Calculates the package version (release tag, version_override, or a
branch-derived pre-release), then builds, tests, packs, uploads the
.nupkg/.snupkg as artifacts, and pushes every package to nuget.org.
The workflow publishes to nuget.org with NuGet Trusted Publishing: it exchanges a short-lived GitHub OIDC token for a temporary nuget.org API key at run time. There are no long-lived API-key secrets. Each consumer must:
- Set an organization secret
NUGET_USER— your nuget.org profile name — and pass it down withsecrets: inherit(the reusable workflow readssecrets.NUGET_USER, so withoutinheritthe login runs with an empty user). - Register a nuget.org Trusted Publishing policy with:
- Repository Owner = your GitHub organization.
- Repository = your repository.
- Workflow File =
csharp-publish-public.yaml— the reusable file in this repo, not your caller workflow. The OIDC token is minted inside the reusable workflow, so nuget.org matches the reusable file's name. - Environment =
nuget-publish.
- Call the workflow with
permissions: id-token: write. Reusable-workflow permissions are capped by the caller, so the caller must grant this:
jobs:
publish:
permissions:
contents: read
id-token: write
uses: peacefulstudio/github-actions/.github/workflows/csharp-publish-public.yaml@v1
secrets: inheritWrites shields.io endpoint JSON
to an orphan badges branch of the caller repo using the built-in
GITHUB_TOKEN — no gist and no PAT. A single post-CI writer renders every
badge file in one commit, so a README can show live coverage and per-platform
CI badges from that branch's raw URLs. Opt-in and public-repo-oriented.
csharp-ci.yaml / scala-ci.yaml expose a coverage output (integer
percentage from the coverage shard, empty when no shard sets coverage: true)
and a matrix-status output (per-shard {name, os, arch, passed} results) to
feed it. Wire them together in the consumer, gating on main and granting
contents: write:
jobs:
csharp-ci:
uses: peacefulstudio/github-actions/.github/workflows/csharp-ci.yaml@v2
secrets: inherit
badges:
needs: csharp-ci
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
contents: write
uses: peacefulstudio/github-actions/.github/workflows/update-badges.yaml@v2
with:
coverage-data: >-
[{"slug":"csharp","label":"coverage","percent":"${{ needs.csharp-ci.outputs.coverage }}"}]
matrix-data: ${{ needs.csharp-ci.outputs.matrix-status }}Inputs (all optional):
coverage-data— JSON array of{slug, label, percent}; writes onecoverage-<slug>.jsonper entry (entries with an empty/nullpercentare skipped). Default'[]'writes no coverage badge.matrix-data— JSON array of{lang, os, arch, passed}; writes oneci-<lang>-<os>-<arch>.jsonper entry. Tag eachmatrix-statusentry with alangfirst (so one badge branch can hold several languages). Default'[]'.badge-branch— orphan branch to write to (defaultbadges).
Then reference a badge in the README (substitute owner/repo and the file the writer produced):
By default the runner is chosen from the built repo's visibility: public
repos run on GitHub-hosted runners (free minutes) and private / internal repos
run on the self-hosted Hetzner pool (labels ["self-hosted", "hetzner"]). For
csharp-ci / scala-ci the public default is a three-shard matrix
(ubuntu-latest + windows-latest + macos-latest, coverage on ubuntu) and
the private default is a single Hetzner shard; for go-ci, terraform-ci, and
build-and-test the public default is ubuntu-latest and the private default
is ["self-hosted", "hetzner"]. Detection is a gh api visibility lookup; if
it fails or returns an unexpected value the run aborts loudly rather than
guessing a runner.
To override the default, pass runs-on (or, for csharp-ci / scala-ci,
os-list / build-matrix) — any explicit value wins. A private repo with no
Hetzner runner should pin runs-on: ubuntu-latest (or os-list: '["ubuntu-latest"]').
go-ci, terraform-ci, and build-and-test expose a single runs-on
input that accepts either a plain label or a JSON array string:
jobs:
terraform-ci:
uses: peacefulstudio/github-actions/.github/workflows/terraform-ci.yaml@v1
with:
runs-on: '["self-hosted", "hetzner"]' # array string → all labels required
# runs-on: hetzner # or a single labelcsharp-ci and scala-ci take a richer build-matrix for cross-platform
builds that mix self-hosted and hosted runners. It is a JSON array of
shards, each { name, runner, coverage }:
jobs:
csharp-ci:
uses: peacefulstudio/github-actions/.github/workflows/csharp-ci.yaml@v1
with:
build-matrix: >-
[
{"name": "linux-amd64", "runner": ["self-hosted", "hetzner"], "coverage": true},
{"name": "linux-arm64", "runner": "ubuntu-24.04-arm"},
{"name": "windows-amd64", "runner": "windows-latest"}
]name— the shard label shown in thebuild-and-test (<name>)job title.runner— passed verbatim toruns-on; either a plain label string or a JSON array of labels (a real array, e.g.["self-hosted", "hetzner"]— not a quoted string).coverage— optional, defaults tofalse. At most one shard may setcoverage: true; that shard produces the coverage report, sticky PR comment, job summary (and, forscala-ci, the Cobertura artifact). More than one is rejected to avoid racing the coverage report and duplicating the comment. Setting it on no shard is allowed — the run then produces no coverage report.
build-matrix fully replaces os-list when set. Omit it (or leave it
empty) to keep the os-list behaviour: each label becomes a shard, and the
ubuntu-latest shard — if present — carries coverage.
csharp-ci and scala-ci accept a matrix-mode input that routes paid
GitHub-hosted matrix legs onto idle free self-hosted runners — handy during a
refactor when CI runs constantly:
cheap— collapse the whole matrix to a single shard on the free self-hosted Hetzner pool (["self-hosted", "hetzner"], coverage on), ignoringos-listandbuild-matrix. Private/internal repositories only — see the security note below.full— force the normal matrix (visibility default,os-list, orbuild-matrix).- (empty, the default) — defer to the org/repo variable
CI_MATRIX_MODE.
Security:
cheapis ignored on public repositories. Self-hosted runners must never execute untrusted public- or fork-PR workloads, so on a public repocheapis dropped (with a warning) and the normal GitHub-hosted matrix runs instead. Scope theCI_MATRIX_MODEorg variable to Private repositories to match this guarantee.
Set CI_MATRIX_MODE as an org- or repo-level Actions variable (Settings →
Secrets and variables → Actions → Variables) to flip every consumer at once:
CI_MATRIX_MODE = cheap # all callers collapse to the Hetzner leg
CI_MATRIX_MODE = full # all callers run the normal matrix
# unset # callers run the normal matrix
Precedence is input matrix-mode > variable CI_MATRIX_MODE > normal
matrix, so a single caller can opt out with matrix-mode: full even while
the org variable is cheap:
jobs:
csharp-ci:
uses: peacefulstudio/github-actions/.github/workflows/csharp-ci.yaml@v2
with:
matrix-mode: fullPin to a major version tag (@v1) for stability. Float to the major tag
to pick up non-breaking updates automatically; pin to a SHA if you need
strict immutability.
This repo is public, so any other public repo can call its reusable workflows directly. For private consumer repos in any GitHub org, confirm this repo's access policy in Settings → Actions → General → Access → "Accessible from repositories in the organization" on the consumer side.
- Breaking changes bump the major tag (
v1→v2). - Non-breaking changes move the existing major tag forward.
- Full version tags (
v1.2.0) are available for strict pinning.
Contributions are welcome from anywhere in the GitHub Actions / CI ecosystem. See CONTRIBUTING.md for the dev setup, the integration-test-against-a-real-consumer requirement, the backwards-compatibility contract, and the release process. The per-PR checklist itself lives in the PR template and is filled in when you open a PR. By participating you agree to abide by the Code of Conduct.
For security-sensitive bugs, please follow SECURITY.md instead of opening a public issue.
Apache-2.0. © 2026 Peaceful Studio OÜ. See LICENSE and NOTICE.