Skip to content

Repository files navigation

actions-common

Every ReactiveUI repository builds, tests, signs and releases its code the same way. This repository holds the shared pieces that do that work. Each repository calls them instead of keeping its own copy.

Table of Contents

What this repository gives you

  • Reusable workflows. A reusable workflow is a whole GitHub Actions workflow that another repository runs with uses:.
  • Composite actions. A composite action is a group of steps that a workflow runs as one step.
  • A signer image. This container image signs NuGet packages with the organisation's code-signing certificate.
  • A CodeQL pack. This pack tells CodeQL to trust actions published by this organisation.

Calling a workflow

Call a reusable workflow from a job in your own workflow:

jobs:
  build:
    permissions:
      contents: read
      actions: write
    uses: reactiveui/actions-common/.github/workflows/workflow-common-setup-and-build.yml@main
    with:
      installWorkloads: true
    secrets:
      CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

Grant the permissions listed at the top of the workflow file. Each input has a description in the file.

ReactiveUI's ci-build.yml is a working example.

Every repository uses @main

Repositories call these workflows at @main. A change merged here reaches every repository on its next run.

This repository has no CI that runs the reusable workflows. The repositories that call them are the test.

Reusable workflows

Workflow What it does
workflow-common-setup-and-build.yml Builds and tests on Windows, Linux and macOS, then uploads coverage to Codecov.
workflow-common-release.yml Chooses the release version, builds and packs, then signs the packages in the signer image.
workflow-common-release-unsigned.yml Builds and packs without signing.
workflow-common-create-release.yml Creates the GitHub release and its tag, with release notes and the packages attached.
workflow-common-publish-github-packages.yml Pushes packages to GitHub Packages.
workflow-common-sonarcloud.yml Runs SonarCloud analysis on pushes and on pull requests from the same repository.
workflow-common-codeql.yml Runs CodeQL on C#, on the repository's GitHub Actions and, if you ask, on JavaScript.
workflow-common-aot-smoke.yml Publishes a native AOT test app on Windows, Linux and macOS and runs it.
workflow-common-benchmarks.yml Runs BenchmarkDotNet projects and writes the results to the run summary.
workflow-common-benchmarks-ab.yml Benchmarks two commits on one runner and marks each benchmark faster, slower or unresolved.
workflow-common-device-tests.yml Runs device test apps on an Android emulator (Linux) and an iOS simulator (macOS). See Device tests.

The build, SonarCloud, CodeQL and AOT workflows skip work a change cannot affect. A push or pull request that only changes .github skips the build, tests, SonarCloud and the C# and JavaScript analysis. CodeQL analyses GitHub Actions only when something under .github changed. Skipped jobs still pass required checks.

Composite actions

Action What it does
dotnet-environment Installs the .NET 8 to 11 SDKs, adds Windows Defender exclusions, restores workloads and sets the version. Set stamp-version: 'false' in a job that has no checkout.
dotnet-build Restores and builds with the .NET CLI.
dotnet-build-uno Restores, builds and packs an Uno solution with MSBuild.
dotnet-test Runs the tests on Microsoft Testing Platform and uploads coverage and diagnostic logs. test-projects (testProjects on the build and SonarCloud workflows) takes globs that narrow the run to matching projects, with ! lines to exclude.
minver Reads the version from git tags and exports it for the build.
compute-version-and-tag Works out the next release version from the latest release tag.
sonarcloud Starts a SonarCloud scan before the build and finishes it after the tests.
certum-sign Signs .nupkg files inside the signer image.
dotnet-benchmarks Checks out a commit and runs benchmark projects for the A/B workflow.
detect-changes Reports whether a push or pull request changes files under .github, files outside it, or both.

Steps are written in C#

When a step needs more than one command, it runs a C# script. The .NET SDK reads the script from standard input with dotnet run -:

- name: Resolve release tag
  shell: bash
  env:
    VERSION_OVERRIDE: ${{ inputs.versionOverride }}
    TAG_PREFIX: ${{ inputs.minverTagPrefix }}
  run: |
    cd "$RUNNER_TEMP" && dotnet run - -- "$VERSION_OVERRIDE" "$TAG_PREFIX" <<'CS'
    if (args is not [var versionOverride, var tagPrefix])
    {
        return 2;
    }

    // ...
    CS

Every script follows these rules:

  • Inputs arrive through env:. The step passes them to the script as arguments. The script text never contains ${{ }}, so a value from a pull request cannot change the script's code.
  • The script runs from $RUNNER_TEMP. dotnet run reads global.json and Directory.Build.props from the folder it runs in. The temp folder keeps the calling repository's settings out of the script.
  • The script needs the .NET 11 SDK. A job runs dotnet-environment before its first script.
  • A script declares what it needs at the top. For example, #:package System.Management@* adds a package.

How a version is chosen

  • Every build gets a version from git tags. MinVer reads the latest tag. A build after that tag gets a pre-release version.
  • A release picks its version first. bump adds one to the major, minor or patch number of the latest release. preRelease adds an alpha, beta or rc label with a counter. versionOverride sets an exact version, for example for a backport.
  • The tag comes last. workflow-common-create-release.yml creates the tag at the commit that was built. A build or signing failure leaves no tag behind.

The signer image

docker/certum-signer/Dockerfile builds ghcr.io/reactiveui/certum-signer. The release workflow signs packages inside it.

Why it exists. Certum SimplySign has no command-line signing. Its key is only reachable through the SimplySign Desktop app. The image runs that app on a virtual display and logs in with xdotool. It then signs with jsign and checks the result with dotnet nuget verify.

What it is built on. The image starts from the .NET 11 SDK on Ubuntu 26.04, pinned by digest. It adds the .NET 10 SDK.

Why it builds libxml2. SimplySign ships its own copy of Qt 5.9. That Qt needs libxml2.so.2. Ubuntu 26.04 only ships libxml2.so.16, the name libxml2 2.14 moved to. So the image builds libxml2 2.13.9, the last release with libxml2.so.2. It also builds libxslt 1.1.43, the last release that works with that libxml2.

How it catches a missing library. The build runs ldd on SimplySign and its display plugin. The build fails if either one is missing a library.

One installer quirk. The SimplySign installer stops every process whose command line contains SimplySignDesktop. The install step names the install folder only through $SS_DIST for that reason.

Who can pull it. The image is private. A workflow that calls the release workflow grants packages: read.

When it rebuilds. The image rebuilds when its Dockerfile changes on main, every Monday, and when you start the workflow by hand. The weekly rebuild picks up Ubuntu security fixes, .NET 10 patches and new SimplySign releases.

The CodeQL pack

codeql/actions-trusted-owners is published as reactiveui/actions-trusted-owners.

CodeQL's actions/unpinned-tag query flags any action that a workflow references by a tag or branch, such as @main. It trusts only GitHub's own publishers. Every ReactiveUI repository calls this repository at @main on purpose. Without the pack, CodeQL would flag every shared workflow in every repository.

The pack adds reactiveui to the owners CodeQL trusts. Actions from any other owner are still flagged. workflow-common-codeql.yml loads the pack when it analyses GitHub Actions.

  • Its version counts commits. The patch number is the number of commits that changed the pack. Every change publishes a new version on its own.
  • Its settings live in qlpack.yml. The CodeQL CLI ignores extensionTargets in codeql-pack.yml. GitHub's model pack guide describes the format.
  • It is public. Each repository downloads it with its own GITHUB_TOKEN, which cannot read private packages.

The release train

The release train releases several ReactiveUI repositories in dependency order. Other repositories do not call it. You start it from this repository's Actions tab with release-train.yml.

For example, a Splat release needs ReactiveUI, Akavache and the others to move to the new Splat version and release too. The train does that work for you:

  1. It releases Splat with Splat's own release.yml.
  2. It waits until NuGet lists every new Splat package.
  3. It opens a pull request in each repository that depends on Splat. The pull request updates the package versions.
  4. It merges the pull request when its checks pass, then releases that repository.
  5. It repeats steps 2 to 4 down the dependency graph.

A repository whose latest release already points at the head of its branch has nothing new to ship. The train reuses that release instead of releasing again.

Only the packages a library ships with decide the order. A repository that uses another repository's packages only in its tests does not list it in dependsOn. Its test pins still move to that repository's latest release.

The config

build/release-train.json lists the repositories. Each entry names a repository and the repositories it dependsOn. The train releases a repository only after everything it depends on has released.

{
  "name": "ReactiveUI",
  "dependsOn": ["Primitives", "splat", "ReactiveUI.Binding.SourceGenerators"],
  "releaseInputs": { "bump": "{bump}", "channel": "{channel}" }
}
Setting What it does
name The repository name. The repository is <owner>/<name> unless you set repository.
dependsOn Repositories that must release first.
releaseInputs Inputs for the repository's release workflow. {bump} and {channel} take the train's values. Leave out {channel} when the workflow has no channel input.
releaseWorkflow, branch The release workflow file and the branch it runs on. The defaults are release.yml and main.
ignorePackages Package IDs the train never updates in this repository.
adminMerge Merges the pull request past branch protection. Use it when a ruleset requires a review and lists the App as a bypass actor.

groups names sets of repositories, such as core. timeouts sets how many minutes the train waits for pull request checks, the release run and NuGet.

Starting a train

Input What it does
targets Repositories or groups to release, separated by commas. all releases every repository.
includeDownstream Also releases every repository that depends on the targets.
exclude Repositories or groups to leave out.
bump, channel The release level and channel for every repository.
onFailure stop starts no new level after a failure. continue keeps releasing the repositories that do not depend on the failed one.
dryRun Shows the plan without releasing anything.

To release Splat and everything that uses it, set targets to splat and keep includeDownstream on.

A repository with its own preset can call the train as a reusable workflow with fixed inputs.

How versions are updated

The train reads every .props, .targets and project file in the repository. It updates a PackageVersion or PackageReference that names a package from another repository in the config. When the version is an MSBuild property such as $(SplatVersion), it updates the property instead.

Each package gets its version from this run's release when the train released it. Otherwise it gets the version from the repository's latest GitHub release. The train never lowers a version, and it leaves ranges and other expressions alone.

When a repository fails

The train stops at the failure and reports it. The run summary lists every repository with its result, version, pull request, release and each version it changed. It ends with the inputs to resume.

Say ReactiveUI fails to build with the new Splat version:

  1. Fix the train's pull request in ReactiveUI and merge it.
  2. Start the train again with targets set to ReactiveUI.

Splat does not release again. ReactiveUI picks up the Splat version that already shipped from Splat's latest release. The train reuses an open pull request on its release-train/dependencies branch, so your fix stays.

The GitHub App

The train acts as an organisation GitHub App. The built-in GITHUB_TOKEN cannot do this work: pull requests it opens do not start CI, and it cannot start workflows in other repositories.

An App token expires after one hour, and a train waits longer than that. So the scripts sign their own tokens with the App's private key and replace each token before it expires. Commits and pull requests appear as <app-name>[bot].

The train reads two organisation secrets, and this repository must have access to both:

Secret Value
RELEASE_TRAIN_APP_CLIENT_ID The App's client ID.
RELEASE_TRAIN_APP_PRIVATE_KEY The whole .pem private key file.

Install the App on every repository in the config. The plan job stops before it releases anything when the App is missing from a planned repository. Give the App these repository permissions:

Permission Why
Contents: read and write Push the update branch and merge the pull request.
Pull requests: read and write Open, comment on and merge the pull request.
Actions: read and write Start the release workflow and follow its run.
Checks: read, Commit statuses: read Read the pull request's check results.

When a ruleset requires a review, add the App to the ruleset's bypass list and set adminMerge for that repository.

How dependencies stay current

Renovate opens pull requests for dependency updates. It uses the organisation's preset, which every ReactiveUI repository shares, and the rules in .github/renovate.json.

  • Docker images and actions from actions/*, github/* and microsoft/* are pinned by digest. A digest names one exact image or commit. Renovate updates the digest on the day a new version appears.
  • Trusted updates merge on their own. These are minor, patch and digest updates to those actions, and digest updates to mcr.microsoft.com, ghcr.io/reactiveui and docker/dockerfile images.
  • reactiveui/* actions stay on @main. Every repository picks up a shared change on its next run.
  • The Dockerfile's jsign version is tracked through its # renovate: comment. The SimplySign download has no source Renovate can read, so you update it by hand.

Why there is no dependency cache

GitHub keeps a separate cache for each branch, and each repository gets 10 GB. Pull requests filled that space and pushed out the entries builds needed. On Windows, saving the NuGet cache also took longer than restoring the packages from scratch. So no workflow here caches NuGet packages or workloads.

Coverage from three operating systems

Each operating system uploads its coverage as a temporary artifact. The collect job merges the three into one artifact with actions/upload-artifact/merge and deletes the temporary ones. It then uploads the merged coverage to Codecov.

WinUI tests

A WinUI test needs the Windows App Runtime. Workloads do not install it and the runner image does not have it. Set installWindowsAppRuntime: true and the workflow installs it before the tests run.

The runtime defaults to the latest stable Microsoft.WindowsAppSDK major.minor, which Renovate keeps current. Set windowsAppRuntimeVersion only to pin an older runtime.

Device tests

A device test app runs a repository's Android or iOS platform code on a real emulator or simulator. The normal build cannot run it, so workflow-common-device-tests.yml runs it in two jobs:

  • device-tests (android) runs on Linux. It turns on KVM, installs the emulator and a system image, and boots a headless emulator.
  • device-tests (ios) runs on macOS. It creates and boots a fresh iPhone simulator.

Both jobs run scripts/device-tests.cs and upload the TRX report and device logs as an artifact. The job fails when a test fails. Turn a job off with runAndroid: false or runIos: false; a job turned off reports as skipped, which passes a required check.

jobs:
  device-tests:
    uses: reactiveui/actions-common/.github/workflows/workflow-common-device-tests.yml@main
    with:
      projects: src/tests/MyLibrary.Device.Tests/MyLibrary.Device.Tests.csproj
      solutionFile: MyLibrary.slnx

The script is the same one a developer runs locally, so a failure in CI reproduces on a desk:

dotnet run --file ../actions-common/scripts/device-tests.cs -- android --project src/tests/MyLibrary.Device.Tests/MyLibrary.Device.Tests.csproj

It needs the Android SDK and KVM for android, and macOS with Xcode for ios. It refuses a host that cannot run the platform. The header of the script lists every option and exit code.

The workflow checks the script out at its own commit, so a caller that points at a branch of this repository runs that branch's script.

Sponsors

JetBrains gives ReactiveUI's maintainers licences for its tools through its open source support programme. Anthropic supports them with Claude through Claude for Open Source. OpenAI supports them with Codex through Codex for Open Source.

JetBrains Claude by Anthropic OpenAI

See our sponsors for more information. JetBrains, Claude, Anthropic, OpenAI and Codex names and logos are trademarks of their respective owners.

About

Shared GitHub Actions workflows and composite actions used across the ReactiveUI organisation. Covers building, testing, code coverage, package signing and releases so every repository follows the same pipeline.

Resources

Code of conduct

Security policy

Stars

2 stars

Watchers

7 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages