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: