Every ktsu repository runs the same CI entry point: a ci.yml that calls
ci-shared.yml in this repository and nothing else. ci-shared.yml works out what kind
of repository it is looking at and dispatches to the right pipeline.
A repository that names its pipeline directly — uses: .../dotnet.yml@release — has to be
edited when it moves to a different pipeline, and the set of repositories needing that edit
is only discoverable by reading all of them. A repository that names ci-shared.yml says
"build me however ktsu builds this kind of repository", which stays true without edits.
The payoff is that adding a language or a build environment is a change here. A Rust
pipeline becomes one more job in ci-shared.yml; no repository is touched.
jobs.<job_id>.uses cannot take an expression — GitHub is explicit that "You cannot use
contexts or expressions in this keyword" — so the dispatch is not a computed path. It is
one statically referenced job per pipeline, each gated by an if: on the detect job's
outputs. if: on a uses: job does work; the Dependabot merge gate has relied on it in
production since its rollout.
detect classifies the repository from two independent signals, and requires both:
| Signal | Source | What it answers |
|---|---|---|
| Topic | topics on the repository |
What the repository intends to be |
| Marker file | global.json for .NET |
What the repository actually is |
Requiring both closes the two ways this goes wrong. blogs and ktsu.dev carry the
dotnet topic without being .NET builds, so a topic on its own over-matches. A repository
whose topic was never set would match nothing, and a run that matches no pipeline and
passes anyway is precisely the silent success this design exists to remove — so a
mismatch is a hard failure that names what to fix, not a skip.
Visibility comes from the API rather than github.event.repository.private. The payload
shape varies across push, pull_request, schedule and workflow_dispatch, and a
missing field would read as "public" — the one wrong answer that matters, because it
routes a private repository through the public pipeline.
A fork does not inherit its parent's topics. Verified against ktsu-dev/winget-pkgs,
a fork of microsoft/winget-pkgs: the parent carries topics, the fork's repository object
has none at all. Selecting on a topic alone would therefore fail every fork of every ktsu
repository, and fail it by telling a stranger to add organization metadata they do not own.
So detect borrows the parent's topics when the repository is a fork and has no dotnet
topic of its own. A fork then classifies as whatever it was forked from. The marker-file
check still applies, and files are inherited, so this loosens nothing about what a fork
has to actually be. A fork that sets its own dotnet topic is taken at its word and the
parent is never consulted.
Nothing else needs a fork-specific gate, because the pipelines already have one each:
| Concern | Existing guard |
|---|---|
| Publishing | ktsubuild is passed EXPECTED_OWNER: ktsu-dev and decides should_release itself |
| SonarQube | every Sonar step is gated on env.SONAR_TOKEN != '', which is empty in a fork |
| Installing the build tool | dotnet tool install ktsu.KtsuBuild.Tool from public NuGet, no secret |
A forker therefore gets discovery, build and tests across all three platforms, and the steps that would need ktsu's secrets skip themselves.
Each repository holds this at .github/workflows/ci.yml, byte-identical everywhere:
name: CI
on:
push:
branches: [main, develop]
paths-ignore:
["**.md", ".github/ISSUE_TEMPLATE/**", ".github/pull_request_template.md"]
pull_request:
schedule:
- cron: "0 23 * * *" # Daily at 11 PM UTC
workflow_dispatch:
inputs:
version-bump:
description: 'Version bump type'
required: false
default: 'auto'
type: choice
options:
- auto
- patch
- minor
- major
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# The caller grants; a called workflow can only reduce. This is the union of what the
# pipelines request -- dotnet.yml's release job needs contents and packages, its security
# job needs id-token -- and a pipeline that needs a permission missing here fails the run
# at startup, before any job exists to report it.
permissions:
contents: write
packages: write
id-token: write
jobs:
ci:
uses: ktsu-dev/.github/.github/workflows/ci-shared.yml@release
secrets: inherit
with:
# `inputs` is empty on push, pull_request and schedule; only a dispatch sets it.
version-bump: ${{ inputs.version-bump || 'auto' }}Triggers, path filters and concurrency live here because a reusable workflow cannot declare
triggers, and a concurrency group inside one would contend with the caller waiting on it —
github.workflow resolves to the caller on both sides, so the group string would collide.
The asymmetry is deliberate and is the one part of this block that must not be "tidied up"
into symmetry. scripts/tests/shared-ci-template.tests.ps1 asserts both halves of it.
push keeps its paths-ignore because release gating runs off KtsuBuild's should_release
rather than the event type, so a docs-only push to main would otherwise cut a version.
pull_request must not have one. A filtered pull_request trigger does not report a
neutral check — it reports nothing, so any ruleset requiring Build, Test & Release
blocks a docs-only pull request permanently, with no check to wait on and nothing to
override. That also catches pull requests editing DESCRIPTION.md and TAGS.md, which the
Terraform workspace derives repository metadata from.
This has already been reverted twice by rollouts that regenerated the trigger block from a
symmetric template — cf13319 removed the filter across 41 repositories on 2026-08-23, and
a4cec36 put it back three days later as a side effect of adopting the unified workflow.
See #5.
Callers reference @release, a tag that is moved rather than a version that has to be
propagated. main can take work in progress without touching fifty repositories' CI;
moving the tag promotes it.
Promote by running the Promote release workflow in this repository from the Actions
tab. It takes a ref (default main) and refuses to move the tag unless:
- the ref resolves to a commit here;
- that commit is contained in main, so nothing reaches fifty repositories' CI without having gone through review here;
ci-shared.ymlexists at that commit, and every pipeline it dispatches to exists too.
It then force-moves an annotated release tag recording who promoted it, and writes a
summary saying which commit the tag moved from and to. Promotions are queued rather than
cancelled, so a run that may already have moved the tag is never interrupted.
Moving the tag by hand works too, but skips all of the above.
Inside ci-shared.yml the pipelines are referenced absolutely, at @release — not
relatively. A relative ./ inside a workflow that was itself reached through a tag is
resolved by GitHub against the tag object rather than the commit it points at, and a tag
object has no tree, so the lookup fails:
error parsing called workflow ".github/workflows/ci.yml"
-> "ktsu-dev/.github/.github/workflows/ci-shared.yml@release" (source tag with sha:af44498...)
--> "./.github/workflows/dotnet.yml" : workflow was not found.
That sha is the annotated tag's own, not the commit it points to. The relative form does
resolve on a pull_request event, which is exactly what makes this trap worth writing
down — it looks correct until the first push to a default branch. The absolute form
resolves identically on every event, and because release is promoted as one unit the
dispatcher and the pipeline it selects still move together.
The cost is that a pull request against this repository tests its own ci-shared.yml
against the released pipelines rather than its own. Changing a pipeline and the
dispatcher together therefore wants two promotions, or a throwaway tag.
A SonarCloud outage never holds a release, including where SONAR_BLOCKING_GATE makes the
quality gate blocking. dotnet.yml treats three things as an outage: the probe before analysis
getting no answer, and the scanner's begin or end reporting a server error (Error 5xx on https://...) or a failed connection. An outage sets an outage output on the step that saw it,
posts a warning and a step summary, and the Release step reads it and goes ahead without a gate.
A gate that was evaluated and failed still stops a release where the gate is blocking, and any other scanner failure (a bad token, a rejected analysis) still fails the run.
Being lenient at end also avoids a trap. The KtsuBuild step pushes the release's metadata commit
to main before end runs, so a run that fails there leaves a version bumped but not released, and
rerunning it fails with a non-fast-forward push. The recovery is a fresh run: a manual dispatch, the
nightly schedule, or the next merge.
dependabot-merge.yml in each repository lists the workflows whose completion re-opens the
merge question, by workflow name. Under shared CI that list is the single name CI,
identical in every repository — which removes the per-repository footgun described in
dependabot-auto-merge.md. Renaming the caller away from CI, or back to per-pipeline
names, silently stops Dependabot PRs merging.
The Status column on the organization profile is the latest run of one workflow file on a
repository's default branch, and KtsuBuild reads that file by name. It reads ci.yml —
the caller, not the pipeline the caller dispatches to. That is deliberate for the same reason
the dispatcher exists: a repository that starts building through a new pipeline keeps its
badge without the generator being taught anything.
While repositories are still being moved over, update-readme.yml passes
--fallback-workflow dotnet.yml, so an unmigrated repository still shows a status and the run
logs a warning naming it. The flag comes off when the warnings stop.
A repository that has neither file reports no status, which is the intended signal for a repository that has opted out of shared CI rather than a bug to work around.
update-sdks.yml is reusable in the same way and reached through the same release tag, but
it is not part of the dispatcher: it runs on its own weekly schedule rather than on push, so
folding it into ci-shared.yml would run it on every commit. Repositories call it from their
own update-sdks.yml. See sdk-pinning.md.
- Add the workflow to this repository with
on: workflow_call. - Add a job to
ci-shared.ymlwith a staticuses:and anif:ondetect's outputs. - Teach
detectthe new topic and marker file, including its failure messages. - Move the
releasetag.
No repository is edited unless it is changing what kind of repository it is.