diff --git a/.github/scripts/comment-licence-results.py b/.github/scripts/comment-licence-results.py new file mode 100644 index 0000000..9c1a334 --- /dev/null +++ b/.github/scripts/comment-licence-results.py @@ -0,0 +1,153 @@ +"""Post a licence finding from an unprivileged ORT run on its pull request.""" + +import html +import io +import json +import os +import re +from pathlib import Path +from urllib.parse import urlparse +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen +from zipfile import ZipFile + + +MARKER = "" +API = "https://api.github.com" + + +def api(path, method="GET", body=None): + data = json.dumps(body).encode() if body is not None else None + request = Request( + API + path, + data=data, + method=method, + headers={ + "Authorization": f"Bearer {os.environ['GH_TOKEN']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + ) + with urlopen(request) as response: + return json.load(response) + + +def cell(value): + content = html.escape(str(value).replace("|", "\\|").replace("\n", " ")[:300]) + return f"{content}" + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, request, fp, code, message, headers, new_url): + return None + + +def result_files(repo, run_id): + artifacts = api(f"/repos/{repo}/actions/runs/{run_id}/artifacts?per_page=100")["artifacts"] + matches = [item for item in artifacts if item["name"] == "ort-licence-results" and not item["expired"]] + if not matches: + raise ValueError("ORT run has no results artifact") + + path = f"/repos/{repo}/actions/artifacts/{matches[0]['id']}/zip" + request = Request(API + path, headers={"Authorization": f"Bearer {os.environ['GH_TOKEN']}"}) + try: + build_opener(NoRedirect()).open(request) + except HTTPError as error: + if error.code != 302: + raise + location = error.headers["Location"] + else: + raise ValueError("GitHub did not redirect to the artifact archive") + if urlparse(location).scheme != "https": + raise ValueError("Artifact archive did not use HTTPS") + + with urlopen(location) as response: + payload = response.read(20_000_001) + if len(payload) > 20_000_000: + raise ValueError("ORT result artifact is unexpectedly large") + with ZipFile(io.BytesIO(payload)) as archive: + return { + name: archive.read(name) + for name in ("pr-number", "evaluation-result.json") + if name in archive.namelist() and archive.getinfo(name).file_size <= 10_000_000 + } + + +def failure_body(files, run_url): + lines = [MARKER, "## Licence compliance failed", ""] + if "evaluation-result.json" in files: + try: + violations = json.loads(files["evaluation-result.json"])["evaluator"]["violations"] + except (ValueError, KeyError, TypeError): + violations = None + if violations is None: + lines.append("ORT produced an unreadable evaluation result. Check the workflow log.") + elif violations: + lines.extend(["| Package | Licence | Finding |", "| --- | --- | --- |"]) + for violation in violations[:20]: + lines.append( + f"| {cell(violation['pkg'])} | {cell(violation.get('license') or 'Unknown')} " + f"| {cell(violation['message'])} |" + ) + if len(violations) > 20: + lines.append(f"\n{len(violations) - 20} more findings are in the workflow result.") + else: + lines.append("ORT failed without a policy violation. Check the workflow log.") + else: + lines.append("ORT did not produce an evaluation result. Check the workflow log.") + lines.extend(["", f"[View the ORT run]({run_url})"]) + return "\n".join(lines) + + +def main(): + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + run = event["workflow_run"] + if run["event"] != "pull_request" or run["conclusion"] not in {"success", "failure"}: + return + + repo = os.environ["GITHUB_REPOSITORY"] + files = result_files(repo, run["id"]) + raw_number = files["pr-number"].decode().strip() + if not re.fullmatch(r"[1-9][0-9]*", raw_number): + raise ValueError("Invalid pull request number in ORT artifact") + + number = int(raw_number) + associated_prs = run.get("pull_requests", []) + if associated_prs and number not in {item["number"] for item in associated_prs}: + raise ValueError("ORT artifact names a different pull request from the workflow run") + pr = api(f"/repos/{repo}/pulls/{number}") + if pr["base"]["repo"]["full_name"] != repo or run["head_sha"] not in { + pr["head"]["sha"], + pr["merge_commit_sha"], + }: + raise ValueError("ORT run does not match the pull request's current head") + + existing = None + page = 1 + while True: + comments = api(f"/repos/{repo}/issues/{number}/comments?per_page=100&page={page}") + for comment in comments: + if comment["user"]["login"] == "github-actions[bot]" and MARKER in comment["body"]: + existing = comment + break + if existing or len(comments) < 100: + break + page += 1 + + if run["conclusion"] == "failure": + run_url = f"https://github.com/{repo}/actions/runs/{run['id']}" + body = failure_body(files, run_url) + elif existing: + body = f"{MARKER}\n## Licence compliance passed\n\nThe current ORT run passed." + else: + return + + if existing: + api(f"/repos/{repo}/issues/comments/{existing['id']}", "PATCH", {"body": body}) + else: + api(f"/repos/{repo}/issues/{number}/comments", "POST", {"body": body}) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/licence-compliance-comment.yml b/.github/workflows/licence-compliance-comment.yml new file mode 100644 index 0000000..fa5ea3c --- /dev/null +++ b/.github/workflows/licence-compliance-comment.yml @@ -0,0 +1,21 @@ +name: Licence compliance PR comment +on: + workflow_run: + workflows: [Licence compliance pilot] + types: [completed] +permissions: + contents: read + actions: read + pull-requests: write +jobs: + comment: + if: github.event.workflow_run.event == 'pull_request' && (github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure') + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Update PR comment + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/scripts/comment-licence-results.py diff --git a/.github/workflows/licence-compliance.yml b/.github/workflows/licence-compliance.yml new file mode 100644 index 0000000..5baa434 --- /dev/null +++ b/.github/workflows/licence-compliance.yml @@ -0,0 +1,77 @@ +name: Licence compliance pilot +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + licence-compliance: + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Record PR for the comment workflow + if: github.event_name == 'pull_request' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + mkdir -p "$RUNNER_TEMP/ort-results" + printf '%s\n' "$PR_NUMBER" > "$RUNNER_TEMP/ort-results/pr-number" + - name: Analyse NuGet dependencies with ORT + run: | + mkdir -p "$RUNNER_TEMP/ort-results" "$RUNNER_TEMP/ort-project" "$RUNNER_TEMP/ort-home" + cp -a "$GITHUB_WORKSPACE/." "$RUNNER_TEMP/ort-project/" + docker run --rm --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$RUNNER_TEMP/ort-project,target=/project" \ + --mount "type=bind,source=$RUNNER_TEMP/ort-results,target=/results" \ + --mount "type=bind,source=$RUNNER_TEMP/ort-home,target=/home/ort" \ + ghcr.io/oss-review-toolkit/ort:92.0.0@sha256:b756f9ae0b8d6b435012a1b7097d4e419d327029cd8daeb36c7f400af9b51d31 \ + --warn analyze -i /project -o /results -f JSON + - name: Evaluate licence policy + run: | + set +e + docker run --rm --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$RUNNER_TEMP/ort-project,target=/project,readonly" \ + --mount "type=bind,source=$RUNNER_TEMP/ort-results,target=/results" \ + --mount "type=bind,source=$RUNNER_TEMP/ort-home,target=/home/ort" \ + ghcr.io/oss-review-toolkit/ort:92.0.0@sha256:b756f9ae0b8d6b435012a1b7097d4e419d327029cd8daeb36c7f400af9b51d31 \ + --warn evaluate -i /results/analyzer-result.json -o /results -f JSON \ + --rules-file /project/.ort/evaluator.rules.kts + result=$? + set -e + python3 - <<'PY' + import json + import os + from pathlib import Path + + results = Path(os.environ["RUNNER_TEMP"]) / "ort-results" + analysis = json.loads((results / "analyzer-result.json").read_text()) + evaluation = results / "evaluation-result.json" + packages = analysis["analyzer"]["result"]["packages"] + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: + summary.write(f"## ORT licence review\n\n{len(packages)} packages analysed.\n\n") + if evaluation.exists(): + violations = json.loads(evaluation.read_text())["evaluator"]["violations"] + if violations: + summary.write("| Package | Licence | Finding |\n| --- | --- | --- |\n") + for violation in violations: + package = violation["pkg"] + licence = violation.get("license") or "Unknown" + message = violation["message"] + summary.write(f"| {package} | {licence} | {message} |\n") + else: + summary.write("All package licences matched the pilot allowlist.\n") + else: + summary.write("ORT did not produce an evaluation result. Check the job log.\n") + PY + exit "$result" + - uses: actions/upload-artifact@v7.0.2 + if: always() + with: + name: ort-licence-results + path: | + ${{ runner.temp }}/ort-results/*.json + ${{ runner.temp }}/ort-results/pr-number + if-no-files-found: warn diff --git a/.ort/evaluator.rules.kts b/.ort/evaluator.rules.kts new file mode 100644 index 0000000..0acc8f4 --- /dev/null +++ b/.ort/evaluator.rules.kts @@ -0,0 +1,48 @@ +val allowedLicences = setOf( + "MIT", + "Apache-2.0", + "0BSD", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "ISC", + "Zlib" +).map { it.toSpdx() as SpdxSingleLicenseExpression }.toSet() + +fun PackageRule.LicenseRule.isOutsideAllowlist() = object : RuleMatcher { + override val description = "isOutsideAllowlist($license)" + override fun matches() = license !in allowedLicences +} + +val licenceRules = ruleSet(ortResult, licenseInfoResolver, resolutionProvider) { + packageRule("UNAPPROVED_PACKAGE_LICENCE") { + require { + -isExcluded() + } + + // ORT also visits the repository's project entries, which are not consumed packages. + if (pkg.metadata.id.type == "NuGet" && pkg.metadata.id.version.isNotEmpty()) { + if (pkg.metadata.declaredLicensesProcessed.spdxExpression == null) { + error( + "No recognised licence was declared for ${pkg.metadata.id.toCoordinates()}.", + "Review the package licence and record a justified curation before approving it." + ) + } + + licenseRule("UNAPPROVED_PACKAGE_LICENCE", LicenseView.CONCLUDED_OR_DECLARED_OR_DETECTED) { + require { + -isExcluded() + +isOutsideAllowlist() + } + + error( + "The licence $license for ${pkg.metadata.id.toCoordinates()} is outside the pilot allowlist.", + "Review the licence before adding it to the allowlist or record a justified exception." + ) + } + } + } +} + +ruleViolations += licenceRules.violations