From 2bdbfe9d9b57bd8f5198b4beb258c28f868ccc5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:53:18 +0000 Subject: [PATCH] Classify a fork by its parent's topics A fork does not inherit its parent's topics. ktsu-dev/winget-pkgs, a fork of microsoft/winget-pkgs, is the proof to hand: the parent carries topics, the fork's repository object has none at all. detect required the dotnet topic, so as written it failed every fork of every ktsu repository -- and failed it by telling a stranger to add organization metadata they do not own. Before shared CI a fork got a complete local dotnet.yml and simply worked, so this was a regression I introduced. Borrow 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 nothing is loosened about what a fork has to actually be; a fork that sets its own topic is taken at its word and the parent is never consulted. Nothing else needed a fork gate. Checking rather than assuming turned up one already in place for each concern: ktsubuild is passed EXPECTED_OWNER and decides should_release itself, every Sonar step is gated on SONAR_TOKEN being non-empty, and the build tool installs from public NuGet without a secret. A second owner check would have duplicated deliberate existing intent. Ten cases were run against the extracted script with a stubbed gh: upstream public and private, a fork inheriting its parent's topic, a fork with its own topic (parent not consulted), a fork whose parent is unreadable, a fork with no parent field, a fork inheriting a topic but lacking global.json, and the topic-without-global.json case that blogs and ktsu.dev would hit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014RABe2NufFc9hwm94iB3Rf --- .github/workflows/ci-shared.yml | 35 ++++++++++++++++++++++++++++++--- docs/shared-ci.md | 24 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-shared.yml b/.github/workflows/ci-shared.yml index 36605c4..7037b9d 100644 --- a/.github/workflows/ci-shared.yml +++ b/.github/workflows/ci-shared.yml @@ -59,10 +59,34 @@ jobs: # repository through the public pipeline. meta=$(gh api "repos/$GITHUB_REPOSITORY") private=$(printf '%s' "$meta" | jq -r '.private') + is_fork=$(printf '%s' "$meta" | jq -r '.fork') topics=$(printf '%s' "$meta" | jq -r '.topics | join(" ")') echo "private=$private" >> "$GITHUB_OUTPUT" - echo "Repository is private: $private" - echo "Topics: $topics" + echo "Repository is private: $private, fork: $is_fork" + echo "Topics: ${topics:-}" + + # 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 a pipeline 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. + # Borrow the parent's topics instead, so a fork classifies as whatever it was + # forked from. The marker-file check below still applies, and files are + # inherited, so this loosens nothing about what a fork has to actually be. + if [ "$is_fork" = "true" ] \ + && [ "$(printf '%s' "$meta" | jq -r '.topics | index("dotnet") != null')" != "true" ]; then + parent=$(printf '%s' "$meta" | jq -r '.parent.full_name // empty') + if [ -n "$parent" ] && parent_meta=$(gh api "repos/$parent" 2>/dev/null); then + meta=$parent_meta + topics=$(printf '%s' "$meta" | jq -r '.topics | join(" ")') + echo "Fork of $parent; using its topics: ${topics:-}" + else + # A parent that cannot be read -- deleted, or private to someone else -- + # is not fatal on its own. Fall through and let the checks below report + # what is actually missing. + echo "Fork, but its parent could not be read; classifying on this repository alone." + fi + fi # A topic says what the repository intends to be. A marker file says what it # actually is. Requiring both is what stops the two ways this can go wrong: @@ -94,7 +118,12 @@ jobs: if [ "$has_global_json" = "true" ]; then echo "::error::This repository has a global.json but not the 'dotnet' topic." >&2 - echo "::error::Add the 'dotnet' topic; the topic is what selects the pipeline." >&2 + if [ "$is_fork" = "true" ]; then + echo "::error::This is a fork and its parent's topics could not be read either." >&2 + echo "::error::Add the 'dotnet' topic to this fork to build it." >&2 + else + echo "::error::Add the 'dotnet' topic; the topic is what selects the pipeline." >&2 + fi exit 1 fi diff --git a/docs/shared-ci.md b/docs/shared-ci.md index 50d9ec2..a37e055 100644 --- a/docs/shared-ci.md +++ b/docs/shared-ci.md @@ -40,6 +40,30 @@ shape varies across `push`, `pull_request`, `schedule` and `workflow_dispatch`, missing field would read as "public" — the one wrong answer that matters, because it routes a private repository through the public pipeline. +### Forks + +**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. + ## The caller Each repository holds this at `.github/workflows/ci.yml`, byte-identical everywhere: