Workflows¶
Every Snail library, server and application runs its CI, its release, its site and its benchmarks through the shared
workflows of Snail.Sdk, so that a fix to how the fleet builds is made once, here, and every repository gets it on its
next run. A repository keeps only short callers in .github/workflows/; this page lists each shared workflow and the
nuget-push action with a caller, its inputs, outputs, secrets and permissions. The input tables are taken from the
workflow files as they are in this release.
Calling them: @v1¶
A caller names a workflow by the tag v1:
uses: orldev/Snail.Sdk/.github/workflows/lib-ci.yml@v1
v1 is the major version of the workflow and action contract, not the version of a package. It is a moving tag: it
points at the commit of a package release vX.Y.Z whose workflows and action keep every v1 caller working, and the
owner moves it after that release is on nuget.org. A change that would break a v1 caller — a removed or renamed
input, output, secret or artifact, an input made required, a default that makes a run do something else, a permission
the caller must newly grant — is released under a new tag, v2, and v1 stays where it was. Callers therefore get
fixes without editing anything, and never a break they did not choose. Dependabot keeps the callers' tag current: it
proposes @v2 when one exists.
| Workflow | Called from | Does |
|---|---|---|
lib-ci.yml |
a library's ci.yml |
tests, conformance, the reference, the package, the native probe |
lib-publish.yml |
a library's publish.yml |
the release: tests, pack, artifact packages |
mcp-ci.yml |
a server's ci.yml |
tests, conformance, the reference, the native build per platform, the stdio contract |
mcp-publish.yml |
a server's publish.yml |
the release: a tool package per platform, the fallback and the pointer |
app-ci.yml |
an application's ci.yml |
tests, conformance |
mcp-image.yml |
a container server's or an application's image.yml |
the image, pushed to GHCR from a version tag |
docs.yml |
every repository's docs.yml |
the site on GitHub Pages |
perf.yml |
every repository's perf.yml |
benchmarks against the baseline, mutation testing |
nuget-push (action) |
the push job of a publish.yml |
pushes the packages through NuGet trusted publishing |
Every shared workflow declares permissions: contents: read and asks for nothing more except where the tables below say
so. The conformance check holds each caller to this page: S3LC0011 checks that the callers exist and
call the workflows of the repository's level — ci.yml, publish.yml, docs.yml and perf.yml for a library or a
server, ci.yml, image.yml and perf.yml for an application — S3LC0027 that every call names a workflow or action
Snail.Sdk keeps, passes only inputs it declares and every input it requires.
lib-ci.yml¶
The build and tests of a library, on every push and pull request. The job test runs dotnet test on the solution,
then the conformance check (-t:SnailConformance on the first project under src/), then builds
with -p:SnailReference=true and fails when docs/ differs from what is committed — the reference on the site is
always the code's — and packs, so package validation against SnailPackageBaseline runs on every change. The job
native-probe publishes every *.NativeProbe project with Native AOT on Linux, fails on any IL or AOT warning,
and runs the program: a library that only claims to be native is caught here.
name: build and tests
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
ci:
uses: orldev/Snail.Sdk/.github/workflows/lib-ci.yml@v1
| Input | Type | Default | Meaning |
|---|---|---|---|
solution |
string | '' |
The solution to build; the one .slnx at the root when empty. |
os |
string | ubuntu-latest |
The runner the tests run on. |
test-filter |
string | '' |
A dotnet test filter, such as one that leaves out tests needing a container. |
test-environment |
string | '' |
Variables the tests run with, one NAME=value a line, such as one that turns a skipped container test into a failure. |
forgiven-native-warnings |
string | '' |
An extended regular expression of native warnings a probe may hear — a dependency's, explained in the probe project, such as 'Microsoft.AspNetCore.Components'. Every other trim or AOT warning still fails. |
Secrets: DOCKERHUB_USERNAME and DOCKERHUB_TOKEN, both optional, passed with secrets: inherit. They exist only for
tests that pull an image Docker Hub gives to a signed-in pull; without them the login step is skipped. Docker Hub is
used for pulling test images only — Snail images are published to GHCR.
Artifacts: test results (the .trx files).
lib-publish.yml¶
The release of a library, on a version tag or by hand. It checks the version (vX.Y.Z or vX.Y.Z-suffix), runs the
conformance check with the release checks on (-p:SnailConformanceRelease=true: nothing may rest on a local feed),
tests, packs every package with -p:Version=<version> and uploads them as the artifact packages. It pushes nothing
and holds no id-token: the push is a job of the caller's own, for the reason below.
name: release to NuGet
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+*']
workflow_dispatch:
inputs:
version:
description: The version to pack, such as 0.1.0 or 0.1.0-preview.1
required: true
push:
description: Push the packages to NuGet
type: boolean
default: false
permissions:
contents: read
jobs:
release:
uses: orldev/Snail.Sdk/.github/workflows/lib-publish.yml@v1
with:
version: ${{ inputs.version }}
push: ${{ github.event_name == 'push' || inputs.push }}
secrets: inherit
push:
needs: release
if: needs.release.outputs.push == 'true'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: orldev/Snail.Sdk/.github/actions/nuget-push@v1
with:
user: ${{ secrets.NUGET_USER }}
The tag pattern v[0-9]+.[0-9]+.[0-9]+* starts a release on v1.2.0 and v1.2.0-preview.1 but never on a moving
major tag such as v1 (S3LC0028 advises it).
| Input | Type | Default | Meaning |
|---|---|---|---|
version |
string | '' |
The version to pack; the tag without its v when empty. |
push |
boolean | false |
Whether the caller pushes the packages to NuGet; passed through to the output push. |
os |
string | ubuntu-latest |
The runner the tests run on and the packages are packed on. |
test-filter |
string | '' |
A dotnet test filter, such as one that leaves out tests needing a container. |
test-environment |
string | '' |
Variables the tests run with, one NAME=value a line, such as one that turns a skipped container test into a failure. |
| Output | Meaning |
|---|---|
version |
The version the packages carry. |
push |
'true' when the packages are to be pushed: the condition of the caller's push job. |
Secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN, optional, as in lib-ci.yml. Artifact: packages (*.nupkg,
*.snupkg), kept 7 days.
mcp-ci.yml¶
The build and tests of an MCP server. The job test runs the tests, the conformance check and the reference check as
for a library. The job project reads from the project, with one dotnet msbuild -getProperty, the program's name
(AssemblyName), whether the server is native (SnailMcpNative) and its platforms (ToolPackageRuntimeIdentifiers),
so the caller never repeats what the project says. A native server is then published with Native AOT on a runner of
each platform — any trim or AOT warning fails it, since such a warning is a call that fails at run time — and, where
the runner can run it, its contract tests run against that program through contract-variable. A framework-dependent
server is published once and asked the same way.
jobs:
ci:
uses: orldev/Snail.Sdk/.github/workflows/mcp-ci.yml@v1
with:
contract-variable: SNAIL_CLEANUP_SERVER
| Input | Type | Default | Meaning |
|---|---|---|---|
project |
string | src |
The server's project, a folder or a .csproj. |
tests |
string | tests |
The test project, a folder or a .csproj. |
os |
string | ubuntu-latest |
The runner the tests run on, and a framework-dependent server is asked on; macos-latest for a server that reads the Mac. |
test-filter |
string | '' |
A dotnet test filter for the tests, such as one leaving out those that read the real machine. |
runtimes |
string | '' |
The platforms a native server ships to, as a JSON list of { rid, os, runs }; empty takes them from the project's ToolPackageRuntimeIdentifiers, so the workflow builds exactly the packages the pointer names. |
forgiven-native-warnings |
string | '' |
An extended regular expression of native warnings that do not fail the build — a dependency's, explained in the project, such as 'Jint.dll|: Jint.'. Every other trim or AOT warning still does. |
contract-variable |
string | required | The variable the stdio contract tests read the program from, such as SNAIL_CLEANUP_SERVER. |
contract-filter |
string | FullyQualifiedName~Contract |
The dotnet test filter that selects the contract tests. |
The contract variable is SNAIL_<SHORT>_SERVER, <SHORT> being the server's short name in capitals; the tests read it
through ServerProgram.Command of Snail.Sdk.Mcp.Testing.
Each platform runs on its own runner:
| Platform | Runner | Contract run |
|---|---|---|
osx-arm64 |
macos-latest |
yes |
osx-x64 |
macos-latest |
no — built only, the runner is ARM |
linux-x64 |
ubuntu-latest |
yes |
linux-arm64 |
ubuntu-24.04-arm |
yes |
win-x64 |
windows-latest |
yes |
win-arm64 |
windows-11-arm |
yes |
any (the framework-dependent fallback) is not built per platform; a platform outside the table fails the job, so a
new one is a change to this workflow, not a silent gap. Artifacts: test results.
mcp-publish.yml¶
The release of a server as a dotnet tool. Inputs are those of mcp-ci.yml, so the caller passes the same values in
both files, plus also-pack, version and push. The job version checks the version; test runs the conformance
check with the release checks on and the tests; project reads the project as in CI, and also its PackageId. A
native server is packed on each platform's runner (dotnet pack -r <rid>), published and asked over stdio there, and
uploaded as packages-<rid>; the job portable packs the framework-dependent fallback (-r any), the pointer package
that names the per-platform packages, and every also-pack project, and uploads them as packages-portable. Every
package and its assemblies carry -p:Version=<version>, so the version a server reports is the one released, and
.mcp/server.json is rewritten with it before packing. The job fails when the server's package was not packed or,
for a native server, does not name RuntimeIdentifierPackages.
jobs:
release:
uses: orldev/Snail.Sdk/.github/workflows/mcp-publish.yml@v1
with:
contract-variable: SNAIL_CLEANUP_SERVER
version: ${{ inputs.version }}
push: ${{ github.event_name == 'push' || inputs.push }}
push:
needs: release
if: needs.release.outputs.push == 'true'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: orldev/Snail.Sdk/.github/actions/nuget-push@v1
with:
user: ${{ secrets.NUGET_USER }}
The on: block is the library's above. mcp-publish.yml takes no secrets, so the release job needs no
secrets: inherit.
| Input | Type | Default | Meaning |
|---|---|---|---|
project |
string | src |
The server's project, a folder or a .csproj. |
tests |
string | tests |
The test project, a folder or a .csproj. |
os |
string | ubuntu-latest |
The runner the tests run on. |
test-filter |
string | '' |
A dotnet test filter for the tests, such as one leaving out those that read the real machine. |
runtimes |
string | '' |
The platforms a native server ships to, as a JSON list of { rid, os, runs }; empty takes them from the project's ToolPackageRuntimeIdentifiers, so the workflow builds exactly the packages the pointer names. |
forgiven-native-warnings |
string | '' |
An extended regular expression of native warnings that do not fail the build — a dependency's, explained in the project, such as 'Jint.dll|: Jint.'. Every other trim or AOT warning still does. |
contract-variable |
string | required | The variable the stdio contract tests read the program from. |
contract-filter |
string | FullyQualifiedName~Contract |
The dotnet test filter that selects the contract tests. |
also-pack |
string | '' |
Other projects released with the server, separated by spaces, such as src/Core. |
version |
string | '' |
The version to pack; the tag without its v when empty. |
push |
boolean | false |
Whether the caller pushes the packages to NuGet; passed through to the output push. |
| Output | Meaning |
|---|---|
version |
The version the packages carry. |
push |
'true' when the packages are to be pushed: the condition of the caller's push job. |
Artifacts: packages-<rid> per platform and packages-portable, kept 7 days. The caller's push job needs the whole
call, so it runs only when the tests, every platform's package and the portable packages all succeeded.
Why the push is the caller's job¶
nuget.org trusted publishing gives a short-lived API key only to an OIDC token whose repository is the repository
its trusted publishing policy names and whose job_workflow_ref is a workflow file of that same repository. A job
defined inside a reusable workflow of orldev/Snail.Sdk carries Snail.Sdk's job_workflow_ref, so nuget.org refuses
it for every other repository. An action, on the other hand, runs inside the caller's job, whose job_workflow_ref is
the caller's own publish.yml. Hence the two jobs of every publish.yml:
releasecallslib-publish.ymlormcp-publish.yml: it builds, tests and packs, with noid-token.pushneedsrelease, runs whenneeds.release.outputs.push == 'true', holdsid-token: write, and has one step, thenuget-pushaction. It checks out nothing and runs none of the repository's code, so the only job that can get a key never runs a test.
The trusted publishing policy on nuget.org names the owner orldev, the repository by its current name and the
workflow file publish.yml. The secret NUGET_USER holds the nuget.org account the policy belongs to, devorl.
S3LC0011 holds every publish.yml to this shape: the release job without id-token: write, the push job with it.
The nuget-push action¶
orldev/Snail.Sdk/.github/actions/nuget-push@v1, a composite action for the push job of a publish.yml (above). It
downloads the artifacts matching packages, logs in with NuGet/login@v1 (an OIDC key for this run only), and runs
push.sh: every *.nupkg is
pushed to nuget.org with --skip-duplicate, a .snupkg beside a package goes with it, and a package whose
DotnetToolSettings.xml names RuntimeIdentifierPackages — the pointer of a native server — is pushed after every
other, since pushed first it would install nothing until the packages it names arrive. A failed push stops the script,
so the pointer never follows a failure; no package found is an error. A run repeated after a partial push completes it.
| Input | Type | Default | Meaning |
|---|---|---|---|
user |
string | required | The nuget.org account the trusted publishing policy belongs to (secret NUGET_USER). |
packages |
string | packages* |
The artifacts holding the packages, as a download-artifact pattern. |
It needs a Linux runner (runs-on: ubuntu-latest) and permissions: { id-token: write, contents: read } on its job.
app-ci.yml¶
The build and tests of an ASP.NET application, on every push and pull request. The job test runs dotnet test on the
solution, then the conformance check. An application publishes no package and no API reference, so
neither is built here: it is released as its image, which its image.yml builds through
mcp-image.yml on every push and pushes to GHCR from a version tag.
name: build and tests
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
ci:
uses: orldev/Snail.Sdk/.github/workflows/app-ci.yml@v1
| Input | Type | Default | Meaning |
|---|---|---|---|
solution |
string | '' |
The solution to build; the one .slnx at the root when empty. |
os |
string | ubuntu-latest |
The runner the tests run on. |
test-filter |
string | '' |
A dotnet test filter, such as one that leaves out tests needing a container. |
test-environment |
string | '' |
Variables the tests run with, one NAME=value a line, such as one that turns a skipped container test into a failure. |
Secrets: DOCKERHUB_USERNAME and DOCKERHUB_TOKEN, both optional, as for lib-ci.yml.
Artifacts: test results (the .trx files).
mcp-image.yml¶
The container image of a server whose project sets SnailMcpContainer, or of an application. Neither the solution nor
the tests build the Dockerfile, so the image is built on every push and pull request, and a broken one shows before a
merge. It is pushed only from a v* tag, to ghcr.io/<owner>/s3l-<short>-mcp — an application names its image in the
image input — tagged with the version, latest and the short commit; a prerelease tag (v1.2.0-preview.1) gets its
version and the commit but leaves latest where it is. A branch or pull request build is tagged with its name and the
commit and is not pushed. Images go to GHCR only.
Every build passes VERSION as a build argument: the release's version from the tag (v1.2.0 gives 1.2.0), or
0.0.0-<branch>.<commit> from a branch or pull request, so the server built into the image reports in initialize
what it is. The template's Dockerfile takes it as ARG VERSION=0.0.0-local — what a local docker build gets — passes
it to dotnet publish as -p:Version and labels the image org.opencontainers.image.version. A local build is tagged
by hand, docker build -t s3l-<short>-mcp:local ., never left to default to latest, the name a deployment pulls, and
names its commit when that matters: --build-arg VERSION=0.0.0-local.$(git rev-parse --short HEAD), since the image sees
no git. A Dockerfile without the ARG only sees a warning that the argument was not consumed.
name: server image
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
image:
permissions:
contents: read
packages: write
uses: orldev/Snail.Sdk/.github/workflows/mcp-image.yml@v1
| Input | Type | Default | Meaning |
|---|---|---|---|
image |
string | '' |
The image name when not empty; s3l- |
dockerfile |
string | Dockerfile |
The Dockerfile, from the repository root. |
context |
string | . |
The build context. |
platforms |
string | linux/amd64 |
The platforms to build, as buildx takes them. |
build-args |
string | '' |
Build arguments, one NAME=value a line, as the Dockerfile's ARG lines take them. |
Permissions: the job image asks for packages: write, which the caller grants. A new GHCR package is private; the
owner makes it public once.
docs.yml¶
The site on GitHub Pages, on a push to the default branch. The job build builds the solution with
-p:SnailReference=true, so the site never shows an older reference than the code, runs prepare if given, then the
SnailSite target on a project of the repository — the same build as on a machine, strict, with the theme the SDK
carries. The job deploy publishes it.
name: documentation
on:
push:
branches: [master]
workflow_dispatch:
permissions:
contents: read
jobs:
docs:
permissions:
contents: read
pages: write
id-token: write
uses: orldev/Snail.Sdk/.github/workflows/docs.yml@v1
| Input | Type | Default | Meaning |
|---|---|---|---|
solution |
string | '' |
The solution to build; the one .slnx at the root when empty. |
project |
string | '' |
A project of the repository that runs the SnailSite target; the first under src/ that has it when empty. |
prepare |
string | '' |
A command run after the reference is written and before the site is built, such as one that measures coverage into a page. |
Permissions: the job deploy asks for pages: write and id-token: write, which the caller grants. The repository's
Pages source is GitHub Actions; the workflows share the concurrency group pages, so two deployments never overlap.
perf.yml¶
Benchmarks and mutation testing, on a schedule (every two months) and by hand before a release that touches a hot
path. The job benchmarks runs the benchmark project with --job short --exporters csv github and compares the
allocations of each benchmark with the CSV reports kept in baseline/ beside the project: an allocation that grows past
allocation-tolerance fails the job. Only memory fails it, because time on a shared runner moves by tens of percent
between runs and allocations do not; times are reported beside it. The job mutation runs Stryker.NET, of the version
the workflow pins, and reports its score; it fails only under mutation-break. A repository starts with the
template's baseline and replaces it with the reports of its first accepted run.
name: performance
on:
schedule:
- cron: '0 4 1 */2 *'
workflow_dispatch:
permissions:
contents: read
jobs:
perf:
uses: orldev/Snail.Sdk/.github/workflows/perf.yml@v1
| Input | Type | Default | Meaning |
|---|---|---|---|
benchmarks |
string | benchmarks |
The benchmark project, a folder or a .csproj. |
benchmark-filter |
string | * |
The benchmarks to run, as BenchmarkDotNet's --filter takes it. |
os |
string | ubuntu-latest |
The runner the benchmarks run on; the baseline must come from the same kind. |
allocation-tolerance |
number | 5 |
How many percent an allocation may grow over the baseline. |
mutation |
boolean | true |
Run Stryker.NET. |
mutation-break |
number | 0 |
The mutation score under which the job fails; 0 reports only. |
Artifacts: benchmarks and mutation (the reports).
The MSBuild names the workflows rely on¶
The workflows read these from a project with dotnet msbuild -getProperty or pass them with -p:; they are part of
the v1 contract, and a change to their meaning is a change of major version.
| Name | Read or set by | For |
|---|---|---|
AssemblyName |
mcp-ci.yml, mcp-publish.yml |
the program to run for the contract tests |
PackageId |
mcp-publish.yml |
the server's package to look for among those packed |
SnailMcpNative |
mcp-ci.yml, mcp-publish.yml |
native per-platform build or one framework-dependent one |
ToolPackageRuntimeIdentifiers |
mcp-ci.yml, mcp-publish.yml |
the platforms, one runner each |
Version |
lib-publish.yml, mcp-publish.yml (-p:Version=) |
the released version, in the packages and the assemblies |
SnailReference |
lib-ci.yml, mcp-ci.yml, docs.yml (-p:SnailReference=true) |
writing the reference |
SnailConformance, SnailConformanceRelease |
lib-*, mcp-* |
the conformance check, with the release checks in a release |
SnailSite, SnailSiteProject, SnailSiteFolder, SnailSiteRepositoryUrl |
docs.yml |
finding the project that builds the site, and building it |
Each is described in the MSBuild properties.
Snail.Sdk's own workflows¶
Snail.Sdk does not call its shared workflows; it has three of its own.
| Workflow | Runs on | Does |
|---|---|---|
ci.yml |
every branch push, pull requests | dotnet test (the SDK built into packages and used on real projects), actionlint over the shared and template workflows, shellcheck over the action's script |
publish.yml |
a tag vX.Y.Z |
tests, packs every package and pushes them with the nuget-push action of the same commit |
site.yml |
pushes to master, pull requests |
builds this site strictly with tools/site.sh and, from master, deploys it to Pages |
No workflow runs on the moving v1 tag: it points at a commit those workflows have already checked.