diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ee28c..35574ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Please choose versions by [Semantic Versioning](http://semver.org/). * MINOR version when you add functionality in a backwards-compatible manner, and * PATCH version when you make backwards-compatible bug fixes. +## Unreleased + +- fix: rule-checks: gate the two `go-licensing` MUST rules on real repo visibility instead of assuming every repo is public. `check_license_file_required` was a bare `[ ! -f LICENSE ]` — its own message said "Public Go projects must…" but nothing checked whether the repo was public, so the guide's documented private-repo exemption was never implemented. `check_readme_license_section` had the same gap. Visibility now comes from `gh repo view --json isPrivate` (memoised, and only called when a finding would otherwise fire), and both rules **fail open** when it cannot be determined — a MUST-tier false positive blocks every PR in an org, while a missed finding on a public repo surfaces at the next review. `license-file-required` is also now actually Go-scoped (requires `go.mod`), matching its own docs. Measured impact: 69 of 73 non-archived `Seibert-Data` repos deliberately carry no LICENSE, so the rule was blocking essentially every PR org-wide; one such PR had to be admin-merged to land. +- fix: go-licensing-guide: replace the host-based visibility heuristic ("hosted on `github.com` → public") with the repo's `isPrivate` flag. The Octopus migration moved 73 private repos onto `github.com`, so host stopped implying visibility — this was the root cause of the false positive above. Documents the fail-open behaviour. + ## v0.42.2 - fix: ast-grep-runner: pass finding payloads to `jq` via `--slurpfile` instead of `--argjson`, fixing `Argument list too long` on large result sets. Both sites moved: the per-owner merge and the final assembly. Payloads were being read out of files into shell variables and pushed back through `argv`; `jq` now reads the files directly, so payload size is irrelevant. Scales with finding count and matched-text size, not file count — reproduced with 1,956 findings from 429 files, which previously produced an **empty output file and exit 0**. diff --git a/docs/go-licensing-guide.md b/docs/go-licensing-guide.md index bac80a1..259fd06 100644 --- a/docs/go-licensing-guide.md +++ b/docs/go-licensing-guide.md @@ -6,13 +6,17 @@ This guide covers how to properly handle licensing in Go projects. Licensing requirements depend on whether the repository is **public** or **private/internal**: -| | Public (GitHub) | Private/Internal (Bitbucket) | +| | Public | Private/Internal | |---|---|---| | LICENSE file | Required | Not needed | | README license section | Required | Not needed | | Source file headers | Required | Not needed | -**How to detect**: If the repo is hosted on `github.com` → public. If hosted on `bitbucket.seibert.tools` or similar internal hosting → private/internal. +**How to detect**: read the repo's own visibility flag — `gh repo view --json isPrivate -q .isPrivate`. `false` → public, `true` → private/internal. + +**Do NOT infer visibility from the host.** `github.com` used to mean public and `bitbucket.seibert.tools` private, but the Octopus migration moved 73 private `Seibert-Data` repos onto `github.com`. Host-based detection now misclassifies nearly every private repo as public — it was the cause of a MUST-tier false positive that blocked merges org-wide (fixed 2026-08). + +**When visibility cannot be determined** (no `gh`, no remote, offline, not a repo), these rules **do not fire**. They are MUST-tier, so a false positive blocks every PR in the org, while a missed finding on a public repo is caught at the next review. The rest of this guide applies to **public repositories only**. diff --git a/scripts/rule-checks.sh b/scripts/rule-checks.sh index 25df26f..45e5701 100755 --- a/scripts/rule-checks.sh +++ b/scripts/rule-checks.sh @@ -100,27 +100,67 @@ changed_file_matches() { return 1 } +# --------------------------------------------------------------------------- +# repo_visibility — prints "public", "private" or "unknown". Memoised: one +# `gh` call per run at most, and only when a licensing rule is about to fire. +# +# Host is NOT a proxy for visibility. Private repos live on github.com too, so +# the only reliable signal is the repo's own isPrivate flag. +# --------------------------------------------------------------------------- +REPO_VISIBILITY_CACHE="" +repo_visibility() { + if [ -n "$REPO_VISIBILITY_CACHE" ]; then + printf '%s' "$REPO_VISIBILITY_CACHE" + return 0 + fi + local vis="unknown" out + if command -v gh >/dev/null 2>&1; then + if out=$( (cd "$TARGET_DIR" && gh repo view --json isPrivate -q .isPrivate) 2>/dev/null ); then + case "$out" in + true) vis="private" ;; + false) vis="public" ;; + esac + fi + fi + REPO_VISIBILITY_CACHE="$vis" + printf '%s' "$vis" +} + +# --------------------------------------------------------------------------- +# repo_is_public — true only when the repo is KNOWN public. +# +# Fails open on "unknown" (no gh, no remote, offline, not a repo) by design: +# these are MUST-tier rules, so a false positive blocks merges on every PR in +# the org, while a missed finding on a public repo surfaces at the next review. +# --------------------------------------------------------------------------- +repo_is_public() { + [ "$(repo_visibility)" = "public" ] +} + # --------------------------------------------------------------------------- # RULE: go-licensing/license-file-required (MUST) -# Always run (cheap, applies to any Go project). +# Public Go repos only — see docs/go-licensing-guide.md § Public vs Private. +# Ordered cheapest-first so the `gh` call only happens when a finding would +# otherwise be emitted. # --------------------------------------------------------------------------- check_license_file_required() { - local license_file="$TARGET_DIR/LICENSE" - if [ ! -f "$license_file" ]; then - emit_finding \ - "license-assistant" \ - "go-licensing/license-file-required" \ - "MUST" \ - "$TARGET_DIR/LICENSE" \ - 0 0 \ - "(file absent)" \ - "No LICENSE file found at repo root. Public Go projects must have a root LICENSE file. See docs/go-licensing-guide.md." - fi + [ -f "$TARGET_DIR/LICENSE" ] && return 0 + [ -f "$TARGET_DIR/go.mod" ] || return 0 # rule is Go-scoped per its own docs + repo_is_public || return 0 + emit_finding \ + "license-assistant" \ + "go-licensing/license-file-required" \ + "MUST" \ + "$TARGET_DIR/LICENSE" \ + 0 0 \ + "(file absent)" \ + "No LICENSE file found at repo root. Public Go projects must have a root LICENSE file. See docs/go-licensing-guide.md." } # --------------------------------------------------------------------------- # RULE: go-licensing/readme-license-section-required (MUST) # Run when README.md changed, or always (cheap grep). +# Public repos only — same visibility gate as license-file-required. # --------------------------------------------------------------------------- check_readme_license_section() { changed_file_matches "*README.md" || return 0 @@ -129,6 +169,7 @@ check_readme_license_section() { return 0 # no README — different issue, not this rule fi if ! grep -qE '^## License' "$readme"; then + repo_is_public || return 0 local line line=$(wc -l < "$readme") emit_finding \