This is an automated email from the ASF dual-hosted git repository. tqchen pushed a commit to branch jenkins-cleanup-trusted-main in repository https://gitbox.apache.org/repos/asf/tvm.git
commit bd5449872b507f93b215f8e6044489406b532c5a Author: Tianqi Chen <[email protected]> AuthorDate: Thu Oct 15 02:16:59 2026 +0000 [REFACTOR][CI] Retire stale scripts and recover rejected helpers from main --- ci/README.md | 3 +- ci/jenkins/README.md | 45 +- ci/jenkins/generated/arm_jenkinsfile.groovy | 39 +- ci/jenkins/generated/cpu_jenkinsfile.groovy | 41 +- ci/jenkins/generated/docker_jenkinsfile.groovy | 39 +- ci/jenkins/generated/gpu_jenkinsfile.groovy | 39 +- ci/jenkins/generated/wasm_jenkinsfile.groovy | 39 +- ci/scripts/github/__init__.py | 18 - ci/scripts/github/github_tvmbot.py | 799 --------------------- ci/scripts/jenkins/cmd_utils.py | 107 --- ci/scripts/jenkins/determine_docker_images.py | 153 ---- ci/scripts/jenkins/git_change_docker.sh | 55 -- ci/scripts/jenkins/git_change_docs.sh | 39 - ci/scripts/jenkins/git_skip_ci.py | 61 -- ci/scripts/jenkins/git_skip_ci_globs.py | 76 -- ci/scripts/jenkins/git_utils.py | 212 ------ ci/scripts/jenkins/http_utils.py | 34 - ci/scripts/jenkins/retry.sh | 39 - ci/scripts/jenkins/s3.py | 193 ----- ci/scripts/jenkins/should_rebuild_docker.py | 152 ---- ci/scripts/jenkins/should_run_slow_tests.py | 85 --- ci/scripts/package/README.md | 31 - ci/scripts/package/build-environment.yaml | 42 -- .../package/manylinux_build_libtvm_runtime_cuda.sh | 64 -- .../package/windows_build_libtvm_runtime_cuda.bat | 99 --- tests/python/ci/jenkins_loader_test.groovy | 94 +++ tests/python/ci/test_ci.py | 57 +- tests/python/ci/test_jenkins_helpers.py | 8 +- tests/python/ci/test_jenkins_loader.py | 106 +++ tests/scripts/task_show_node_info.sh | 44 -- 30 files changed, 418 insertions(+), 2395 deletions(-) diff --git a/ci/README.md b/ci/README.md index 163d600dcd..292fdddd61 100644 --- a/ci/README.md +++ b/ci/README.md @@ -27,8 +27,7 @@ TVM project in a healthy state and preventing breakages. CI in TVM is broken int - Definitions of test suites, with each suite defined as a separate `task_` script in [`tests/scripts`](../tests/scripts). - GitHub automation [`ci/github`](github), wheel builds [`ci/wheel`](wheel), - and Jenkins host commands [`ci/jenkins/scripts`](jenkins/scripts). Legacy - [`ci/scripts`](scripts) paths remain for older trusted Jenkinsfiles used by PR jobs. + and Jenkins host commands [`ci/jenkins/scripts`](jenkins/scripts). - The linux test sequence (in [`Jenkinsfile`](../ci/jenkins/generated/)), which lints and builds TVM and runs test suites using Docker on Linux. - The Windows and Mac test sequences (in [`.github/actions`](../.github/actions)). diff --git a/ci/jenkins/README.md b/ci/jenkins/README.md index 052260d4ef..495ff64cb8 100644 --- a/ci/jenkins/README.md +++ b/ci/jenkins/README.md @@ -54,32 +54,35 @@ and the standalone skip commands retain their command-line interfaces. ### Trust and rollout -Each entry point evaluates the shared helper returned by `readTrusted` at -Jenkins' selected SCM revision. Untrusted PRs that modify or delete the helper -fail immediately, before node allocation, checkout, or credentials. Missing -helpers, interrupted reads, and evaluation errors also propagate directly. -No alternative helper is fetched or evaluated. - -Trusted helper changes use Jenkins' selected revision directly. Cancellation -runs once before preparation. During preparation, the normal source merge pins -the PR target revision for subsequent stages, including release targets. -Host scripts and artifact metadata follow that pinned target revision for PR -authors outside `CONTRIBUTORS.md`. - -The legacy `ci/scripts/` hierarchy and `tests/scripts/task_show_node_info.sh` -remain alongside the new paths because fork PR jobs can select older trusted -Jenkinsfiles from their target branch. Those files keep the old docs-only and -slow-test gates and their helper imports; the native pipelines use the new -layout without those gates. Both layouts share the artifact registry and image -configuration. Keep the legacy commands until the trusted targets no longer -call them. Use a trusted branch build to exercise native helper changes; do not -bypass a refused read by evaluating PR-owned code or a different revision. +Each entry point first reads the shared helper with `readTrusted` at Jenkins' +selected SCM revision. Trusted named branches retain their own helper. For PRs, +Jenkins rejects an untrusted helper modification and may report a deleted helper +as missing. These specific read failures recover by fetching `refs/heads/main` +from the fixed `https://github.com/apache/tvm.git` URL into a fresh temporary bare +repository on a small CPU node. The fetched commit is resolved and its helper +blob is read directly, without checking out or evaluating PR-owned code. Recovery +always uses latest upstream main, including for release-target PRs. + +Other read failures, cancellation, fallback fetch/read failures and helper +evaluation errors propagate. Evaluation occurs outside the read-recovery block. +The fallback requires no credentials or controller configuration changes. + +Cancellation runs once before preparation. During preparation, the normal source +merge pins the PR target revision for subsequent stages, including release +targets. Host scripts and artifact metadata follow that pinned target revision +for PR authors outside `CONTRIBUTORS.md`. + +The obsolete `ci/scripts/` copies and old node-info path have been removed now +that main's Jenkinsfiles use the canonical layout. The live `task_build.py`, +source-mode test scripts and artifact interfaces remain available. Older target +branches must retain their matching legacy commands; they are not supplied by +this checkout. Before enabling the migration on production jobs, validate it with the existing Jenkins plugin versions and sandbox settings on a trusted branch: run all five entry points, suspend/resume across a node step, and exercise spot loss, a normal build-command failure, superseded builds, and a failed on-demand retry. Check -trusted helper changes, untrusted PRs without helper changes, and refusal of +trusted helper changes, untrusted PRs without helper changes, and recovery of untrusted modified/deleted helpers, including a release-target PR. Verify image overrides and disabled deployment gates before checking enabled docs/Docker deployment in the normal trusted deployment context. diff --git a/ci/jenkins/generated/arm_jenkinsfile.groovy b/ci/jenkins/generated/arm_jenkinsfile.groovy index 1b7c0d733d..e34ad8fdf2 100644 --- a/ci/jenkins/generated/arm_jenkinsfile.groovy +++ b/ci/jenkins/generated/arm_jenkinsfile.groovy @@ -15,8 +15,43 @@ // specific language governing permissions and limitations // under the License. -// Reject untrusted helper edits before allocating nodes or running pipeline steps. -ci = evaluate(readTrusted('ci/jenkins/generated/common.groovy')) +// Keep trusted branches on their selected helper; recover rejected PR helpers +// only from the fixed upstream main ref. Never evaluate the checkout's copy. +def helperPath = 'ci/jenkins/generated/common.groovy' +def helperSource +try { + helperSource = readTrusted(helperPath) +} catch (hudson.AbortException failure) { + def rejected = failure.message == "‘${helperPath}’ has been modified in an untrusted revision" + def missing = failure.message?.endsWith("/${helperPath} not found") + if (!env.CHANGE_ID || !(rejected || missing)) { + throw failure + } +} catch (java.io.FileNotFoundException failure) { + if (!env.CHANGE_ID || failure.message != helperPath) { + throw failure + } +} +if (helperSource == null) { + node('CPU-SMALL') { + helperSource = sh( + returnStdout: true, + label: 'Read trusted helper from upstream main', + script: ''' + set -eu + helper_repo=$(mktemp -d) + trap 'rm -rf "$helper_repo"' EXIT + git init --bare --quiet "$helper_repo" + git --git-dir="$helper_repo" fetch --quiet --no-tags --depth=1 \ + https://github.com/apache/tvm.git +refs/heads/main:refs/heads/trusted-main + revision=$(git --git-dir="$helper_repo" rev-parse --verify 'refs/heads/trusted-main^{commit}') + git --git-dir="$helper_repo" show "$revision:ci/jenkins/generated/common.groovy" + ''' + ) + } +} +// Evaluation and subsequent pipeline failures must never trigger a fallback. +ci = evaluate(helperSource) ci.start() ci.build('ARM-GRAVITON3', 'BUILD: arm', 'tvm/build-arm', images.ci_arm, 'arm') { diff --git a/ci/jenkins/generated/cpu_jenkinsfile.groovy b/ci/jenkins/generated/cpu_jenkinsfile.groovy index 02c2c234b4..119fd242a6 100644 --- a/ci/jenkins/generated/cpu_jenkinsfile.groovy +++ b/ci/jenkins/generated/cpu_jenkinsfile.groovy @@ -15,8 +15,43 @@ // specific language governing permissions and limitations // under the License. -// Reject untrusted helper edits before allocating nodes or running pipeline steps. -ci = evaluate(readTrusted('ci/jenkins/generated/common.groovy')) +// Keep trusted branches on their selected helper; recover rejected PR helpers +// only from the fixed upstream main ref. Never evaluate the checkout's copy. +def helperPath = 'ci/jenkins/generated/common.groovy' +def helperSource +try { + helperSource = readTrusted(helperPath) +} catch (hudson.AbortException failure) { + def rejected = failure.message == "‘${helperPath}’ has been modified in an untrusted revision" + def missing = failure.message?.endsWith("/${helperPath} not found") + if (!env.CHANGE_ID || !(rejected || missing)) { + throw failure + } +} catch (java.io.FileNotFoundException failure) { + if (!env.CHANGE_ID || failure.message != helperPath) { + throw failure + } +} +if (helperSource == null) { + node('CPU-SMALL') { + helperSource = sh( + returnStdout: true, + label: 'Read trusted helper from upstream main', + script: ''' + set -eu + helper_repo=$(mktemp -d) + trap 'rm -rf "$helper_repo"' EXIT + git init --bare --quiet "$helper_repo" + git --git-dir="$helper_repo" fetch --quiet --no-tags --depth=1 \ + https://github.com/apache/tvm.git +refs/heads/main:refs/heads/trusted-main + revision=$(git --git-dir="$helper_repo" rev-parse --verify 'refs/heads/trusted-main^{commit}') + git --git-dir="$helper_repo" show "$revision:ci/jenkins/generated/common.groovy" + ''' + ) + } +} +// Evaluation and subsequent pipeline failures must never trigger a fallback. +ci = evaluate(helperSource) ci.start() ci.build('CPU', 'BUILD: CPU', 'tvm/build-cpu', images.ci_cpu, 'cpu') { @@ -31,7 +66,7 @@ ci.build('CPU', 'BUILD: CPU', 'tvm/build-cpu', images.ci_cpu, 'cpu') { stage('Test') { parallel('unittest: CPU': { - ci.test('CPU-SMALL', 'unittest: CPU', 'tvm/ut-python-cpu', images.ci_cpu, 'cpu') { + ci.test('CPU', 'unittest: CPU', 'tvm/ut-python-cpu', images.ci_cpu, 'cpu') { ci.download('cpu') ci.cpp_unittest(images.ci_cpu) ci.python_unittest(images.ci_cpu) diff --git a/ci/jenkins/generated/docker_jenkinsfile.groovy b/ci/jenkins/generated/docker_jenkinsfile.groovy index 0039be7712..b4b67852ba 100644 --- a/ci/jenkins/generated/docker_jenkinsfile.groovy +++ b/ci/jenkins/generated/docker_jenkinsfile.groovy @@ -15,8 +15,43 @@ // specific language governing permissions and limitations // under the License. -// Reject untrusted helper edits before allocating nodes or running pipeline steps. -ci = evaluate(readTrusted('ci/jenkins/generated/common.groovy')) +// Keep trusted branches on their selected helper; recover rejected PR helpers +// only from the fixed upstream main ref. Never evaluate the checkout's copy. +def helperPath = 'ci/jenkins/generated/common.groovy' +def helperSource +try { + helperSource = readTrusted(helperPath) +} catch (hudson.AbortException failure) { + def rejected = failure.message == "‘${helperPath}’ has been modified in an untrusted revision" + def missing = failure.message?.endsWith("/${helperPath} not found") + if (!env.CHANGE_ID || !(rejected || missing)) { + throw failure + } +} catch (java.io.FileNotFoundException failure) { + if (!env.CHANGE_ID || failure.message != helperPath) { + throw failure + } +} +if (helperSource == null) { + node('CPU-SMALL') { + helperSource = sh( + returnStdout: true, + label: 'Read trusted helper from upstream main', + script: ''' + set -eu + helper_repo=$(mktemp -d) + trap 'rm -rf "$helper_repo"' EXIT + git init --bare --quiet "$helper_repo" + git --git-dir="$helper_repo" fetch --quiet --no-tags --depth=1 \ + https://github.com/apache/tvm.git +refs/heads/main:refs/heads/trusted-main + revision=$(git --git-dir="$helper_repo" rev-parse --verify 'refs/heads/trusted-main^{commit}') + git --git-dir="$helper_repo" show "$revision:ci/jenkins/generated/common.groovy" + ''' + ) + } +} +// Evaluation and subsequent pipeline failures must never trigger a fallback. +ci = evaluate(helperSource) ci.start() def ecr_push(full_name) { diff --git a/ci/jenkins/generated/gpu_jenkinsfile.groovy b/ci/jenkins/generated/gpu_jenkinsfile.groovy index ea49bfab06..47ccb0b5c0 100644 --- a/ci/jenkins/generated/gpu_jenkinsfile.groovy +++ b/ci/jenkins/generated/gpu_jenkinsfile.groovy @@ -15,8 +15,43 @@ // specific language governing permissions and limitations // under the License. -// Reject untrusted helper edits before allocating nodes or running pipeline steps. -ci = evaluate(readTrusted('ci/jenkins/generated/common.groovy')) +// Keep trusted branches on their selected helper; recover rejected PR helpers +// only from the fixed upstream main ref. Never evaluate the checkout's copy. +def helperPath = 'ci/jenkins/generated/common.groovy' +def helperSource +try { + helperSource = readTrusted(helperPath) +} catch (hudson.AbortException failure) { + def rejected = failure.message == "‘${helperPath}’ has been modified in an untrusted revision" + def missing = failure.message?.endsWith("/${helperPath} not found") + if (!env.CHANGE_ID || !(rejected || missing)) { + throw failure + } +} catch (java.io.FileNotFoundException failure) { + if (!env.CHANGE_ID || failure.message != helperPath) { + throw failure + } +} +if (helperSource == null) { + node('CPU-SMALL') { + helperSource = sh( + returnStdout: true, + label: 'Read trusted helper from upstream main', + script: ''' + set -eu + helper_repo=$(mktemp -d) + trap 'rm -rf "$helper_repo"' EXIT + git init --bare --quiet "$helper_repo" + git --git-dir="$helper_repo" fetch --quiet --no-tags --depth=1 \ + https://github.com/apache/tvm.git +refs/heads/main:refs/heads/trusted-main + revision=$(git --git-dir="$helper_repo" rev-parse --verify 'refs/heads/trusted-main^{commit}') + git --git-dir="$helper_repo" show "$revision:ci/jenkins/generated/common.groovy" + ''' + ) + } +} +// Evaluation and subsequent pipeline failures must never trigger a fallback. +ci = evaluate(helperSource) ci.start() ci.build('CPU', 'BUILD: GPU', 'tvm/build-gpu', images.ci_gpu, 'gpu') { diff --git a/ci/jenkins/generated/wasm_jenkinsfile.groovy b/ci/jenkins/generated/wasm_jenkinsfile.groovy index ddf932529d..ac4b075721 100644 --- a/ci/jenkins/generated/wasm_jenkinsfile.groovy +++ b/ci/jenkins/generated/wasm_jenkinsfile.groovy @@ -15,8 +15,43 @@ // specific language governing permissions and limitations // under the License. -// Reject untrusted helper edits before allocating nodes or running pipeline steps. -ci = evaluate(readTrusted('ci/jenkins/generated/common.groovy')) +// Keep trusted branches on their selected helper; recover rejected PR helpers +// only from the fixed upstream main ref. Never evaluate the checkout's copy. +def helperPath = 'ci/jenkins/generated/common.groovy' +def helperSource +try { + helperSource = readTrusted(helperPath) +} catch (hudson.AbortException failure) { + def rejected = failure.message == "‘${helperPath}’ has been modified in an untrusted revision" + def missing = failure.message?.endsWith("/${helperPath} not found") + if (!env.CHANGE_ID || !(rejected || missing)) { + throw failure + } +} catch (java.io.FileNotFoundException failure) { + if (!env.CHANGE_ID || failure.message != helperPath) { + throw failure + } +} +if (helperSource == null) { + node('CPU-SMALL') { + helperSource = sh( + returnStdout: true, + label: 'Read trusted helper from upstream main', + script: ''' + set -eu + helper_repo=$(mktemp -d) + trap 'rm -rf "$helper_repo"' EXIT + git init --bare --quiet "$helper_repo" + git --git-dir="$helper_repo" fetch --quiet --no-tags --depth=1 \ + https://github.com/apache/tvm.git +refs/heads/main:refs/heads/trusted-main + revision=$(git --git-dir="$helper_repo" rev-parse --verify 'refs/heads/trusted-main^{commit}') + git --git-dir="$helper_repo" show "$revision:ci/jenkins/generated/common.groovy" + ''' + ) + } +} +// Evaluation and subsequent pipeline failures must never trigger a fallback. +ci = evaluate(helperSource) ci.start() ci.build('CPU', 'BUILD: WASM', 'tvm/build-wasm', images.ci_wasm, 'wasm') { diff --git a/ci/scripts/github/__init__.py b/ci/scripts/github/__init__.py deleted file mode 100644 index fcdda82fc0..0000000000 --- a/ci/scripts/github/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -# isort: skip_file -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -"""Package to enable testing of GitHub scripts.""" diff --git a/ci/scripts/github/github_tvmbot.py b/ci/scripts/github/github_tvmbot.py deleted file mode 100755 index 6975bc9d35..0000000000 --- a/ci/scripts/github/github_tvmbot.py +++ /dev/null @@ -1,799 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ruff: noqa: E402, E501, F841, RUF012 - -import argparse -import json -import logging -import os -import re -import sys -import traceback -import warnings -from collections.abc import Callable -from pathlib import Path -from typing import Any - -# Hackery to enable importing of utils from ci/scripts/jenkins -REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent -sys.path.append(str(REPO_ROOT / "ci" / "scripts" / "jenkins")) - -from cmd_utils import init_log -from git_utils import GitHubRepo, git, parse_remote, post - -Review = dict[str, Any] -CIJob = dict[str, Any] -Comment = dict[str, Any] -CommentChecker = Callable[[Comment], bool] - -EXPECTED_JOBS = ["tvm-ci/pr-head"] -TVM_BOT_JENKINS_TOKEN = os.environ["TVM_BOT_JENKINS_TOKEN"] -GH_ACTIONS_TOKEN = os.environ["GH_ACTIONS_TOKEN"] -JENKINS_URL = "https://ci.tlcpack.ai/" -THANKS_MESSAGE = r"(\s*)Thanks for contributing to TVM! Please refer to guideline https://tvm.apache.org/docs/contribute/ for useful information and tips. After the pull request is submitted, please request code reviews from \[Reviewers\]\(https://github.com/apache/incubator-tvm/blob/master/CONTRIBUTORS.md#reviewers\) by them in the pull request thread.(\s*)" - - -def to_json_str(obj: Any) -> str: - return json.dumps(obj, indent=2) - - -COLLABORATORS_QUERY = """ -query ($owner: String!, $name: String!, $user: String!) { - repository(owner: $owner, name: $name) { - collaborators(query: $user, first: 100) { - nodes { - login - } - } - } -} -""" - -MENTIONABLE_QUERY = """ -query ($owner: String!, $name: String!, $user: String!) { - repository(owner: $owner, name: $name) { - mentionableUsers(query: $user, first: 100) { - nodes { - login - } - } - } -} -""" - - -PR_QUERY = """ - query ($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - title - body - state - author { - login - } - comments(last: 100) { - pageInfo { - hasPreviousPage - } - nodes { - authorAssociation - author { - login - } - id - updatedAt - body - } - } - authorCommits:commits(last:100) { - nodes { - commit { - authors(first:100) { - nodes { - name - email - } - } - } - } - } - commits(last: 1) { - nodes { - commit { - oid - statusCheckRollup { - contexts(first: 100) { - pageInfo { - hasNextPage - } - nodes { - ... on CheckRun { - name - databaseId - checkSuite { - workflowRun { - databaseId - workflow { - name - } - } - } - status - conclusion - url - } - ... on StatusContext { - state - context - targetUrl - } - } - } - } - } - } - } - reviewDecision - reviews(last: 100) { - pageInfo { - hasPreviousPage - } - nodes { - body - updatedAt - url - id - authorCanPushToRepository - commit { - oid - } - author { - login - } - state - } - } - } - } - } - """ - - -def walk(obj, visitor, parent_key=None): - """ - Recursively call 'visitor' on all the children of a dictionary - """ - visitor(obj, parent_key) - if isinstance(obj, dict): - for k, v in obj.items(): - walk(v, visitor, parent_key=k) - elif isinstance(obj, list): - for v in obj: - walk(v, visitor) - - -class PR: - def __init__( - self, - number: int, - owner: str, - repo: str, - dry_run: bool = False, - raw_data: dict[str, Any] | None = None, - ): - self.owner = owner - self.number = number - self.repo_name = repo - self.dry_run = dry_run - self.has_error = False - - if dry_run and raw_data: - # In test mode there is no need to fetch anything - self.raw = raw_data - self.github = None - else: - self.github = GitHubRepo(user=owner, repo=repo, token=os.environ["GITHUB_TOKEN"]) - if os.getenv("DEBUG", "0") == "1": - # For local runs fill in the requested data but cache it for - # later use - cached_path = Path("pr.json") - if not cached_path.exists(): - self.raw = self.fetch_data() - with open(cached_path, "w") as f: - json.dump(self.raw, f, indent=2) - else: - with open(cached_path) as f: - self.raw = json.load(f) - else: - # Usual path, fetch the PR's data based on the number from - # GitHub - self.raw = self.fetch_data() - - def checker(obj, parent_key): - """ - Verify that any paged results don't have extra data (if so the bot - may still work since most relevant comments will be more recent) - """ - if parent_key == "pageInfo": - if obj.get("hasPreviousPage", False): - warnings.warn(f"Found {obj} with a previous page, bot may be missing data") - if obj.get("hasNextPage", False): - warnings.warn(f"Found {obj} with a next page, bot may be missing data") - - walk(self.raw, checker) - - logging.info(f"Verified data, running with PR {to_json_str(self.raw)}") - - def __repr__(self): - return json.dumps(self.raw, indent=2) - - def react(self, comment: dict[str, Any], content: str): - """ - React with a thumbs up to a comment - """ - url = f"issues/comments/{comment['id']}/reactions" - data = {"content": content} - if self.dry_run: - logging.info(f"Dry run, would have +1'ed to {url} with {data}") - else: - self.github.post(url, data=data) - - def head_commit(self): - return self.raw["commits"]["nodes"][0]["commit"] - - def co_authors(self) -> list[str]: - authors = [] - for commit in self.raw["authorCommits"]["nodes"]: - # Co-authors always come after the main author according to the - # GitHub docs, so ignore the first item - for author in commit["commit"]["authors"]["nodes"][1:]: - name = author["name"] - email = author["email"] - authors.append(f"{name} <{email}>") - - return list(set(authors)) - - def head_oid(self): - return self.head_commit()["oid"] - - def ci_jobs(self) -> list[CIJob]: - """ - Get a list of all CI jobs (GitHub Actions and other) in a unified format - """ - jobs = [] - for item in self.head_commit()["statusCheckRollup"]["contexts"]["nodes"]: - if "checkSuite" in item: - # GitHub Actions job, parse separately - status = item["conclusion"] - if status is None: - # If the 'conclusion' isn't filled out the job hasn't - # finished yet - status = "PENDING" - workflow_name = item["checkSuite"]["workflowRun"]["workflow"]["name"] - if workflow_name != "CI": - # Ignore all jobs that aren't in the main.yml workflow (these are mostly - # automation jobs that run on PRs for tagging / reviews) - continue - check_name = item["name"] - jobs.append( - { - "name": f"{workflow_name} / {check_name}", - "url": item["url"], - "status": status.upper(), - } - ) - else: - # GitHub Status (e.g. from Jenkins) - jobs.append( - { - "name": item["context"], - "url": item["targetUrl"], - "status": item["state"].upper(), - } - ) - - logging.info(f"Found CI jobs for {self.head_commit()['oid']} {to_json_str(jobs)}") - return jobs - - def reviews(self) -> list[Review]: - return self.raw["reviews"]["nodes"] - - def head_commit_reviews(self) -> list[Review]: - """ - Find reviews associated with the head commit - """ - commits_to_review_status: dict[str, list[Review]] = {} - - for review in self.reviews(): - if not review["authorCanPushToRepository"]: - # ignore reviews from non-committers - continue - - oid = review["commit"]["oid"] - if oid in commits_to_review_status: - commits_to_review_status[oid].append(review) - else: - commits_to_review_status[oid] = [review] - - # Only use the data for the head commit of the PR - head_reviews = commits_to_review_status.get(self.head_oid(), []) - return head_reviews - - def fetch_data(self): - """ - Fetch the data for this PR from GitHub - """ - return self.github.graphql( - query=PR_QUERY, - variables={ - "owner": self.owner, - "name": self.repo_name, - "number": self.number, - }, - )["data"]["repository"]["pullRequest"] - - def search_collaborator(self, user: str) -> list[dict[str, Any]]: - """ - Query GitHub for collaborators matching 'user' - """ - return self.search_users(user, COLLABORATORS_QUERY)["collaborators"]["nodes"] - - def search_users(self, user: str, query: str) -> list[dict[str, Any]]: - return self.github.graphql( - query=query, - variables={ - "owner": self.owner, - "name": self.repo_name, - "user": user, - }, - )["data"]["repository"] - - def search_mentionable_users(self, user: str) -> list[dict[str, Any]]: - return self.search_users(user, MENTIONABLE_QUERY)["mentionableUsers"]["nodes"] - - def comment(self, text: str) -> None: - """ - Leave the comment 'text' on this PR - """ - logging.info(f"Commenting:\n{text}") - # TODO: Update latest comment in-place if there has been no activity - data = {"body": text} - url = f"issues/{self.number}/comments" - if self.dry_run: - logging.info( - f"Dry run, would have commented on url={url} commenting with data={to_json_str(data)}" - ) - return - - self.github.post(url, data=data) - - def state(self) -> str: - """ - PR state (OPEN, CLOSED, MERGED, etc) - """ - return self.raw["state"] - - def processed_body(self) -> str: - body = self.raw["body"].strip().replace("\r", "") - # Remove any @-mentions of people - body = re.sub(r"(\s)@", r"\g<1>", body) - - # Remove the auto-inserted text since it's not useful to have in the commit log - body = re.sub(THANKS_MESSAGE, "\n\n", body) - return body.strip() - - def body_with_co_authors(self) -> str: - """ - Add 'Co-authored-by' strings to the PR body based on the prior commits - in the PR - """ - body = self.processed_body() - author_lines = self.co_authors() - logging.info(f"Found co-authors: author_lines={author_lines}") - full_author_lines = [f"Co-authored-by: {author_line}" for author_line in author_lines] - - authors_to_add = [] - for author_line in author_lines: - if author_line not in body: - authors_to_add.append(f"Co-authored-by: {author_line}") - - if len(authors_to_add) > 0: - # If the line isn't already in the PR body (it could have been - # added manually), put it in - full_author_text = "\n".join(authors_to_add) - body = f"{body}\n\n{full_author_text}" - - return body - - def merge(self) -> None: - """ - Request a merge of this PR via the GitHub API - """ - url = f"pulls/{self.number}/merge" - - title = self.raw["title"] + f" (#{self.number})" - body = self.body_with_co_authors() - logging.info(f"Full commit:\n{title}\n\n{body}") - - data = { - "commit_title": title, - "commit_message": body, - # The SHA is necessary in case there was an update right when this - # script ran, GitHub will sort out who won - "sha": self.head_oid(), - "merge_method": "squash", - } - if self.dry_run: - logging.info(f"Dry run, would have merged with url={url} and data={to_json_str(data)}") - return - - r = self.github.put(url, data=data) - logging.info(f"GitHub merge response: {r}") - return r - - def author(self) -> str: - return self.raw["author"]["login"] - - def find_failed_ci_jobs(self) -> list[CIJob]: - # NEUTRAL is GitHub Action's way of saying cancelled - return [ - job - for job in self.ci_jobs() - if job["status"] not in {"SUCCESS", "SUCCESSFUL", "SKIPPED"} - ] - - def find_missing_expected_jobs(self) -> list[str]: - # Map of job name: has seen in completed jobs - seen_expected_jobs = {name: False for name in EXPECTED_JOBS} - logging.info(f"Expected to see jobs: {seen_expected_jobs}") - - missing_expected_jobs = [] - for job in self.ci_jobs(): - seen_expected_jobs[job["name"]] = True - - for name, seen in seen_expected_jobs.items(): - if not seen: - missing_expected_jobs.append(name) - - return missing_expected_jobs - - def trigger_gha_ci(self, sha: str) -> None: - logging.info("POST-ing a workflow_dispatch event to main.yml") - actions_github = GitHubRepo( - user=self.github.user, repo=self.github.repo, token=GH_ACTIONS_TOKEN - ) - r = actions_github.post( - url="actions/workflows/main.yml/dispatches", - data={ - "ref": "main", - }, - ) - logging.info(f"Successful workflow_dispatch: {r}") - - def merge_if_passed_checks(self) -> dict[str, Any] | None: - failed_ci_jobs = self.find_failed_ci_jobs() - all_ci_passed = len(failed_ci_jobs) == 0 - has_one_approval = False - - if not all_ci_passed: - failed_jobs_msg = "\n".join( - [f" * [{job['name']} (`{job['status']}`)]({job['url']})" for job in failed_ci_jobs] - ) - self.comment( - f"Cannot merge, these CI jobs are not successful on {self.head_oid()}:\n{failed_jobs_msg}" - ) - return None - - missing_expected_jobs = self.find_missing_expected_jobs() - - if len(missing_expected_jobs) > 0: - missing_jobs_msg = "\n".join([f" * `{name}`" for name in missing_expected_jobs]) - self.comment(f"Cannot merge, missing expected jobs:\n{missing_jobs_msg}") - return None - - head_commit_reviews = self.head_commit_reviews() - for review in head_commit_reviews: - if review["state"] == "CHANGES_REQUESTED": - self.comment( - f"Cannot merge, found [this review]({review['url']}) on {self.head_oid()} with changes requested" - ) - return None - - if review["state"] == "APPROVED": - has_one_approval = True - logging.info(f"Found approving review: {to_json_str(review)}") - - if has_one_approval and all_ci_passed: - return self.merge() - elif not has_one_approval: - self.comment( - f"Cannot merge, did not find any approving reviews from users with write access on {self.head_oid()}" - ) - return None - elif not all_ci_passed: - self.comment(f"Cannot merge, CI did not pass on on {self.head_oid()}") - return None - - def rerun_jenkins_ci(self) -> None: - job_names = [ - "tvm-arm", - "tvm-cpu", - "tvm-docker", - "tvm-gpu", - "tvm-wasm", - ] - for name in job_names: - url = JENKINS_URL + f"job/{name}/job/PR-{self.number}/buildWithParameters" - logging.info(f"Rerunning ci with URL={url}") - if self.dry_run: - logging.info("Dry run, not sending POST") - else: - post(url, auth=("tvm-bot", TVM_BOT_JENKINS_TOKEN)) - - def rerun_github_actions(self) -> None: - workflow_ids = [] - for item in self.head_commit()["statusCheckRollup"]["contexts"]["nodes"]: - if "checkSuite" in item and item["conclusion"] == "FAILURE": - workflow_id = item["checkSuite"]["workflowRun"]["databaseId"] - workflow_ids.append(workflow_id) - - workflow_ids = list(set(workflow_ids)) - logging.info(f"Rerunning GitHub Actions workflows with IDs: {workflow_ids}") - if self.dry_run: - actions_github = None - else: - actions_github = GitHubRepo( - user=self.github.user, repo=self.github.repo, token=GH_ACTIONS_TOKEN - ) - for workflow_id in workflow_ids: - if self.dry_run: - logging.info(f"Dry run, not restarting workflow {workflow_id}") - else: - try: - actions_github.post(f"actions/runs/{workflow_id}/rerun-failed-jobs", data={}) - except RuntimeError as e: - logging.exception(e) - # Ignore errors about jobs that are part of the same workflow to avoid - # having to figure out which jobs are in which workflows ahead of time - if "The workflow run containing this job is already running" in str(e): - pass - else: - raise e - - def comment_failure(self, msg: str, exceptions: Exception | list[Exception]): - if not isinstance(exceptions, list): - exceptions = [exceptions] - - logging.info(f"Failed, commenting {exceptions}") - - # Extract all the traceback strings - for item in exceptions: - try: - raise item - except Exception: - item.exception_msg = traceback.format_exc() - - comment = f"{msg} in {args.run_url}\n\n" - for exception in exceptions: - comment += f"<details>\n\n```\n{exception.exception_msg}\n```\n\n" - if hasattr(exception, "read"): - comment += f"with response\n\n```\n{exception.read().decode()}\n```\n\n" - comment += "</details>" - - pr.comment(comment) - pr.has_error = True - return exception - - -def check_author(pr, triggering_comment, args): - comment_author = triggering_comment["user"]["login"] - if pr.author() == comment_author: - logging.info("Comment user is PR author, continuing") - return True - return False - - -def search_users(name, triggering_comment, testing_json, search_fn): - logging.info(f"Checking {name}") - commment_author = triggering_comment["user"]["login"] - if testing_json: - matching_users = json.loads(testing_json) - else: - matching_users = search_fn(commment_author) - logging.info(f"Found {name}: {matching_users}") - user_names = {user["login"] for user in matching_users} - - return len(matching_users) > 0 and commment_author in user_names - - -def check_collaborator(pr, triggering_comment, args): - return search_users( - name="collaborators", - triggering_comment=triggering_comment, - search_fn=pr.search_collaborator, - testing_json=args.testing_collaborators_json, - ) - - -def check_mentionable_users(pr, triggering_comment, args): - return search_users( - name="mentionable users", - triggering_comment=triggering_comment, - search_fn=pr.search_mentionable_users, - testing_json=args.testing_mentionable_users_json, - ) - - -AUTH_CHECKS = { - "mentionable_users": check_mentionable_users, - "collaborators": check_collaborator, - "author": check_author, -} -# Stash the keys so they're accessible from the values -AUTH_CHECKS = {k: (k, v) for k, v in AUTH_CHECKS.items()} - - -class Merge: - triggers = [ - "merge", - "merge this", - "merge this pr", - ] - - auth = [AUTH_CHECKS["collaborators"], AUTH_CHECKS["author"]] - - @staticmethod - def run(pr: PR): - info = None - try: - info = pr.merge_if_passed_checks() - except Exception as e: - pr.comment_failure("Failed to process merge request", e) - raise e - - if info is not None: - try: - pr.trigger_gha_ci(sha=info["sha"]) - except Exception as e: - pr.comment_failure("Failed to trigger GitHub Actions", e) - raise e - - -class Rerun: - triggers = [ - "rerun", - "rerun ci", - "re-run", - "re-run ci", - "run", - "run ci", - ] - - auth = [AUTH_CHECKS["mentionable_users"]] - - @staticmethod - def run(pr: PR): - errors = [] - try: - pr.rerun_jenkins_ci() - except Exception as e: - logging.exception(e) - errors.append(e) - - try: - pr.rerun_github_actions() - except Exception as e: - logging.exception(e) - errors.append(e) - - if len(errors) > 0: - pr.comment_failure("Failed to re-run CI", errors) - - -if __name__ == "__main__": - help = "Check if a PR has comments trying to merge it, and do so based on reviews/CI status" - parser = argparse.ArgumentParser(description=help) - parser.add_argument("--remote", default="origin", help="ssh remote to parse") - parser.add_argument("--pr", required=True, help="pr number to check") - parser.add_argument("--run-url", required=True, help="workflow run URL") - parser.add_argument( - "--trigger-comment-json", required=True, help="json of the comment that triggered this run" - ) - parser.add_argument("--testing-pr-json", help="(testing only) manual data for testing") - parser.add_argument( - "--testing-collaborators-json", help="(testing only) manual data for testing" - ) - parser.add_argument( - "--testing-mentionable-users-json", help="(testing only) manual data for testing" - ) - parser.add_argument( - "--dry-run", - action="store_true", - default=False, - help="run but don't send any request to GitHub", - ) - args = parser.parse_args() - init_log() - comment = json.loads(args.trigger_comment_json) - body = comment["body"].strip() - - # Check that the comment was addressed to tvm-bot - if not body.startswith("@tvm-bot "): - logging.info(f"Not a bot comment, '{body}' does not start with '@tvm-bot'") - exit(0) - - # Find the code to run for the command from the user - user_command = body.lstrip("@tvm-bot").strip() - command_to_run = None - for command in [Merge, Rerun]: - if user_command in command.triggers: - command_to_run = command - break - - if command_to_run is None: - logging.info(f"Command '{user_command}' did not match anything") - exit(0) - - # Find the remote for querying more data about the PR - remote = git(["config", "--get", f"remote.{args.remote}.url"]) - logging.info(f"Using remote remote={remote}") - owner, repo = parse_remote(remote) - - if args.pr.strip() == "": - logging.info("No PR number passed") - exit(0) - - logging.info(f"Checking owner={owner} repo={repo}") - if args.testing_pr_json: - pr = PR( - number=int(args.pr), - owner=owner, - repo=repo, - dry_run=args.dry_run, - raw_data=json.loads(args.testing_pr_json), - ) - else: - pr = PR(number=int(args.pr), owner=owner, repo=repo, dry_run=args.dry_run) - - for name, check in command_to_run.auth: - if check(pr, comment, args): - logging.info(f"Passed auth check '{name}', continuing") - # Only one authorization check needs to pass (e.g. just mentionable - # or PR author), not all of them so quit - break - else: - logging.info(f"Failed auth check '{name}', quitting") - # Add a sad face - pr.react(comment, "confused") - exit(0) - - # Acknowledge the comment with a react - pr.react(comment, "+1") - - state = pr.state() - - if state != "OPEN": - logging.info(f"Ignoring event on PR, state was not OPEN, instead was state={state}") - exit(0) - - # Run the command - command_to_run.run(pr) - - if pr.has_error: - raise RuntimeError("PR commented a failure") diff --git a/ci/scripts/jenkins/cmd_utils.py b/ci/scripts/jenkins/cmd_utils.py deleted file mode 100644 index e1cd8e9b20..0000000000 --- a/ci/scripts/jenkins/cmd_utils.py +++ /dev/null @@ -1,107 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -import logging -import os -import re -import subprocess -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent -assert (REPO_ROOT / "Jenkinsfile").exists - - -class RelativePathFilter(logging.Filter): - def filter(self, record): - path = Path(record.pathname).resolve() - record.relativepath = str(path.relative_to(REPO_ROOT)) - return True - - -def init_log(): - logging.basicConfig( - format="[%(relativepath)s:%(lineno)d %(levelname)-1s] %(message)s", level=logging.INFO - ) - - # Flush on every log call (logging and then calling subprocess.run can make - # the output look confusing) - logging.root.handlers[0].addFilter(RelativePathFilter()) - logging.root.handlers[0].flush = sys.stderr.flush - - -class Sh: - def __init__(self, env=None, cwd=None): - self.env = os.environ.copy() - if env is not None: - self.env.update(env) - self.cwd = cwd - - def tee(self, cmd: str, **kwargs): - """ - Run 'cmd' in a shell then return the (process, stdout) as a tuple - """ - - logging.info(f"+ {cmd}") - - kwargs = { - **self._default_popen_flags(), - **kwargs, - "stdout": subprocess.PIPE, - } - proc = subprocess.Popen(cmd, **kwargs) - - stdout = [] - - def _tee_output(s): - stdout.append(s) - print(s, end="") - - while proc.poll() is None: - _tee_output(proc.stdout.readline()) - _tee_output(proc.stdout.read()) - - stdout = "".join(stdout) - if proc.returncode: - raise subprocess.CalledProcessError(proc.returncode, proc.args, stdout) - - return proc, stdout - - def run(self, cmd: str, **kwargs): - logging.info(f"+ {cmd}") - - kwargs = { - **self._default_popen_flags(), - "check": True, - **kwargs, - } - - return subprocess.run(cmd, **kwargs) - - def _default_popen_flags(self): - return { - "shell": True, - "env": self.env, - "encoding": "utf-8", - "cwd": self.cwd, - } - - -def tags_from_title(title: str) -> list[str]: - tags = re.findall(r"\[(.*?)\]", title) - tags = [t.strip() for t in tags] - return tags diff --git a/ci/scripts/jenkins/determine_docker_images.py b/ci/scripts/jenkins/determine_docker_images.py deleted file mode 100755 index 3f3deb5c57..0000000000 --- a/ci/scripts/jenkins/determine_docker_images.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ruff: noqa: E501 -import argparse -import configparser -import datetime -import json -import logging -import urllib.error -from pathlib import Path -from typing import Any - -from cmd_utils import REPO_ROOT, init_log -from http_utils import get - -DOCKER_API_BASE = "https://hub.docker.com/v2/" -PAGE_SIZE = 25 -TEST_DATA = None -IMAGE_TAGS_FILE = REPO_ROOT / "ci" / "jenkins" / "docker-images.ini" -TVM_CI_ECR = "477529581014.dkr.ecr.us-west-2.amazonaws.com" - - -def docker_api(url: str, use_pagination: bool = False) -> dict[str, Any]: - """ - Run a paginated fetch from the public Docker Hub API - """ - if TEST_DATA is not None: - if url not in TEST_DATA: - raise urllib.error.HTTPError(url, 404, "Not found", {}, None) - return TEST_DATA[url] - pagination = "" - if use_pagination: - pagination = f"?page_size={PAGE_SIZE}&page=1" - url = DOCKER_API_BASE + url + pagination - r, headers = get(url) - reset = headers.get("x-ratelimit-reset") - if reset is not None: - reset = datetime.datetime.fromtimestamp(int(reset)) - reset = reset.isoformat() - logging.info( - f"Docker API Rate Limit: {headers.get('x-ratelimit-remaining')} / {headers.get('x-ratelimit-limit')} (reset at {reset})" - ) - return r - - -def image_exists(spec: str) -> bool: - name, tag = spec.split(":") - try: - r = docker_api(f"repositories/{name}/tags/{tag}") - logging.debug(f"Image exists, got response: {json.dumps(r, indent=2)}") - return True - except urllib.error.HTTPError as e: - # Image was not found - logging.debug(e) - return False - - -def lookup_image_tag(name: str) -> str: - """Resolve image ``name`` (e.g. ``ci_cpu``) to its tag string from the ini. - - Pure ini read — no Docker Hub query, no tlcpackstaging fallback. Used by - ``docker/dev_common.sh`` for local-dev image-name shortcuts where the - full Hub-existence check is unnecessary. - """ - config = configparser.ConfigParser() - config.read(IMAGE_TAGS_FILE) - return config.get("jenkins", name) - - -if __name__ == "__main__": - init_log() - parser = argparse.ArgumentParser( - description="Writes out Docker images names to be used to .docker-image-names/" - ) - parser.add_argument( - "--lookup-only", - metavar="NAME", - help="Print the tag for NAME from the ini and exit (no Docker Hub query, no fallback).", - ) - parser.add_argument( - "--testing-docker-data", - help="(testing only) JSON data to mock response from Docker Hub API", - ) - parser.add_argument( - "--testing-images-data", - help=f"(testing only) JSON data to mock contents of {IMAGE_TAGS_FILE}", - ) - parser.add_argument( - "--base-dir", - default=".docker-image-names", - help="(testing only) Folder to write image names to", - ) - args, other = parser.parse_known_args() - - if args.lookup_only: - print(lookup_image_tag(args.lookup_only)) - raise SystemExit(0) - - name_dir = Path(args.base_dir) - - if args.testing_images_data: - repo_image_tags = json.loads(args.testing_images_data) - else: - config = configparser.ConfigParser() - config.read(IMAGE_TAGS_FILE) - repo_image_tags = {} - for name in other: - repo_image_tags[name] = config.get("jenkins", name) - - images = {} - for name in other: - images[name] = repo_image_tags[name] - - if args.testing_docker_data is not None: - TEST_DATA = json.loads(args.testing_docker_data) - - logging.info(f"Checking if these images exist in tlcpack: {images}") - - name_dir.mkdir(exist_ok=True) - images_to_use = {} - for filename, spec in images.items(): - if spec.startswith(TVM_CI_ECR): - logging.info(f"{spec} is from ECR") - images_to_use[filename] = spec - elif image_exists(spec): - logging.info(f"{spec} found in tlcpack") - images_to_use[filename] = spec - else: - logging.info(f"{spec} not found in tlcpack, using tlcpackstaging") - part, tag = spec.split(":") - user, repo = part.split("/") - tlcpackstaging_tag = f"tlcpackstaging/{repo.replace('-', '_')}:{tag}" - images_to_use[filename] = tlcpackstaging_tag - - for filename, image in images_to_use.items(): - logging.info(f"Writing image {image} to {name_dir / filename}") - with open(name_dir / filename, "w") as f: - f.write(image) diff --git a/ci/scripts/jenkins/git_change_docker.sh b/ci/scripts/jenkins/git_change_docker.sh deleted file mode 100755 index 758ccfdf6e..0000000000 --- a/ci/scripts/jenkins/git_change_docker.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash - -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -set -eux - -if [ "${BRANCH_NAME}" == "main" ]; then - changed_files=$(git diff --no-commit-id --name-only -r HEAD~1) -else - changed_files=$(git diff --no-commit-id --name-only -r origin/main) -fi - -FILES_THAT_SHOULDNT_TRIGGER_REBUILDS=( - "docker/bash.sh" - "docker/with_the_same_user" - "docker/README.md" - "docker/Dockerfile.ci_lint" - "docker/install/ubuntu_install_clang_format.sh" - "docker/lint.sh" - "docker/clear-stale-images.sh" - "README.md" - "lint.sh" - "clear-stale-images.sh" -) - -for file in $changed_files; do - # Certain files under docker/ don't matter for rebuilds, so ignore them - if printf '%s\0' "${FILES_THAT_SHOULDNT_TRIGGER_REBUILDS[@]}" | grep -F -x -z -- "$file"; then - echo "Skipping $file" - continue - fi - # if grep -q "docker/" - echo "Checking $file" - if grep -q "docker/" <<< "$file"; then - exit 1 - fi -done - -# No docker changes -exit 0 diff --git a/ci/scripts/jenkins/git_change_docs.sh b/ci/scripts/jenkins/git_change_docs.sh deleted file mode 100755 index 35b1bb7ec3..0000000000 --- a/ci/scripts/jenkins/git_change_docs.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash - -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -set -eux - -FOUND_ONE_FILE=0 -SAW_NON_DOC_CHANGES=0 - -changed_files=$(git diff --no-commit-id --name-only -r origin/main) - -for file in $changed_files; do - FOUND_ONE_FILE=1 - if ! grep -q "docs/" <<< "$file"; then - SAW_NON_DOC_CHANGES=1 - break - fi -done - -if [ ${FOUND_ONE_FILE} -eq 0 ] || [ ${SAW_NON_DOC_CHANGES} -eq 1 ]; then - exit 0 -else - exit 1 -fi diff --git a/ci/scripts/jenkins/git_skip_ci.py b/ci/scripts/jenkins/git_skip_ci.py deleted file mode 100755 index 3f7caa0f8d..0000000000 --- a/ci/scripts/jenkins/git_skip_ci.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -import argparse -import logging -import os - -from cmd_utils import init_log, tags_from_title -from git_utils import GitHubRepo, git, parse_remote - -if __name__ == "__main__": - help = "Exits with 0 if CI should be skipped, 1 otherwise" - parser = argparse.ArgumentParser(description=help) - parser.add_argument("--pr", required=True) - parser.add_argument("--remote", default="origin", help="ssh remote to parse") - parser.add_argument( - "--pr-title", help="(testing) PR title to use instead of fetching from GitHub" - ) - args = parser.parse_args() - init_log() - - branch = git(["rev-parse", "--abbrev-ref", "HEAD"]) - log = git(["log", "--format=%s", "-1"]) - - # Check the PR's title (don't check this until everything else passes first) - def check_pr_title(): - remote = git(["config", "--get", f"remote.{args.remote}.url"]) - user, repo = parse_remote(remote) - - if args.pr_title: - title = args.pr_title - else: - github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo) - pr = github.get(f"pulls/{args.pr}") - title = pr["title"] - logging.info(f"pr title: {title}") - tags = tags_from_title(title) - logging.info(f"Found title tags: {tags}") - return "skip ci" in tags - - if args.pr != "null" and args.pr.strip() != "" and branch != "main" and check_pr_title(): - logging.info("PR title starts with '[skip ci]', skipping...") - exit(0) - else: - logging.info(f"Not skipping CI:\nargs.pr: {args.pr}\nbranch: {branch}\ncommit: {log}") - exit(1) diff --git a/ci/scripts/jenkins/git_skip_ci_globs.py b/ci/scripts/jenkins/git_skip_ci_globs.py deleted file mode 100755 index 90970eecc6..0000000000 --- a/ci/scripts/jenkins/git_skip_ci_globs.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ruff: noqa: E501 - -import argparse -import fnmatch - -from git_utils import git - -globs = [ - "*.md", - ".github/*", - ".asf.yaml", - ".gitignore", - "LICENSE", - "NOTICE", - "KEYS", - "tests/lint/*", - ".pre-commit-config.yaml", -] - - -def match_any(f: str) -> str | None: - for glob in globs: - if fnmatch.fnmatch(f, glob): - return glob - return None - - -if __name__ == "__main__": - help = "Exits with code 1 if a change only touched files, indicating that CI could be skipped for this changeset" - parser = argparse.ArgumentParser(description=help) - parser.add_argument("--files", help="(testing only) comma separated list of files to check") - args = parser.parse_args() - print(args) - if args.files is not None: - diff = [x for x in args.files.split(",") if x.strip() != ""] - else: - diff = git(["diff", "--no-commit-id", "--name-only", "-r", "origin/main"]) - diff = diff.split("\n") - diff = [d.strip() for d in diff] - diff = [d for d in diff if d != ""] - - print(f"Changed files:\n{diff}") - - if len(diff) == 0: - print("Found no changed files, skipping CI") - exit(0) - - print(f"Checking with globs:\n{globs}") - - for file in diff: - match = match_any(file) - if match is None: - print(f"{file} did not match any globs, running CI") - exit(1) - else: - print(f"{file} matched glob {match}") - - print("All files matched a glob, skipping CI") - exit(0) diff --git a/ci/scripts/jenkins/git_utils.py b/ci/scripts/jenkins/git_utils.py deleted file mode 100644 index b16e3f7aa3..0000000000 --- a/ci/scripts/jenkins/git_utils.py +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ruff: noqa: E501, RUF005 - -import base64 -import json -import logging -import os -import re -import subprocess -from typing import Any -from urllib import error, request - -DRY_RUN = object() - - -def compress_query(query: str) -> str: - query = query.replace("\n", "") - query = re.sub("\s+", " ", query) - return query - - -def post(url: str, body: Any | None = None, auth: tuple[str, str] | None = None): - logging.info(f"Requesting POST to {url} with {body}") - headers = {} - req = request.Request(url, headers=headers, method="POST") - if auth is not None: - auth_str = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()) - req.add_header("Authorization", f"Basic {auth_str.decode()}") - - if body is None: - body = "" - - req.add_header("Content-Type", "application/json; charset=utf-8") - data = json.dumps(body) - data = data.encode("utf-8") - req.add_header("Content-Length", len(data)) - - with request.urlopen(req, data) as response: - return response.read() - - -def dry_run_token(is_dry_run: bool) -> Any: - if is_dry_run: - return DRY_RUN - return os.environ["GITHUB_TOKEN"] - - -class GitHubRepo: - GRAPHQL_URL = "https://api.github.com/graphql" - - def __init__(self, user, repo, token, test_data=None): - self.token = token - self.user = user - self.repo = repo - self.test_data = test_data - self.num_calls = 0 - self.base = f"https://api.github.com/repos/{user}/{repo}/" - - def headers(self): - return { - "Authorization": f"Bearer {self.token}", - } - - def dry_run(self) -> bool: - return self.token == DRY_RUN - - def graphql(self, query: str, variables: dict[str, str] | None = None) -> dict[str, Any]: - query = compress_query(query) - if variables is None: - variables = {} - - response = self._request( - self.GRAPHQL_URL, - {"query": query, "variables": variables}, - method="POST", - ) - if self.dry_run(): - return self.testing_response("POST", self.GRAPHQL_URL) - - if "data" not in response: - msg = f"Error fetching data with query:\n{query}\n\nvariables:\n{variables}\n\nerror:\n{json.dumps(response, indent=2)}" - raise RuntimeError(msg) - return response - - def testing_response(self, method: str, url: str) -> Any: - self.num_calls += 1 - key = f"[{self.num_calls}] {method} - {url}" - if self.test_data is not None and key in self.test_data: - return self.test_data[key] - logging.info(f"Unknown URL in dry run: {key}") - return {} - - def _request(self, full_url: str, body: dict[str, Any], method: str) -> dict[str, Any]: - if self.dry_run(): - logging.info(f"Dry run, would have requested a {method} to {full_url} with {body}") - return self.testing_response(method, full_url) - - logging.info(f"Requesting {method} to {full_url} with {body}") - req = request.Request(full_url, headers=self.headers(), method=method.upper()) - req.add_header("Content-Type", "application/json; charset=utf-8") - data = json.dumps(body) - data = data.encode("utf-8") - req.add_header("Content-Length", len(data)) - - try: - with request.urlopen(req, data) as response: - content = response.read() - except error.HTTPError as e: - msg = str(e) - error_data = e.read().decode() - raise RuntimeError(f"Error response: {msg}\n{error_data}") - - logging.info(f"Got response from {full_url}: {content}") - try: - response = json.loads(content) - except json.decoder.JSONDecodeError: - return content - - return response - - def put(self, url: str, data: dict[str, Any]) -> dict[str, Any]: - return self._request(self.base + url, data, method="PUT") - - def patch(self, url: str, data: dict[str, Any]) -> dict[str, Any]: - return self._request(self.base + url, data, method="PATCH") - - def post(self, url: str, data: dict[str, Any]) -> dict[str, Any]: - return self._request(self.base + url, data, method="POST") - - def get(self, url: str) -> dict[str, Any]: - if self.dry_run(): - logging.info(f"Dry run, would have requested a GET to {url}") - return self.testing_response("GET", url) - url = self.base + url - logging.info(f"Requesting GET to {url}") - req = request.Request(url, headers=self.headers()) - with request.urlopen(req) as response: - response = json.loads(response.read()) - return response - - def delete(self, url: str) -> dict[str, Any]: - if self.dry_run(): - logging.info(f"Dry run, would have requested a DELETE to {url}") - return self.testing_response("DELETE", url) - url = self.base + url - logging.info(f"Requesting DELETE to {url}") - req = request.Request(url, headers=self.headers(), method="DELETE") - with request.urlopen(req) as response: - response = json.loads(response.read()) - return response - - -def parse_remote(remote: str) -> tuple[str, str]: - """ - Get a GitHub (user, repo) pair out of a git remote - """ - if remote.startswith("https://"): - # Parse HTTP remote - parts = remote.split("/") - if len(parts) < 2: - raise RuntimeError(f"Unable to parse remote '{remote}'") - user, repo = parts[-2], parts[-1].replace(".git", "") - else: - # Parse SSH remote - m = re.search(r":(.*)/(.*)\.git", remote) - if m is None or len(m.groups()) != 2: - raise RuntimeError(f"Unable to parse remote '{remote}'") - user, repo = m.groups() - - user = os.getenv("DEBUG_USER", user) - repo = os.getenv("DEBUG_REPO", repo) - return user, repo - - -def git(command, **kwargs): - command = ["git"] + command - logging.info(f"Running {command}") - proc = subprocess.run(command, stdout=subprocess.PIPE, encoding="utf-8", **kwargs) - if proc.returncode != 0: - raise RuntimeError(f"Command failed {command}:\nstdout:\n{proc.stdout}") - return proc.stdout.strip() - - -def find_ccs(body: str) -> list[str]: - matches = re.findall(r"(cc( @[-A-Za-z0-9]+)+)", body, flags=re.MULTILINE) - matches = [full for full, last in matches] - - reviewers = [] - for match in matches: - if match.startswith("cc "): - match = match.replace("cc ", "") - users = [x.strip() for x in match.split("@")] - reviewers += users - - reviewers = set(x for x in reviewers if x != "") - return list(reviewers) diff --git a/ci/scripts/jenkins/http_utils.py b/ci/scripts/jenkins/http_utils.py deleted file mode 100644 index 05e1b71f24..0000000000 --- a/ci/scripts/jenkins/http_utils.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -import json -import logging -from typing import Any -from urllib import request - - -def get(url: str, headers: dict[str, str] | None = None) -> dict[str, Any]: - logging.info(f"Requesting GET to {url}") - if headers is None: - headers = {} - req = request.Request(url, headers=headers) - with request.urlopen(req) as response: - response_headers = {k: v for k, v in response.getheaders()} - response = json.loads(response.read()) - - return response, response_headers diff --git a/ci/scripts/jenkins/retry.sh b/ci/scripts/jenkins/retry.sh deleted file mode 100644 index 933eb1aba6..0000000000 --- a/ci/scripts/jenkins/retry.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash - -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -set -eux - -retry() { - local max_retries=$1 - shift - local n=0 - until [ "$n" -ge "$max_retries" ] - do - "$@" && break - n=$((n+1)) - if [ "$n" -eq "$max_retries" ]; then - echo "failed to update after attempt $n / $max_retries, giving up" - exit 1 - fi - - WAIT=$(python3 -c 'import random; print(random.randint(30, 200))') - echo "failed to update $n / $max_retries, waiting $WAIT to try again" - sleep "$WAIT" - done -} diff --git a/ci/scripts/jenkins/s3.py b/ci/scripts/jenkins/s3.py deleted file mode 100755 index e2e65dcadb..0000000000 --- a/ci/scripts/jenkins/s3.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -import argparse -import importlib.util -import logging -import re -from enum import Enum -from pathlib import Path - -from cmd_utils import REPO_ROOT, Sh, init_log - -DATA_PY = REPO_ROOT / "ci" / "jenkins" / "data.py" - - -def load_files_to_stash(): - """Load the files_to_stash dict from ci/jenkins/data.py.""" - spec = importlib.util.spec_from_file_location("data", DATA_PY) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod.files_to_stash - - -def resolve_bundles(bundle_names: list[str]) -> list[str]: - """Resolve a list of bundle names to a flat list of file paths.""" - files_to_stash = load_files_to_stash() - items = [] - for name in bundle_names: - if name not in files_to_stash: - known = list(files_to_stash.keys()) - logging.error(f"Unknown bundle '{name}'. Known bundles: {known}") - raise SystemExit(1) - items.extend(files_to_stash[name]) - return items - - -RETRY_SCRIPT = REPO_ROOT / "ci" / "scripts" / "jenkins" / "retry.sh" -S3_DOWNLOAD_REGEX = re.compile(r"download: s3://.* to (.*)") -SH = Sh() - - -class Action(Enum): - UPLOAD = 1 - DOWNLOAD = 2 - - -def show_md5(item: str) -> None: - if not Path(item).is_dir(): - sh.run(f"md5sum {item}") - - -def parse_output_files(stdout: str) -> list[str]: - """ - Grab the list of downloaded files from the output of 'aws s3 cp'. Lines look - like: - - download: s3://some/prefix/a_file.txt to a_file.txt - """ - files = [] - for line in stdout.split("\n"): - line = line.strip() - if line == "": - continue - m = S3_DOWNLOAD_REGEX.match(line) - if m: - files.append(m.groups()[0]) - - return files - - -def chmod(files: list[str]) -> None: - """ - S3 has no concept of file permissions so add them back in here to every file - """ - # Add execute bit for downloads - to_chmod = [str(f) for f in files] - logging.info(f"Adding execute bit for files: {to_chmod}") - if len(to_chmod) > 0: - SH.run(f"chmod +x {' '.join(to_chmod)}") - - -def s3(source: str, destination: str, recursive: bool) -> list[str]: - """ - Send or download the source to the destination in S3 - """ - cmd = f". {RETRY_SCRIPT.relative_to(REPO_ROOT)} && retry 3 aws s3 cp --no-progress" - - if recursive: - cmd += " --recursive" - - cmd += f" {source} {destination}" - _, stdout = SH.tee(cmd) - return stdout - - -if __name__ == "__main__": - init_log() - help = "Uploads or downloads files from S3" - parser = argparse.ArgumentParser(description=help) - parser.add_argument("--action", help="either 'upload' or 'download'", required=True) - parser.add_argument("--bucket", help="s3 bucket", required=True) - parser.add_argument( - "--prefix", help="s3 bucket + tag (e.g. s3://tvm-ci-prod/PR-1234/cpu", required=True - ) - parser.add_argument("--items", help="files and folders to upload", nargs="+") - parser.add_argument( - "--bundle", - help="bundle name(s) from ci/jenkins/data.py files_to_stash (repeatable)", - action="append", - dest="bundles", - metavar="NAME", - ) - - args = parser.parse_args() - - if args.items is not None and args.bundles is not None: - parser.error("--items and --bundle are mutually exclusive") - - logging.info(args) - - sh = Sh() - - if Path.cwd() != REPO_ROOT: - logging.error(f"s3.py can only be executed from the repo root, instead was in {Path.cwd()}") - exit(1) - - prefix = args.prefix.strip("/") - s3_path = f"s3://{args.bucket}/{prefix}" - logging.info(f"Using s3 path: {s3_path}") - - if args.action == "upload": - action = Action.UPLOAD - elif args.action == "download": - action = Action.DOWNLOAD - else: - logging.error(f"Unsupported action: {args.action}") - exit(1) - - if args.bundles is not None: - items = resolve_bundles(args.bundles) - elif args.items is not None: - items = args.items - else: - if args.action == "upload": - logging.error("Cannot upload without --items or --bundle") - exit(1) - else: - # Download the whole prefix - items = ["."] - - for item in items: - if action == Action.DOWNLOAD: - source = s3_path - recursive = True - if item != ".": - source = s3_path + "/" + item - recursive = False - try: - stdout = s3(source=source, destination=item, recursive=recursive) - except Exception: - # Optional artifacts (e.g. per-backend device runtime DSOs) may not - # exist in S3 when the build config didn't produce them. Skip silently. - logging.warning(f"Download failed for {item}, skipping (may be optional)") - continue - files = parse_output_files(stdout) - chmod(files) - for file in files: - # Show md5 after downloading - show_md5(file) - elif action == Action.UPLOAD: - if not Path(item).exists(): - logging.warning(f"The path doesn't exist: {item}") - continue - show_md5(item) - if Path(item).is_dir(): - if len(list(Path(item).glob("**/*"))) == 0: - raise RuntimeError(f"Cannot upload empty folder with name: {item}") - s3(item, s3_path + "/" + item, recursive=Path(item).is_dir()) diff --git a/ci/scripts/jenkins/should_rebuild_docker.py b/ci/scripts/jenkins/should_rebuild_docker.py deleted file mode 100755 index 0c608dc43a..0000000000 --- a/ci/scripts/jenkins/should_rebuild_docker.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ruff: noqa: E501 -import argparse -import datetime -import json -import logging -import subprocess -from typing import Any - -from cmd_utils import Sh, init_log -from http_utils import get - -DOCKER_API_BASE = "https://hub.docker.com/v2/" -PAGE_SIZE = 25 -TEST_DATA = None - - -def docker_api(url: str) -> dict[str, Any]: - """ - Run a paginated fetch from the public Docker Hub API - """ - if TEST_DATA is not None: - return TEST_DATA[url] - pagination = f"?page_size={PAGE_SIZE}&page=1" - url = DOCKER_API_BASE + url + pagination - r, headers = get(url) - reset = headers.get("x-ratelimit-reset") - if reset is not None: - reset = datetime.datetime.fromtimestamp(int(reset)) - reset = reset.isoformat() - logging.info( - f"Docker API Rate Limit: {headers.get('x-ratelimit-remaining')} / {headers.get('x-ratelimit-limit')} (reset at {reset})" - ) - if "results" not in r: - raise RuntimeError(f"Error fetching data, no results found in: {r}") - return r - - -def any_docker_changes_since(hash: str) -> bool: - """ - Check the docker/ directory, return True if there have been any code changes - since the specified hash - """ - sh = Sh() - cmd = f"git diff {hash} -- docker/" - proc = sh.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - stdout = proc.stdout.strip() - return stdout != "", stdout - - -def does_commit_exist(hash: str) -> bool: - """ - Returns True if the hash exists in the repo - """ - sh = Sh() - cmd = f"git rev-parse -q {hash}" - proc = sh.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) - print(proc.stdout) - if proc.returncode == 0: - return True - - if "unknown revision or path not in the working tree" in proc.stdout: - return False - - raise RuntimeError(f"Unexpected failure when running: {cmd}") - - -def find_hash_for_tag(tag: dict[str, Any]) -> str: - """ - Split the hash off of a name like <date>-<time>-<hash> - """ - name = tag["name"] - name_parts = name.split("-") - if len(name_parts) != 3: - raise RuntimeError(f"Image {name} is not using new naming scheme") - shorthash = name_parts[2] - return shorthash - - -def find_commit_in_repo(tags: list[dict[str, Any]]): - """ - Look through all the docker tags, find the most recent one which references - a commit that is present in the repo - """ - for tag in tags["results"]: - shorthash = find_hash_for_tag(tag) - logging.info(f"Hash '{shorthash}' does not exist in repo") - if does_commit_exist(shorthash): - return shorthash, tag - - raise RuntimeError(f"No extant hash found in tags:\n{tags}") - - -def main(): - # Fetch all tlcpack images - images = docker_api("repositories/tlcpack") - - # Ignore all non-ci images - relevant_images = [image for image in images["results"] if image["name"].startswith("ci-")] - image_names = [image["name"] for image in relevant_images] - logging.info(f"Found {len(relevant_images)} images to check: {', '.join(image_names)}") - - for image in relevant_images: - # Check the tags for the image - tags = docker_api(f"repositories/tlcpack/{image['name']}/tags") - - # Find the hash of the most recent tag - shorthash, tag = find_commit_in_repo(tags) - name = tag["name"] - logging.info(f"Looking for docker/ changes since {shorthash}") - - any_docker_changes, diff = any_docker_changes_since(shorthash) - if any_docker_changes: - logging.info(f"Found docker changes from {shorthash} when checking {name}") - logging.info(diff) - exit(2) - - logging.info("Did not find changes, no rebuild necessary") - exit(0) - - -if __name__ == "__main__": - init_log() - parser = argparse.ArgumentParser( - description="Exits 0 if Docker images don't need to be rebuilt, 1 otherwise" - ) - parser.add_argument( - "--testing-docker-data", - help="(testing only) JSON data to mock response from Docker Hub API", - ) - args = parser.parse_args() - - if args.testing_docker_data is not None: - TEST_DATA = json.loads(args.testing_docker_data) - - main() diff --git a/ci/scripts/jenkins/should_run_slow_tests.py b/ci/scripts/jenkins/should_run_slow_tests.py deleted file mode 100755 index 31126d7daf..0000000000 --- a/ci/scripts/jenkins/should_run_slow_tests.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -import argparse -import os -import textwrap - -from git_utils import GitHubRepo, git, parse_remote - -SLOW_TEST_TRIGGERS = [ - "@tvm-bot run slow tests", - "@tvm-bot run slow test", - "@tvm-bot run slow", - "@tvm-bot slow tests", - "@tvm-bot slow test", - "@tvm-bot slow", -] - - -def check_match(s: str, searches: list[str]) -> tuple[bool, str | None]: - for search in searches: - if search in s: - return True, search - - return False, None - - -def display(long_str: str) -> str: - return textwrap.indent(long_str, " ") - - -if __name__ == "__main__": - help = "Exits with 1 if CI should run slow tests, 0 otherwise" - parser = argparse.ArgumentParser(description=help) - parser.add_argument("--pr", required=True) - parser.add_argument("--remote", default="origin", help="ssh remote to parse") - parser.add_argument( - "--pr-body", help="(testing) PR body to use instead of fetching from GitHub" - ) - args = parser.parse_args() - - branch = git(["rev-parse", "--abbrev-ref", "HEAD"]) - - # Don't skip slow tests on main or ci-docker-staging - skip_branches = {"main", "ci-docker-staging"} - if branch in skip_branches: - print(f"Branch {branch} is in {skip_branches}, running slow tests") - exit(1) - print(f"Branch {branch} is not in {skip_branches}, checking last commit...") - - if args.pr_body: - body = args.pr_body - else: - remote = git(["config", "--get", f"remote.{args.remote}.url"]) - user, repo = parse_remote(remote) - - github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo) - pr = github.get(f"pulls/{args.pr}") - body = pr["body"] - - body_match, reason = check_match(body, SLOW_TEST_TRIGGERS) - - if body_match: - print(f"Matched {reason} in PR body:\n{display(body)}, running slow tests") - exit(1) - - print( - f"PR Body:\n{display(body)}\ndid not have any of {SLOW_TEST_TRIGGERS}, skipping slow tests" - ) - exit(0) diff --git a/ci/scripts/package/README.md b/ci/scripts/package/README.md deleted file mode 100644 index 972c315309..0000000000 --- a/ci/scripts/package/README.md +++ /dev/null @@ -1,31 +0,0 @@ -<!--- Licensed to the Apache Software Foundation (ASF) under one --> -<!--- or more contributor license agreements. See the NOTICE file --> -<!--- distributed with this work for additional information --> -<!--- regarding copyright ownership. The ASF licenses this file --> -<!--- to you under the Apache License, Version 2.0 (the --> -<!--- "License"); you may not use this file except in compliance --> -<!--- with the License. You may obtain a copy of the License at --> - -<!--- http://www.apache.org/licenses/LICENSE-2.0 --> - -<!--- Unless required by applicable law or agreed to in writing, --> -<!--- software distributed under the License is distributed on an --> -<!--- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY --> -<!--- KIND, either express or implied. See the License for the --> -<!--- specific language governing permissions and limitations --> -<!--- under the License. --> - -# TVM wheel packaging - -The wheels are built by a standard `cibuildwheel` flow, configured in -`.github/workflows/publish_wheel.yml` and `pyproject.toml` (`[tool.cibuildwheel]` -and `[tool.scikit-build]`). This directory holds the few helper scripts that flow -invokes: - -- `manylinux_build_libtvm_runtime_cuda.sh` — run by the `build_cuda_runtime` CI - stage; builds the `libtvm_runtime_cuda.so` sidecar inside the prebuilt - `quay.io/manylinux_cuda` image (CUDA toolkit preinstalled). -- `windows_build_libtvm_runtime_cuda.bat` — the Windows equivalent (run with - `shell: cmd`); installs the CUDA toolkit via conda and builds - `tvm_runtime_cuda.dll`. -- `build-environment.yaml` — conda environment for building the wheel. diff --git a/ci/scripts/package/build-environment.yaml b/ci/scripts/package/build-environment.yaml deleted file mode 100644 index 3b2c4dd167..0000000000 --- a/ci/scripts/package/build-environment.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - -# Build environment for TVM wheel building. -# This environment provides the necessary dependencies for building TVM wheels. -name: tvm-wheel-build - -# The conda channels to lookup the dependencies -channels: - - conda-forge - -# The packages to install to the environment -dependencies: - # Core build tools - - cmake >=3.24 - - ninja - - make - - llvmdev >=11 - - python >=3.10 - - pip - - git - - bzip2 - - zlib - - zstd-static - - pytest - - numpy - - scipy - - cython - - libxml2-devel diff --git a/ci/scripts/package/manylinux_build_libtvm_runtime_cuda.sh b/ci/scripts/package/manylinux_build_libtvm_runtime_cuda.sh deleted file mode 100755 index d0d847ae83..0000000000 --- a/ci/scripts/package/manylinux_build_libtvm_runtime_cuda.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -# Build libtvm_runtime_cuda.so inside a manylinux CUDA container, run by the -# build_cuda_runtime CI job. The official quay.io/manylinux_cuda images ship -# the CUDA toolkit preinstalled under /usr/local/cuda, but omit the libcuda -# driver stub, so we install just cuda-driver-devel from the image's CUDA repo. -# Builds the sidecar into build-wheel-cuda/lib/ for the wheel build to bundle. -# -# Usage: manylinux_build_libtvm_runtime_cuda.sh -set -euxo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -build_dir="${repo_root}/build-wheel-cuda" -parallel="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" - -# The image ships the toolkit but not the libcuda driver stub; install it from -# the image's CUDA repo so the sidecar links libcuda.so.1 (sync version with tag). -dnf -y install cuda-driver-devel-13-1 - -# Build the CUDA runtime sidecar with CUDA on and LLVM off, so it does not need -# the LLVM prefix; the main CPU wheel links LLVM statically. The manylinux CUDA -# image already ships cmake and make, and the build uses the default Makefiles -# generator (no Ninja), so no build tools are installed here. Put the bundled -# CPython and CUDA toolchain on PATH for the CMake configure and nvcc. -export PATH="/opt/python/cp310-cp310/bin:/usr/local/cuda/bin:${PATH}" -nvcc --version - -rm -rf "${build_dir}" -# CMAKE_CUDA_COMPILER only tells CMake which nvcc to use; it does not affect the -# resulting libtvm_runtime_cuda.so, which is built only from .cc host sources (no -# .cu device code, so nvcc is never invoked for it). CMAKE_CUDA_ARCHITECTURES is -# intentionally not set: it would be a no-op here for the same reason (verified -- -# the .so is byte-identical across arch values and carries no device code), and -# modern CMake fills in a default so configure does not fail without it. -cmake -S "${repo_root}" -B "${build_dir}" \ - -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_TESTING=OFF \ - -DTVM_BUILD_PYTHON_MODULE=ON \ - -DUSE_CUDA=/usr/local/cuda \ - -DUSE_LLVM=OFF \ - -DUSE_CUBLAS=OFF -DUSE_CUDNN=OFF -DUSE_CUTLASS=OFF -DUSE_NCCL=OFF -DUSE_NVTX=OFF \ - -DCMAKE_CUDA_COMPILER=/usr/local/cuda/bin/nvcc -cmake --build "${build_dir}" --target tvm_runtime tvm_runtime_cuda --parallel "${parallel}" - -cuda_lib="${build_dir}/lib/libtvm_runtime_cuda.so" -test -f "${cuda_lib}" -patchelf --set-rpath '$ORIGIN' "${cuda_lib}" -echo "CUDA runtime: ${cuda_lib}" diff --git a/ci/scripts/package/windows_build_libtvm_runtime_cuda.bat b/ci/scripts/package/windows_build_libtvm_runtime_cuda.bat deleted file mode 100644 index 4864c18a80..0000000000 --- a/ci/scripts/package/windows_build_libtvm_runtime_cuda.bat +++ /dev/null @@ -1,99 +0,0 @@ -@echo off -rem Licensed to the Apache Software Foundation (ASF) under one -rem or more contributor license agreements. See the NOTICE file -rem distributed with this work for additional information -rem regarding copyright ownership. The ASF licenses this file -rem to you under the Apache License, Version 2.0 (the -rem "License"); you may not use this file except in compliance -rem with the License. You may obtain a copy of the License at -rem -rem http://www.apache.org/licenses/LICENSE-2.0 -rem -rem Unless required by applicable law or agreed to in writing, -rem software distributed under the License is distributed on an -rem "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -rem KIND, either express or implied. See the License for the -rem specific language governing permissions and limitations -rem under the License. -rem -rem Build tvm_runtime_cuda.dll on a Windows runner, run by the build_cuda_runtime -rem CI job (on the host; unlike Linux there is no build container on Windows). -rem Installs the pinned CUDA toolkit via conda and builds the sidecar into -rem build-wheel-cuda\lib\ for the wheel build to bundle. Windows mirror of -rem manylinux_build_libtvm_runtime_cuda.sh. Run with: shell: cmd. -setlocal enableextensions - -rem repo root = this script's directory / ..\..\.. (native Windows paths; no cygpath). -pushd "%~dp0..\..\.." || exit /b 1 -set "repo_root=%CD%" -popd -set "build_dir=%repo_root%\build-wheel-cuda" -set "cuda_prefix=C:\opt\cuda" - -rem Locate conda: the runner ships Miniconda (exposed via %CONDA%) but it may not be -rem on PATH in this shell. -set "conda_exe=conda" -where conda >nul 2>nul || set "conda_exe=%CONDA%\Scripts\conda.exe" - -rem Install the pinned CUDA toolkit via conda from the nvidia channel, mirroring the -rem LLVM-via-conda install used elsewhere. The win-64 channel caps at 13.0.x, so this -rem pins 13.0.2 -- slightly behind the Linux image's CUDA 13.1, which is harmless: the -rem sidecar has no device code and links the CUDA runtime by soname only. The nvidia -rem CDN occasionally returns a transient HTTP 5xx, so retry once; a half-finished first -rem attempt can leave the prefix partially populated, so wipe it before retrying. -if not exist "%cuda_prefix%\Library\bin\nvcc.exe" ( - call "%conda_exe%" create -q -p "%cuda_prefix%" -c nvidia/label/cuda-13.0.2 cuda-toolkit -y - if errorlevel 1 ( - if exist "%cuda_prefix%" rmdir /s /q "%cuda_prefix%" - call "%conda_exe%" create -q -p "%cuda_prefix%" -c nvidia/label/cuda-13.0.2 cuda-toolkit -y || exit /b 1 - ) -) - -rem conda lays the Windows toolkit out under <prefix>\Library (bin\nvcc.exe, -rem lib\x64\cudart.lib, include\...). Discover the root from nvcc.exe so TVM's -rem FindCUDA MSVC branch resolves against the real layout instead of a hardcode. -set "nvcc_exe=" -for /f "delims=" %%i in ('dir /s /b "%cuda_prefix%\nvcc.exe" 2^>nul') do if not defined nvcc_exe set "nvcc_exe=%%i" -if not defined nvcc_exe ( echo nvcc.exe not found under %cuda_prefix% & exit /b 1 ) -rem cuda_root = dirname(dirname(nvcc)) = <prefix>\Library -for %%i in ("%nvcc_exe%") do set "nvcc_bin=%%~dpi" -pushd "%nvcc_bin%.." || exit /b 1 -set "cuda_root=%CD%" -popd -set "CUDA_PATH=%cuda_root%" - -python -m pip install -U pip cmake ninja || exit /b 1 -"%nvcc_exe%" --version || exit /b 1 - -rem nvcc needs the MSVC host compiler (cl.exe), so locate VS via vswhere and run the -rem cmake configure+build inside vcvars64 (this shell is not a VS Developer prompt). -set "vswhere=C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -set "vs_path=" -for /f "usebackq delims=" %%i in (`"%vswhere%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "vs_path=%%i" -if not defined vs_path ( echo Visual Studio with VC tools not found & exit /b 1 ) -set "vcvars=%vs_path%\VC\Auxiliary\Build\vcvars64.bat" - -if exist "%build_dir%" rmdir /s /q "%build_dir%" - -rem CMAKE_CUDA_COMPILER only tells CMake which nvcc to use (load-bearing: the conda -rem nvcc is not on PATH); it does not affect the resulting tvm_runtime_cuda.dll, which -rem is built only from .cc host sources (no .cu device code). CMAKE_CUDA_ARCHITECTURES -rem is intentionally not set -- a no-op for the same reason, and modern CMake fills a -rem default. -allow-unsupported-compiler guards against the runner's MSVC being newer -rem than the CUDA toolkit officially supports. The cmake command is kept on one line: -rem `^` continuations in a batch file break on any trailing whitespace. -rem CMake parses backslashes in string values as escapes (e.g. C:\opt -> invalid \o), -rem so hand cmake forward-slash paths. cmd builtins (rmdir / if exist) keep backslashes. -set "repo_root_fwd=%repo_root:\=/%" -set "build_dir_fwd=%build_dir:\=/%" -set "cuda_root_fwd=%cuda_root:\=/%" -set "nvcc_fwd=%nvcc_exe:\=/%" - -call "%vcvars%" || exit /b 1 -cmake -S "%repo_root_fwd%" -B "%build_dir_fwd%" -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DTVM_BUILD_PYTHON_MODULE=ON -DUSE_CUDA="%cuda_root_fwd%" -DUSE_LLVM=OFF -DUSE_CUBLAS=OFF -DUSE_CUDNN=OFF -DUSE_CUTLASS=OFF -DUSE_NCCL=OFF -DUSE_NVTX=OFF -DCMAKE_CUDA_COMPILER="%nvcc_fwd%" -DCMAKE_CUDA_FLAGS="-allow-unsupported-compiler" || exit /b 1 -cmake --build "%build_dir_fwd%" --target tvm_runtime tvm_runtime_cuda --config Release || exit /b 1 - -if not exist "%build_dir%\lib\tvm_runtime_cuda.dll" ( echo tvm_runtime_cuda.dll was not produced & exit /b 1 ) -rem No patchelf/rpath step on Windows; delvewheel vendors dependencies at repair time. -echo CUDA runtime: %build_dir%\lib\tvm_runtime_cuda.dll -endlocal diff --git a/tests/python/ci/jenkins_loader_test.groovy b/tests/python/ci/jenkins_loader_test.groovy new file mode 100644 index 0000000000..28d0df3d74 --- /dev/null +++ b/tests/python/ci/jenkins_loader_test.groovy @@ -0,0 +1,94 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import org.codehaus.groovy.control.CompilerConfiguration + +class LoaderPipeline extends Script { + Object run() {} + Object evaluate(String source) { + state.evaluated << source + if (state.evaluationFailure) throw state.evaluationFailure + return 'loaded helper' + } + def readTrusted(String path) { + assert path == 'ci/jenkins/generated/common.groovy' + if (state.readFailure) throw state.readFailure + return 'selected revision helper' + } + def node(String label, Closure body) { + assert label == 'CPU-SMALL' + state.nodes++ + body() + } + def sh(Map options) { + state.fetches++ + assert options.returnStdout + assert options.script.contains('https://github.com/apache/tvm.git') + assert options.script.contains('+refs/heads/main:refs/heads/trusted-main') + assert !options.script.contains('CHANGE_TARGET') + if (state.fetchFailure) throw state.fetchFailure + return 'latest main helper' + } +} + +def loader = new GroovyClassLoader(getClass().classLoader) +def abort = loader.parseClass('package hudson; class AbortException extends IOException { AbortException(String message) { super(message) } }') +def config = new CompilerConfiguration(scriptBaseClass: 'LoaderPipeline') +def helper = 'ci/jenkins/generated/common.groovy' +def modified = { abort.newInstance("‘${helper}’ has been modified in an untrusted revision".toString()) } +def missing = { new FileNotFoundException(helper) } +def cases = [ + [name: 'unchanged PR'], + [name: 'trusted named branch', branch: true], + [name: 'modified PR', readFailure: modified(), fallback: true], + [name: 'deleted PR lightweight', readFailure: missing(), fallback: true], + [name: 'deleted PR heavyweight', readFailure: abort.newInstance("/workspace/trusted/${helper} not found".toString()), fallback: true], + [name: 'release target PR', readFailure: modified(), fallback: true, target: 'release'], + [name: 'missing named branch', branch: true, readFailure: missing()], + [name: 'rejected named branch', branch: true, readFailure: modified()], + [name: 'unrelated abort', readFailure: abort.newInstance('controller offline')], + [name: 'unrelated missing file', readFailure: new FileNotFoundException('other/file')], + [name: 'read network failure', readFailure: new IOException('network')], + [name: 'interrupted read', readFailure: new InterruptedException('cancelled')], + [name: 'fetch failure', readFailure: modified(), fallback: true, fetchFailure: new IOException('fetch failed')], + [name: 'missing main helper', readFailure: missing(), fallback: true, fetchFailure: abort.newInstance('main helper missing')], + [name: 'selected evaluation error', evaluationFailure: new IllegalStateException('bad helper')], + [name: 'fallback evaluation error', readFailure: modified(), fallback: true, evaluationFailure: new IllegalStateException('bad main helper')], +] +int count = 0 +args.each { filename -> + def fullSource = new File(filename).text + def source = fullSource.substring(0, fullSource.indexOf('\nci.start()')) + cases.each { scenario -> + def state = [nodes: 0, fetches: 0, evaluated: []] + scenario + def binding = new Binding([state: state, env: [CHANGE_ID: scenario.branch ? null : '123', CHANGE_TARGET: scenario.target ?: 'main']]) + Throwable failure + try { + new GroovyShell(loader, binding, config).evaluate(source) + } catch (Throwable caught) { + failure = caught + } + def expected = scenario.fetchFailure ?: scenario.evaluationFailure ?: (scenario.fallback ? null : scenario.readFailure) + assert failure.is(expected): "${filename}: ${scenario.name}: ${failure} != ${expected}" + assert state.nodes == (scenario.fallback ? 1 : 0): scenario.name + assert state.fetches == (scenario.fallback ? 1 : 0): scenario.name + def shouldEvaluate = (!scenario.readFailure || scenario.fallback) && !scenario.fetchFailure + assert state.evaluated == (shouldEvaluate ? [scenario.fallback ? 'latest main helper' : 'selected revision helper'] : []): scenario.name + count++ + } +} +println("${count} native loader cases passed") diff --git a/tests/python/ci/test_ci.py b/tests/python/ci/test_ci.py index 5e71cc7c0f..7144fee3d1 100644 --- a/tests/python/ci/test_ci.py +++ b/tests/python/ci/test_ci.py @@ -23,15 +23,12 @@ from pathlib import Path import pytest -from .test_utils import JENKINS_SCRIPT_ROOT, REPO_ROOT, TempGit, run_script +from .test_utils import JENKINS_SCRIPT_ROOT, TempGit, run_script [email protected]( - params=[JENKINS_SCRIPT_ROOT, REPO_ROOT / "ci/scripts/jenkins"], ids=["native", "legacy"] -) -def jenkins_script_root(request): - # Fork PR jobs still use main's Jenkinsfiles and the legacy command paths. - return request.param [email protected] +def jenkins_script_root(): + return JENKINS_SCRIPT_ROOT def parameterize_named(**kwargs): @@ -314,49 +311,3 @@ def test_should_rebuild_docker( if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) - - [email protected]( - "files, expected", - [([], 0), (["docs/page.rst"], 1), (["src/code.cc"], 0), (["docs/page.rst", "src/code.cc"], 0)], -) -def test_legacy_docs_gate(tmp_path, files, expected): - repo = TempGit(tmp_path) - repo.run("config", "user.name", "CI Test") - repo.run("config", "user.email", "[email protected]") - repo.run("commit", "--allow-empty", "-m", "base") - repo.run("update-ref", "refs/remotes/origin/main", "HEAD") - for filename in files: - path = tmp_path / filename - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("change") - repo.run("add", ".") - repo.run("commit", "--allow-empty", "-m", "change") - result = run_script( - [REPO_ROOT / "ci/scripts/jenkins/git_change_docs.sh"], cwd=tmp_path, check=False - ) - assert result.returncode == expected - - [email protected]( - "branch, body, expected", - [ - ("main", "ordinary change", 1), - ("ci-docker-staging", "ordinary change", 1), - ("feature", "ordinary change", 0), - ("feature", "@tvm-bot run slow tests", 1), - ], -) -def test_legacy_slow_gate(tmp_path, branch, body, expected): - repo = TempGit(tmp_path) - repo.run("config", "user.name", "CI Test") - repo.run("config", "user.email", "[email protected]") - repo.run("commit", "--allow-empty", "-m", "base") - if branch != "main": - repo.run("checkout", "-b", branch) - result = run_script( - [REPO_ROOT / "ci/scripts/jenkins/should_run_slow_tests.py", "--pr", "1", "--pr-body", body], - cwd=tmp_path, - check=False, - ) - assert result.returncode == expected diff --git a/tests/python/ci/test_jenkins_helpers.py b/tests/python/ci/test_jenkins_helpers.py index cfc0c69ced..f521d41303 100644 --- a/tests/python/ci/test_jenkins_helpers.py +++ b/tests/python/ci/test_jenkins_helpers.py @@ -40,11 +40,9 @@ def executable(path, body): path.chmod(0o755) [email protected]( - "script_root", [JENKINS_SCRIPT_ROOT, REPO_ROOT / "ci/scripts/jenkins"], ids=["native", "legacy"] -) @pytest.mark.parametrize("failures", [2, 3]) -def test_s3_transfer_retries(tmp_path, monkeypatch, failures, script_root): +def test_s3_transfer_retries(tmp_path, monkeypatch, failures): + script_root = JENKINS_SCRIPT_ROOT # Run the real retry.sh with fake AWS and sleep, without network or delays. executable( tmp_path / "aws", @@ -70,7 +68,7 @@ with pathlib.Path(os.environ["SLEEP_LOG"]).open("a") as out: monkeypatch.setenv("FAILURES", str(failures)) monkeypatch.setenv("SLEEP_LOG", str(tmp_path / "sleeps")) monkeypatch.syspath_prepend(str(script_root)) - destination = "file name" if script_root == JENKINS_SCRIPT_ROOT else "artifact.so" + destination = "file name" for transfer in range(2): log = tmp_path / f"aws-{transfer}" monkeypatch.setenv("AWS_LOG", str(log)) diff --git a/tests/python/ci/test_jenkins_loader.py b/tests/python/ci/test_jenkins_loader.py new file mode 100644 index 0000000000..2907c94b78 --- /dev/null +++ b/tests/python/ci/test_jenkins_loader.py @@ -0,0 +1,106 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Exercise the native helper trust boundary and its isolated Git fetch.""" + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from .test_utils import REPO_ROOT, TempGit + +PIPELINES = sorted((REPO_ROOT / "ci/jenkins/generated").glob("*_jenkinsfile.groovy")) +HELPER = "ci/jenkins/generated/common.groovy" + + +def test_native_loader_trust_boundary(): + groovy = shutil.which("groovy") + if groovy is None: + pytest.skip("Groovy is required for the executable pipeline loader checks") + subprocess.run( + [groovy, str(Path(__file__).with_name("jenkins_loader_test.groovy")), *PIPELINES], + check=True, + ) + + [email protected]("failure", [None, "fetch", "missing-helper"]) +def test_trusted_main_fetch(tmp_path, failure): + # Execute the actual loader shell against a local upstream. A Git shim only + # substitutes the transport URL; ref selection and object reads use real Git. + upstream = tmp_path / "upstream" + upstream.mkdir() + repo = TempGit(upstream) + repo.run("config", "user.name", "CI Test") + repo.run("config", "user.email", "[email protected]") + helper = upstream / HELPER + helper.parent.mkdir(parents=True) + helper.write_text("old main helper") + repo.run("add", ".") + repo.run("commit", "-m", "initial main") + repo.run("branch", "release") + helper.write_text("latest main helper") + repo.run("commit", "-am", "advance main") + if failure == "missing-helper": + helper.unlink() + repo.run("commit", "-am", "remove helper") + + checkout = tmp_path / "untrusted-checkout" + checkout.mkdir() + untrusted_helper = checkout / HELPER + untrusted_helper.parent.mkdir(parents=True) + untrusted_helper.write_text("rejected PR helper") + binary_dir = tmp_path / "bin" + binary_dir.mkdir() + git = binary_dir / "git" + git.write_text( + "#!/usr/bin/env python3\n" + "import os, sys\n" + "args = sys.argv[1:]\n" + "url = 'https://github.com/apache/tvm.git'\n" + "if 'fetch' in args:\n" + " assert url in args\n" + " assert '+refs/heads/main:refs/heads/trusted-main' in args\n" + " if os.environ.get('FAIL_FETCH'): sys.exit(7)\n" + " args[args.index(url)] = os.environ['TEST_UPSTREAM']\n" + "os.execv(os.environ['REAL_GIT'], ['git'] + args)\n" + ) + git.chmod(0o755) + env = dict( + os.environ, + PATH=f"{binary_dir}:{os.environ['PATH']}", + REAL_GIT=shutil.which("git"), + TEST_UPSTREAM=str(upstream), + FAIL_FETCH="yes" if failure == "fetch" else "", + CHANGE_TARGET="release", + ) + scripts = { + p.read_text().split("label: 'Read trusted helper from upstream main',")[1].split("'" * 3)[1] + for p in PIPELINES + } + assert len(scripts) == 1 + result = subprocess.run( + ["sh", "-c", scripts.pop()], cwd=checkout, env=env, text=True, capture_output=True + ) + if failure: + assert result.returncode != 0 + assert result.stdout == "" + else: + assert result.returncode == 0, result.stderr + assert result.stdout == "latest main helper" + assert untrusted_helper.read_text() == "rejected PR helper" diff --git a/tests/scripts/task_show_node_info.sh b/tests/scripts/task_show_node_info.sh deleted file mode 100755 index af095ed453..0000000000 --- a/tests/scripts/task_show_node_info.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -set -euxo pipefail - -echo "===== JENKINS INFO =====" -echo "NODE_NAME=$NODE_NAME" -echo "EXECUTOR_NUMBER=$EXECUTOR_NUMBER" -echo "WORKSPACE=$WORKSPACE" -echo "BUILD_NUMBER=$BUILD_NUMBER" -echo "WORKSPACE=$WORKSPACE" - -echo "===== EC2 INFO =====" -function ec2_metadata() { - # See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html - curl -w '\n' -fsSL "http://169.254.169.254/latest/meta-data/$1" || echo failed -} - -ec2_metadata ami-id -ec2_metadata instance-id -ec2_metadata instance-type -ec2_metadata hostname -ec2_metadata public-hostname - -echo "===== RUNNER INFO =====" -df --human-readable -lscpu -free -nvidia-smi 2>/dev/null || echo "CUDA not found"
