From 82d77aeb0ecdef15eea36fe43e7debf9200c42ca Mon Sep 17 00:00:00 2001 From: Kronk Bot <4156439+kronk-bot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:03:54 +0000 Subject: [PATCH 1/2] feat: audit public repository licenses --- .../workflows/public_repo_license_audit.yaml | 45 +++++ README.md | 12 ++ scripts/audit_public_repo_licenses.sh | 126 ++++++++++++ scripts/public_repo_license_findings.jq | 7 + scripts/test_audit_public_repo_licenses.py | 187 ++++++++++++++++++ 5 files changed, 377 insertions(+) create mode 100644 .github/workflows/public_repo_license_audit.yaml create mode 100755 scripts/audit_public_repo_licenses.sh create mode 100644 scripts/public_repo_license_findings.jq create mode 100644 scripts/test_audit_public_repo_licenses.py diff --git a/.github/workflows/public_repo_license_audit.yaml b/.github/workflows/public_repo_license_audit.yaml new file mode 100644 index 0000000..f9534db --- /dev/null +++ b/.github/workflows/public_repo_license_audit.yaml @@ -0,0 +1,45 @@ +name: Public Repository License Audit + +on: + pull_request: + paths: + - .github/workflows/public_repo_license_audit.yaml + - scripts/audit_public_repo_licenses.sh + - scripts/public_repo_license_findings.jq + - scripts/test_audit_public_repo_licenses.py + schedule: + - cron: "17 13 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: public-repository-license-audit + cancel-in-progress: false + +jobs: + pull-request-audit: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Test public repository license audit + run: python3 -m unittest -v scripts/test_audit_public_repo_licenses.py + + scheduled-audit: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Audit public repository licenses and update tracking issue + env: + GH_TOKEN: ${{ github.token }} + run: scripts/audit_public_repo_licenses.sh --update-issue diff --git a/README.md b/README.md index 59d3462..917cab6 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,15 @@ Composite action that lists the calling repository's branches and outputs the hi run: echo "Release branch is ${{ steps.get_current_release_branch.outputs.branch }}" ``` Pin by commit SHA for reproducibility. + +## Public repository license audit + +`public_repo_license_audit.yaml` runs every Monday and can also be dispatched manually. It fails closed when either organization API cannot be read, then checks every public, active, non-fork repository in `PickNikRobotics` and `PickNikRoboticsServices` for a root license that GitHub detects as a recognized SPDX license. + +If a repository has no detected license or GitHub reports `NOASSERTION`, the workflow fails and creates or updates one tracking issue in this repository. The issue body is machine-owned and replaced on every run, so human review notes and repository dispositions belong in comments. The issue closes automatically after all findings are resolved. Pull requests that change the audit run its mock API test suite without mutating issues. + +Run the same check locally with an authenticated GitHub CLI: + +```bash +GH_TOKEN= scripts/audit_public_repo_licenses.sh --check-only +``` diff --git a/scripts/audit_public_repo_licenses.sh b/scripts/audit_public_repo_licenses.sh new file mode 100755 index 0000000..3804a1f --- /dev/null +++ b/scripts/audit_public_repo_licenses.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly ORGANIZATIONS=(PickNikRobotics PickNikRoboticsServices) +readonly TRACKING_REPO="${TRACKING_REPO:-PickNikRobotics/moveit_pro_ci}" +readonly ISSUE_TITLE="Public repositories missing detected licenses" +readonly MODE="${1:---check-only}" +if ! SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"; then + echo "::error::Unable to resolve the audit script directory." >&2 + exit 2 +fi +readonly SCRIPT_DIRECTORY +readonly LICENSE_FILTER_PATH="${SCRIPT_DIRECTORY}/public_repo_license_findings.jq" + +if [[ "$MODE" != "--check-only" && "$MODE" != "--update-issue" ]]; then + echo "Usage: $0 [--check-only|--update-issue]" >&2 + exit 2 +fi +if [[ ! -r "$LICENSE_FILTER_PATH" ]]; then + echo "::error::Unable to read repository license filter at ${LICENSE_FILTER_PATH}." >&2 + exit 2 +fi +if ! LICENSE_FILTER="$(<"$LICENSE_FILTER_PATH")"; then + echo "::error::Unable to load repository license filter at ${LICENSE_FILTER_PATH}." >&2 + exit 2 +fi +readonly LICENSE_FILTER + +missing_repos=() +for organization in "${ORGANIZATIONS[@]}"; do + # Capture each command before parsing it so an API or authentication failure + # cannot be mistaken for an empty, all-clear result. + if ! audit_output="$( + gh api --paginate "/orgs/${organization}/repos?type=public&per_page=100" \ + --jq "$LICENSE_FILTER" + )"; then + echo "::error::Unable to audit public repository licenses in ${organization}." >&2 + exit 2 + fi + + while IFS= read -r repository; do + [[ -n "$repository" ]] && missing_repos+=("$repository") + done <<< "$audit_output" +done + +if ((${#missing_repos[@]} > 0)); then + if ! sorted_missing_repos="$(printf '%s\n' "${missing_repos[@]}" | sort -u)"; then + echo "::error::Unable to sort repository license findings." >&2 + exit 2 + fi + mapfile -t missing_repos <<< "$sorted_missing_repos" +fi + +if ((${#missing_repos[@]} == 0)); then + echo "All public, active, non-fork repositories in ${ORGANIZATIONS[*]} have a detected root license." +else + echo "Found ${#missing_repos[@]} public, active, non-fork repositories without a detected root license:" + printf ' - %s\n' "${missing_repos[@]}" +fi + +if [[ "$MODE" == "--check-only" ]]; then + ((${#missing_repos[@]} == 0)) + exit +fi + +if ! issue_output="$( + gh api --paginate "/repos/${TRACKING_REPO}/issues?state=open&per_page=100" \ + --jq ".[] | select(.pull_request == null and .title == \"${ISSUE_TITLE}\") | .number" +)"; then + echo "::error::Unable to query the tracking issue." >&2 + exit 2 +fi +tracking_issues=() +while IFS= read -r issue; do + [[ -n "$issue" ]] && tracking_issues+=("$issue") +done <<< "$issue_output" +if ((${#tracking_issues[@]} > 1)); then + echo "::error::Found multiple open tracking issues titled '${ISSUE_TITLE}'." >&2 + exit 2 +fi +issue_number="${tracking_issues[0]:-}" + +if ((${#missing_repos[@]} == 0)); then + if [[ -n "$issue_number" ]]; then + gh issue comment "$issue_number" --repo "$TRACKING_REPO" \ + --body $'[written by AI]\n\nThe scheduled audit is clean again; closing this tracking issue.' + gh issue close "$issue_number" --repo "$TRACKING_REPO" --reason completed + fi + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + echo "## Public repository license audit: clean" >> "$GITHUB_STEP_SUMMARY" + fi + exit 0 +fi + +body_file="$(mktemp)" +trap 'rm -f "$body_file"' EXIT +{ + echo "[written by AI]" + echo + echo "The scheduled license audit found public, active, non-fork repositories whose root license is missing or not recognized by GitHub:" + echo + for repository in "${missing_repos[@]}"; do + printf -- '- [ ] [%s](https://github.com/%s)\n' "$repository" "$repository" + done + echo + echo "For each repository, add a provenance-compatible root license, or make it private or archived. This machine-owned body is replaced on every run; record review notes and dispositions in issue comments." + echo + echo "This issue is maintained automatically by \`${TRACKING_REPO}/.github/workflows/public_repo_license_audit.yaml\`." +} > "$body_file" + +if [[ -n "$issue_number" ]]; then + gh issue edit "$issue_number" --repo "$TRACKING_REPO" --body-file "$body_file" +else + gh issue create --repo "$TRACKING_REPO" --title "$ISSUE_TITLE" --body-file "$body_file" +fi + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "## Public repository license audit: action required" + echo + printf -- '- %s\n' "${missing_repos[@]}" + } >> "$GITHUB_STEP_SUMMARY" +fi + +# Keep the workflow visibly failing until the public exposure is resolved. +exit 1 diff --git a/scripts/public_repo_license_findings.jq b/scripts/public_repo_license_findings.jq new file mode 100644 index 0000000..0f23932 --- /dev/null +++ b/scripts/public_repo_license_findings.jq @@ -0,0 +1,7 @@ +.[] +| select( + (.fork == false) + and (.archived == false) + and ((.license == null) or (.license.spdx_id == "NOASSERTION")) + ) +| .full_name diff --git a/scripts/test_audit_public_repo_licenses.py b/scripts/test_audit_public_repo_licenses.py new file mode 100644 index 0000000..3eb3ee8 --- /dev/null +++ b/scripts/test_audit_public_repo_licenses.py @@ -0,0 +1,187 @@ +from pathlib import Path +import json +import os +import subprocess +import tempfile +import textwrap +import unittest + + +SCRIPT = Path(__file__).with_name("audit_public_repo_licenses.sh") +LICENSE_FILTER = Path(__file__).with_name("public_repo_license_findings.jq") + + +class PublicRepositoryLicenseAuditTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.directory = Path(self.temporary_directory.name) + self.log_path = self.directory / "gh.log" + mock_gh = self.directory / "gh" + mock_gh.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + from pathlib import Path + import sys + + arguments = sys.argv[1:] + log_path = Path(os.environ["MOCK_GH_LOG"]) + with log_path.open("a", encoding="utf-8") as log: + print(json.dumps(arguments), file=log) + + if arguments[:2] == ["api", "--paginate"]: + endpoint = arguments[2] + if endpoint.startswith("/orgs/"): + organization = endpoint.split("/")[2] + if os.environ.get("MOCK_FAIL_ORG") == organization: + raise SystemExit(1) + missing = json.loads(os.environ.get("MOCK_MISSING", "{}")) + print("\\n".join(missing.get(organization, []))) + elif "/issues?" in endpoint: + print(os.environ.get("MOCK_ISSUE_NUMBER", "")) + else: + raise SystemExit(f"Unexpected API endpoint: {endpoint}") + elif arguments[:2] == ["issue", "create"] or arguments[:2] == ["issue", "edit"]: + body_index = arguments.index("--body-file") + 1 + with log_path.open("a", encoding="utf-8") as log: + print(Path(arguments[body_index]).read_text(encoding="utf-8"), file=log) + elif arguments[:2] in (["issue", "comment"], ["issue", "close"]): + pass + else: + raise SystemExit(f"Unexpected gh arguments: {arguments}") + """ + ), + encoding="utf-8", + ) + mock_gh.chmod(0o755) + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def run_audit(self, *arguments: str, **environment: str) -> subprocess.CompletedProcess[str]: + process_environment = os.environ.copy() + process_environment.update( + { + "PATH": f"{self.directory}:{process_environment['PATH']}", + "MOCK_GH_LOG": str(self.log_path), + **environment, + } + ) + return subprocess.run( + [str(SCRIPT), *arguments], + check=False, + capture_output=True, + env=process_environment, + text=True, + ) + + def read_log(self) -> str: + return self.log_path.read_text(encoding="utf-8") + + def test_production_filter_selects_only_active_public_license_findings(self) -> None: + repositories = [ + { + "full_name": "PickNikRobotics/missing", + "fork": False, + "archived": False, + "license": None, + }, + { + "full_name": "PickNikRobotics/unrecognized", + "fork": False, + "archived": False, + "license": {"spdx_id": "NOASSERTION"}, + }, + { + "full_name": "PickNikRobotics/licensed", + "fork": False, + "archived": False, + "license": {"spdx_id": "BSD-3-Clause"}, + }, + { + "full_name": "PickNikRobotics/archived", + "fork": False, + "archived": True, + "license": None, + }, + { + "full_name": "PickNikRobotics/fork", + "fork": True, + "archived": False, + "license": None, + }, + ] + result = subprocess.run( + ["jq", "-r", "-f", str(LICENSE_FILTER)], + check=False, + capture_output=True, + input=json.dumps(repositories), + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout.splitlines(), + ["PickNikRobotics/missing", "PickNikRobotics/unrecognized"], + ) + + def test_clean_audit_checks_both_organizations(self) -> None: + result = self.run_audit("--check-only") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("PickNikRobotics PickNikRoboticsServices", result.stdout) + log = self.read_log() + self.assertIn("/orgs/PickNikRobotics/repos", log) + self.assertIn("/orgs/PickNikRoboticsServices/repos", log) + self.assertIn("NOASSERTION", log) + + def test_missing_license_fails_and_names_repository(self) -> None: + missing = {"PickNikRoboticsServices": ["PickNikRoboticsServices/example"]} + result = self.run_audit("--check-only", MOCK_MISSING=json.dumps(missing)) + self.assertEqual(result.returncode, 1) + self.assertIn("PickNikRoboticsServices/example", result.stdout) + + def test_api_failure_is_not_reported_as_clean(self) -> None: + result = self.run_audit( + "--check-only", MOCK_FAIL_ORG="PickNikRoboticsServices" + ) + self.assertEqual(result.returncode, 2) + self.assertIn("Unable to audit", result.stderr) + self.assertNotIn("have a detected root license", result.stdout) + + def test_sort_failure_is_not_reported_as_clean(self) -> None: + mock_sort = self.directory / "sort" + mock_sort.write_text("#!/usr/bin/env bash\nexit 1\n", encoding="utf-8") + mock_sort.chmod(0o755) + missing = {"PickNikRobotics": ["PickNikRobotics/example"]} + result = self.run_audit("--check-only", MOCK_MISSING=json.dumps(missing)) + self.assertEqual(result.returncode, 2) + self.assertIn("Unable to sort", result.stderr) + self.assertNotIn("have a detected root license", result.stdout) + + def test_update_mode_creates_one_tracking_issue(self) -> None: + missing = {"PickNikRobotics": ["PickNikRobotics/example"]} + result = self.run_audit("--update-issue", MOCK_MISSING=json.dumps(missing)) + self.assertEqual(result.returncode, 1) + log = self.read_log() + self.assertIn('["issue", "create"', log) + self.assertIn("[written by AI]", log) + self.assertIn("- [ ] [PickNikRobotics/example]", log) + self.assertIn("machine-owned body is replaced on every run", log) + self.assertIn("record review notes and dispositions in issue comments", log) + + def test_update_mode_closes_existing_issue_after_remediation(self) -> None: + result = self.run_audit("--update-issue", MOCK_ISSUE_NUMBER="42") + self.assertEqual(result.returncode, 0, result.stderr) + log = self.read_log() + self.assertIn('["issue", "comment", "42"', log) + self.assertIn('["issue", "close", "42"', log) + + def test_update_mode_fails_on_duplicate_tracking_issues(self) -> None: + result = self.run_audit("--update-issue", MOCK_ISSUE_NUMBER="42\n43") + self.assertEqual(result.returncode, 2) + self.assertIn("multiple open tracking issues", result.stderr) + + +if __name__ == "__main__": + unittest.main() From d861c9b83e064ddd37ecc75e04f4e6a52c333bbd Mon Sep 17 00:00:00 2001 From: Kronk Bot <4156439+kronk-bot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:00:16 +0000 Subject: [PATCH 2/2] fix: protect human tracking issues --- .../workflows/public_repo_license_audit.yaml | 4 ++ scripts/audit_public_repo_licenses.sh | 4 +- scripts/test_audit_public_repo_licenses.py | 65 ++++++++++++++++++- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/.github/workflows/public_repo_license_audit.yaml b/.github/workflows/public_repo_license_audit.yaml index f9534db..f061bb5 100644 --- a/.github/workflows/public_repo_license_audit.yaml +++ b/.github/workflows/public_repo_license_audit.yaml @@ -25,6 +25,8 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Test public repository license audit run: python3 -m unittest -v scripts/test_audit_public_repo_licenses.py @@ -38,6 +40,8 @@ jobs: issues: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Audit public repository licenses and update tracking issue env: diff --git a/scripts/audit_public_repo_licenses.sh b/scripts/audit_public_repo_licenses.sh index 3804a1f..c814e4a 100755 --- a/scripts/audit_public_repo_licenses.sh +++ b/scripts/audit_public_repo_licenses.sh @@ -4,6 +4,7 @@ set -euo pipefail readonly ORGANIZATIONS=(PickNikRobotics PickNikRoboticsServices) readonly TRACKING_REPO="${TRACKING_REPO:-PickNikRobotics/moveit_pro_ci}" readonly ISSUE_TITLE="Public repositories missing detected licenses" +readonly ISSUE_MARKER="" readonly MODE="${1:---check-only}" if ! SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"; then echo "::error::Unable to resolve the audit script directory." >&2 @@ -65,7 +66,7 @@ fi if ! issue_output="$( gh api --paginate "/repos/${TRACKING_REPO}/issues?state=open&per_page=100" \ - --jq ".[] | select(.pull_request == null and .title == \"${ISSUE_TITLE}\") | .number" + --jq ".[] | select(.pull_request == null and .title == \"${ISSUE_TITLE}\" and ((.body // \"\") | contains(\"${ISSUE_MARKER}\"))) | .number" )"; then echo "::error::Unable to query the tracking issue." >&2 exit 2 @@ -95,6 +96,7 @@ fi body_file="$(mktemp)" trap 'rm -f "$body_file"' EXIT { + echo "$ISSUE_MARKER" echo "[written by AI]" echo echo "The scheduled license audit found public, active, non-fork repositories whose root license is missing or not recognized by GitHub:" diff --git a/scripts/test_audit_public_repo_licenses.py b/scripts/test_audit_public_repo_licenses.py index 3eb3ee8..fc4f090 100644 --- a/scripts/test_audit_public_repo_licenses.py +++ b/scripts/test_audit_public_repo_licenses.py @@ -24,6 +24,7 @@ def setUp(self) -> None: import json import os from pathlib import Path + import subprocess import sys arguments = sys.argv[1:] @@ -40,7 +41,18 @@ def setUp(self) -> None: missing = json.loads(os.environ.get("MOCK_MISSING", "{}")) print("\\n".join(missing.get(organization, []))) elif "/issues?" in endpoint: - print(os.environ.get("MOCK_ISSUE_NUMBER", "")) + query = arguments[arguments.index("--jq") + 1] + result = subprocess.run( + ["jq", "-r", query], + check=False, + capture_output=True, + input=os.environ.get("MOCK_ISSUES", "[]"), + text=True, + ) + print(result.stdout, end="") + if result.returncode != 0: + print(result.stderr, end="", file=sys.stderr) + raise SystemExit(result.returncode) else: raise SystemExit(f"Unexpected API endpoint: {endpoint}") elif arguments[:2] == ["issue", "create"] or arguments[:2] == ["issue", "edit"]: @@ -165,23 +177,70 @@ def test_update_mode_creates_one_tracking_issue(self) -> None: self.assertEqual(result.returncode, 1) log = self.read_log() self.assertIn('["issue", "create"', log) + self.assertIn("", log) self.assertIn("[written by AI]", log) self.assertIn("- [ ] [PickNikRobotics/example]", log) self.assertIn("machine-owned body is replaced on every run", log) self.assertIn("record review notes and dispositions in issue comments", log) def test_update_mode_closes_existing_issue_after_remediation(self) -> None: - result = self.run_audit("--update-issue", MOCK_ISSUE_NUMBER="42") + issues = [ + { + "number": 42, + "title": "Public repositories missing detected licenses", + "body": "\n[written by AI]", + "pull_request": None, + } + ] + result = self.run_audit("--update-issue", MOCK_ISSUES=json.dumps(issues)) self.assertEqual(result.returncode, 0, result.stderr) log = self.read_log() self.assertIn('["issue", "comment", "42"', log) self.assertIn('["issue", "close", "42"', log) def test_update_mode_fails_on_duplicate_tracking_issues(self) -> None: - result = self.run_audit("--update-issue", MOCK_ISSUE_NUMBER="42\n43") + issues = [ + { + "number": number, + "title": "Public repositories missing detected licenses", + "body": "", + "pull_request": None, + } + for number in (42, 43) + ] + result = self.run_audit("--update-issue", MOCK_ISSUES=json.dumps(issues)) self.assertEqual(result.returncode, 2) self.assertIn("multiple open tracking issues", result.stderr) + def test_update_mode_does_not_mutate_same_title_human_issue(self) -> None: + missing = {"PickNikRobotics": ["PickNikRobotics/example"]} + issues = [ + { + "number": 42, + "title": "Public repositories missing detected licenses", + "body": body, + "pull_request": None, + } + for body in (None, "Human-authored issue body") + ] + result = self.run_audit( + "--update-issue", + MOCK_MISSING=json.dumps(missing), + MOCK_ISSUES=json.dumps(issues), + ) + self.assertEqual(result.returncode, 1) + log = self.read_log() + self.assertIn('["issue", "create"', log) + self.assertNotIn('["issue", "edit", "42"', log) + self.assertNotIn('["issue", "comment", "42"', log) + self.assertNotIn('["issue", "close", "42"', log) + + clean_result = self.run_audit("--update-issue", MOCK_ISSUES=json.dumps(issues)) + self.assertEqual(clean_result.returncode, 0) + clean_log = self.read_log() + self.assertNotIn('["issue", "comment", "42"', clean_log) + self.assertNotIn('["issue", "close", "42"', clean_log) + if __name__ == "__main__": unittest.main()