Seven GitHub Actions workflows handle continuous integration, releases, NuGet publishing, platform packaging, container publishing, version management, and CI tool distribution.
PR / push to main
└─ ci.yml ─── build + test + coverage
Manual trigger (Actions tab → "Create release" → Run workflow)
└─ create-release.yml ── reads VersionPrefix, creates tag + GitHub Release
├─ publish.yml ──────── pack changed libraries → NuGet
│ └─ (on success) version-bump.yml ── bump VersionPrefix, auto-merge PR
├─ release-packages.yml ── self-contained binaries → GitHub Release assets
└─ release-docker.yml ─── multi-arch Docker image → GHCR
Manual trigger (Actions tab → "Publish CI Tool" → Run workflow)
└─ publish-tool.yml ── pack & push tinkwell-ci-package → NuGet
| Trigger | PR to main, push to main |
| Skipped when | Title or commit message contains [NO CI] |
- PR builds run unit tests only (
--filter "Category!=Integration"). - Push builds run all tests (unit + integration).
- All CI builds pass
-p:ContinuousIntegrationBuild=true, which activatesTreatWarningsAsErrorsinsrc/Directory.Build.props. - Coverage is collected via Coverlet, merged with ReportGenerator, and posted as a sticky comment on PRs.
A
coverage-reportartifact is uploaded on every run.
| Trigger | GitHub Release published |
| Checkout | fetch-depth: 0 (full history, all tags) |
Libraries under src/app/libs/ fall into two groups, declared per-csproj via the <TinkwellPackageGroup> property:
| Group | Meaning |
|---|---|
SDK |
Part of the Tinkwell product surface. Always packed on every release, at the product version, to stay in lockstep with the runtime. |
Standalone |
Reusable libraries with no hard tie to the Tinkwell runtime (e.g. Tinkwell.Coap, Tinkwell.Modbus, Tinkwell.Expressions). Packed only when something that affects them has changed since the previous tag. |
ExcludeFromRelease |
Not packed by this workflow (e.g. Tinkwell.Build.Ci, which has its own publish-tool.yml). |
A missing marker defaults to ExcludeFromRelease so a new, unclassified lib is never silently pushed to NuGet.
Detection is delegated to .github/scripts/detect-libs.sh, which:
- Finds the previous tag:
git tag --sort=-v:refname | grep -E '^v?[0-9]' | sed -n '2p'. - Reads
<TinkwellPackageGroup>from everysrc/app/libs/*/*.csproj. - Emits all
SDKcsproj paths intosdk_projectsunconditionally. - For every
Standalonelib, marks it dirty if:- its own directory has diffs against the previous tag, or
- any of its transitive
<ProjectReference>dependencies (SDK or Standalone) is dirty. SDK libs are treated as implicitly dirty, so Standalone libs that consume them get repacked with aPackageReferencepointing at the new version.
- Emits dirty Standalone csproj paths into
standalone_projects. - On the very first release (no previous tag), sets
pack_all=trueand emits every SDK and Standalone csproj. - If the shared
src/app/libs/Directory.Build.propshas any non-VersionPrefixchange (authors, license, URLs, tags, etc.), also setspack_all=true.
The pack step iterates over the union of sdk_projects and standalone_projects.
The pack version comes from the release tag (-p:PackageVersion=<version>), not from the VersionPrefix in Directory.Build.props.
The push step uses --skip-duplicate as a safety net in case a package version already exists on NuGet (e.g. after a re-run).
In practice this means:
- Every SDK library is versioned together with the product on every release.
Tinkwell.Expressions(Standalone but depending on SDK libs throughTinkwell.Core/Tinkwell.Telemetry) is republished on every release too, because itsPackageReferencevalues need to move forward with the SDK.Tinkwell.Coap,Tinkwell.Coap.Server,Tinkwell.Encoding,Tinkwell.Lwm2m,Tinkwell.Lwm2m.Server,Tinkwell.Modbusare republished only when their own code (or another Standalone lib they transitively reference) has changed.
| Trigger | GitHub Release published (runs in parallel with publish.yml) |
Builds self-contained .NET binaries for four RIDs on ubuntu-latest (cross-compilation):
| RID | Artifact |
|---|---|
win-x64 |
ZIP |
win-arm64 |
ZIP |
linux-x64 |
tarball + .deb |
linux-arm64 |
tarball + .deb |
All artifacts are uploaded to the GitHub Release via gh release upload.
For Windows ZIPs, the workflow prints the SHA256 hash in the GitHub Actions step summary.
These hashes are needed when submitting the winget manifest to microsoft/winget-pkgs (see packaging/winget/AdrianoRepetti.Tinkwell.yaml).
| Trigger | GitHub Release published (runs in parallel with publish.yml and release-packages.yml); also workflow_dispatch for manual rebuilds |
Builds and pushes a multi-arch container image to the GitHub Container Registry at ghcr.io/<owner>/tinkwell.
| Detail | Value |
|---|---|
| Dockerfile | packaging/docker/Dockerfile |
| Platforms | linux/amd64, linux/arm64 |
| Base image (runtime stage) | mcr.microsoft.com/dotnet/runtime-deps:10.0-bookworm-slim |
| Tags | <version>, <major>.<minor>, latest (only on the release trigger) |
| Auth | Uses GITHUB_TOKEN with packages: write permission |
| Cache | GitHub Actions cache, scope tinkwell-base |
The image is a runtime only: no ensemble.tw and no plugins are baked in. Users supply both via bind mount or by deriving from the base image — see Running under Docker. The manual workflow_dispatch accepts a version override (useful for republishing a tag without recreating the release) and a push boolean (set to false to dry-run the build without uploading).
| Trigger | After publish.yml completes successfully (workflow_run) |
- Fetches the release tag from the triggering workflow run via the GitHub API.
- Computes the next patch version (e.g.
0.1.0->0.1.1). - Updates
VersionPrefixin bothsrc/app/Directory.Build.propsandsrc/app/libs/Directory.Build.props. - Opens a PR titled
chore: Update libs version to X.Y.Z [NO CI]and enables auto-merge (squash).
The [NO CI] tag causes CI to skip the version-bump PR, so it merges immediately if branch protection allows skipped checks.
| Trigger | Manual (workflow_dispatch — Actions tab → "Create release" → Run workflow) |
This is the recommended way to create a release.
It reads the version from the code so there is no risk of a mismatch between the tag and VersionPrefix:
- Reads
VersionPrefixfromsrc/app/libs/Directory.Build.props. - Checks that the tag
v{version}does not already exist (fails with an error if it does). - Creates a GitHub Release with auto-generated release notes, which triggers
publish.ymlandrelease-packages.yml.
- Go to Actions > Create release > Run workflow.
- The workflow reads
VersionPrefix(e.g.0.1.0), creates tagv0.1.0and a GitHub Release. publish.ymldetects which libraries undersrc/app/libs/have changed since the previous tag and packs only those. If none changed, NuGet is skipped.release-packages.ymlbuilds platform binaries (win-x64, win-arm64, linux-x64, linux-arm64) and uploads ZIPs, tarballs, and.debpackages.release-docker.ymlbuilds and pushes the multi-arch Docker image to GHCR.- On success,
version-bump.ymlbumpsVersionPrefixto0.1.1and opens an auto-merging PR.
You can still create a release manually via Releases > Draft a new release if needed, but the create-release.yml workflow is preferred because it guarantees the tag matches the version in the code.
| Trigger | Manual (workflow_dispatch — Actions tab → "Publish CI Tool" → Run workflow) |
Packs and publishes the Tinkwell.Build.Ci global tool (the tinkwell-ci-package command) to NuGet.
The version is specified as an input parameter when triggering the workflow.
This is a manual workflow because the CI tool follows its own release cadence, independent of the main Tinkwell release.
It packs only src/app/libs/Tinkwell.Build.Ci/Tinkwell.Build.Ci.csproj and pushes the resulting .nupkg to NuGet with --skip-duplicate.
The version-bump workflow edits src/app/libs/Directory.Build.props after every release.
The change-detection script diffs this file while ignoring the VersionPrefix line, so the auto-bump does not trigger a false-positive "all libraries changed" on every subsequent release.
Any other change in the shared props file (authors, license, URLs, tags, etc.) still forces pack_all=true.
publish.yml passes -p:PackageVersion=<tag> explicitly, so the NuGet package version always matches the release tag.
The VersionPrefix in Directory.Build.props is only used for local development builds (dotnet build / dotnet pack without an explicit version override).
If you change shared metadata in src/app/libs/Directory.Build.props (author, license, URLs, tags, etc.), the change-detection script detects the non- VersionPrefix diff and automatically packs every SDK and Standalone lib.
No manual workaround is needed.
The gate above is correctness-safe: a change in any Tinkwell lib propagates to every dependent lib via the <ProjectReference> graph, so stale PackageReference versions on NuGet are not possible.
Splitting the Standalone libraries (Tinkwell.Coap, Tinkwell.Lwm2m, Tinkwell.Modbus, etc.) into their own repositories is therefore a product-shaping decision (release cadence, discoverability, issue triage) rather than a bug workaround.
It remains an option post-1.0 once the protocol APIs stabilise.
These steps are needed once when creating the GitHub repository.
Go to Settings > Secrets and variables > Actions and add:
| Secret | Description |
|---|---|
NUGET_API_KEY |
API key from nuget.org. Scope it to push packages for the Tinkwell.* prefix. |
Go to Settings > Actions > General > Workflow permissions and select:
- Read and write permissions
- Check Allow GitHub Actions to create and approve pull requests
This is needed for the version-bump workflow to create branches, push commits, and open PRs using GITHUB_TOKEN.
Go to Settings > Branches > Add branch protection rule for main:
- Require a pull request before merging -- enabled
- Require status checks to pass before merging -- enabled, add the
Testcheck - Allow auto-merge -- must be enabled at the repo level: Settings > General > Pull Requests > Allow auto-merge
The version-bump PRs have [NO CI] in their title, which causes the CI workflow to skip.
Since no required checks run, the auto-merge proceeds immediately.
If you configure Test as a required check, the [NO CI] skip will mark the job as skipped (not failed), and GitHub treats skipped required checks as passing -- so auto-merge still works.
Coverage works out of the box with no external accounts:
- How it works: All test projects already include
coverlet.collector. The CI workflow collects Cobertura XML duringdotnet test, merges reports with ReportGenerator, and posts a coverage summary as a sticky comment on each PR. - Coverage artifacts: Every CI run uploads a
coverage-reportartifact with the merged Cobertura XML -- useful for trend tracking or integration with other tools. - Optional: Codecov integration -- If you later want historical trends and badges, sign up at codecov.io, add a
CODECOV_TOKENsecret, and add a step to upload. Not required for the basic setup.