Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions .github/scripts/comment-licence-results.py
Original file line number Diff line number Diff line change
@@ -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 = "<!-- ort-licence-compliance -->"
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"<code>{content}</code>"


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()
21 changes: 21 additions & 0 deletions .github/workflows/licence-compliance-comment.yml
Original file line number Diff line number Diff line change
@@ -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
77 changes: 77 additions & 0 deletions .github/workflows/licence-compliance.yml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions .ort/evaluator.rules.kts
Original file line number Diff line number Diff line change
@@ -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
Loading