Skip to content

peacefulstudio/github-actions

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

36 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

github-actions

License

Shared reusable GitHub Actions workflows for Peaceful Studio repos.

Project stewardship

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.

Available workflows

build-and-test.yaml — actionlint validator

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@v1

go-ci.yaml — Go build, test, coverage, lint

Discovers 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@v1

To 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'

csharp-ci.yaml — .NET build, test, coverage, pack

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 with read:packages for dotnet restore against private NuGet feeds (e.g. GitHub Packages). Omit for public-only restores. Pass via secrets: inherit or an explicit secrets: 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: inherit

Caller prerequisites (xUnit v3 + Microsoft.Testing.Platform)

This 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.json at working-directory pinning the .NET SDK version — required unless the caller passes an explicit dotnet-version input. Bumping to a new SDK line is then a caller-side global.json change; no release of this workflow is needed.

  • Directory.Packages.props pinning:

    • xunit.v33.2.2
    • Microsoft.Testing.Extensions.CodeCoverage — required: the workflow resolves the dotnet-coverage merge-tool version from this pin (scanning every Directory.Packages.props under working-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 missing global.json. The version must be a literal (e.g. 18.8.0). See canton-ledger-api-csharp#79 for the MTP 1.x / 2.x compatibility rationale: do not bump CodeCoverage past 18.0.x until xunit.v3 ships 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.xml files somewhere under tests/.

  • 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.xml at the repo root (or wherever TestingPlatformCommandLineArguments points), 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's Microsoft.Testing.Extensions.CodeCoverage replaces it. Mixing the two produces duplicate or empty Cobertura reports.

scala-ci.yaml — sbt build, test, coverage

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

Caller prerequisites

  • sbt project rooted at working-directorybuild.sbt, project/build.properties, and project/plugins.sbt are expected at that path (they're hashed into the sbt cache key).

  • sbt-scoverage plugin declared in <working-directory>/project/plugins.sbt. Without it, sbt coverage and sbt coverageReport fail 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.xml after sbt coverageReport — this is the path the workflow stages, summarises, and uploads. The default cobertura-path input targets scala-2.13; Scala 3 and other-major callers MUST override cobertura-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-version and java-distribution for projects pinned to an older or alternative JDK.

  • sbt-command is eval'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 or workflow_dispatch payloads.

terraform-ci.yaml — Terraform fmt, validate, test

Runs terraform fmt -check -recursive, then discovers Terraform modules under the configured workspace and runs terraform init -backend=false

  • terraform validate against each. If any .tftest.hcl files exist, it also runs terraform test -verbose from 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):

  1. Explicit list via module-paths (relative to working-directory).
  2. Every direct subdirectory of modules/ if that directory exists.
  3. Every directory (up to depth 3) containing main.tf, versions.tf, or terraform.tf, excluding tests/, examples/, and .terraform/. The fallback is best-effort — set module-paths explicitly 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-hosted ubuntu-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 pin runs-on: ubuntu-latest. On any macos-* 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@v1

For 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'

csharp-publish-public.yaml — pack and publish to nuget.org

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.

Trusted publishing

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:

  1. Set an organization secret NUGET_USER — your nuget.org profile name — and pass it down with secrets: inherit (the reusable workflow reads secrets.NUGET_USER, so without inherit the login runs with an empty user).
  2. 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.
  3. 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: inherit

update-badges.yaml — live coverage + CI matrix badges via an orphan branch

Writes 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 one coverage-<slug>.json per entry (entries with an empty/null percent are skipped). Default '[]' writes no coverage badge.
  • matrix-data — JSON array of {lang, os, arch, passed}; writes one ci-<lang>-<os>-<arch>.json per entry. Tag each matrix-status entry with a lang first (so one badge branch can hold several languages). Default '[]'.
  • badge-branch — orphan branch to write to (default badges).

Then reference a badge in the README (substitute owner/repo and the file the writer produced):

![coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/<owner>/<repo>/badges/coverage-csharp.json)

Selecting runners

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 label

csharp-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 the build-and-test (<name>) job title.
  • runner — passed verbatim to runs-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 to false. At most one shard may set coverage: true; that shard produces the coverage report, sticky PR comment, job summary (and, for scala-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.

Cheap matrix mode

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), ignoring os-list and build-matrix. Private/internal repositories only — see the security note below.
  • full — force the normal matrix (visibility default, os-list, or build-matrix).
  • (empty, the default) — defer to the org/repo variable CI_MATRIX_MODE.

Security: cheap is ignored on public repositories. Self-hosted runners must never execute untrusted public- or fork-PR workloads, so on a public repo cheap is dropped (with a warning) and the normal GitHub-hosted matrix runs instead. Scope the CI_MATRIX_MODE org 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: full

Pinning

Pin 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.

Access

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.

Versioning

  • Breaking changes bump the major tag (v1v2).
  • Non-breaking changes move the existing major tag forward.
  • Full version tags (v1.2.0) are available for strict pinning.

Contributing

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.

License

Apache-2.0. © 2026 Peaceful Studio OÜ. See LICENSE and NOTICE.

About

Shared reusable GitHub Actions workflows for Peaceful Studio repos.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors