From 1f8f44f936ae496cc43a9e433563b9bc7a936d89 Mon Sep 17 00:00:00 2001 From: Tai Le Date: Wed, 19 Aug 2026 17:42:11 +0700 Subject: [PATCH 1/3] ci: gate style, PR hygiene and build on Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint and pull-request hygiene, the slice of the suite that needs no hardware, and the wheel — none of which needs a GPU. GitLab stays the source of truth for the GPU suite and the benchmarks. - prek runs the hooks prek.toml pins over the whole tree. - Title lints the PR title, which squash-merge turns into the commit subject. That holds only because the repo sets squash_merge_commit_title = PR_TITLE; the COMMIT_OR_PR_TITLE default would take a single-commit PR's own subject, which nothing but the opt-in commit-msg hook lints. - DCO checks every commit the PR adds carries a matching sign-off. - contract runs tests/contract and tests/cubin, which import only the standard library and pytest. - wheel builds the extension and imports it. cpp/CMakeLists.txt declares LANGUAGES CXX ASM, so nvcc compiles nothing: the kernels are precompiled CUBINs assembled in as .incbin shards, and the only CUDA dependency is CUDA::cuda_driver against the toolkit's libcuda stub. Nothing links PyTorch, which is why the GitLab job for this already runs on a CPU runner. The first three plus contract share one always-on trigger, so they live in ci.yml. The wheel keeps its own file: it is the only one worth starting by hand, and workflow_dispatch there builds from any branch or tag without a pull request to open first. Adding dispatch grants nothing new — a pull_request run already executes the workflow file from the PR head — and it needs actions: write, so it stays with write collaborators. Both jobs that need LFS fetch the CUBIN packs by pattern rather than passing lfs: true, which pulls 88.2 MB where 2.5 MB is read. They are also the two that keep checkout's credentials, because git lfs pull authenticates with them on an internal repo; the other three drop the token. Every pin is read out of the file that declares it, so CI cannot drift from what `pip install -e '.[dev]'` gives a contributor: prek from requirements-dev.txt, pytest from the same, Python from pyproject.toml. The Python floor rather than python-version-file, which resolves ">=3.12,<4.0" to the newest 3.x the runner has — the published wheel is cp312, and a gate that compiles a different ABI is not the gate docs/dev.md promises. The prek hook cache carries restore-keys, so bumping one hook's rev rebuilds that hook rather than all nine. Title opts out of the cache entirely: it builds commitizen's environment and nothing else, which costs about what saving it does, and skipping it removes the per-job cache key the two jobs otherwise needed to avoid clobbering each other. Jimver/cuda-toolkit is the only third-party action and is pinned to a commit, per NVIDIA ProdSec and NVIDIA/warp's own practice. There is no first-party alternative: the org pattern is to run inside nvcr.io/nvidia/cuda, which ships neither git-lfs for the packs nor a Python setup-python recognises. Signed-off-by: Tai Le --- .github/actions/prek/action.yml | 56 +++++++++++++++ .github/actions/python/action.yml | 30 ++++++++ .github/scripts/check-dco.sh | 88 +++++++++++++++++++++++ .github/scripts/check-pr-title.sh | 73 +++++++++++++++++++ .github/workflows/ci.yml | 82 +++++++++++++++++++++ .github/workflows/pr.yml | 66 +++++++++++++++++ .github/workflows/wheel.yml | 115 ++++++++++++++++++++++++++++++ 7 files changed, 510 insertions(+) create mode 100644 .github/actions/prek/action.yml create mode 100644 .github/actions/python/action.yml create mode 100755 .github/scripts/check-dco.sh create mode 100755 .github/scripts/check-pr-title.sh create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/wheel.yml diff --git a/.github/actions/prek/action.yml b/.github/actions/prek/action.yml new file mode 100644 index 00000000..1903c108 --- /dev/null +++ b/.github/actions/prek/action.yml @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +name: Set up prek +description: > + Install the prek pinned in requirements-dev.txt and restore its hook + environments, so a job runs the same hook versions a contributor gets from + `pip install -e '.[dev]'`. + +inputs: + cache: + description: > + Restore and save the hook environments. Worth it for a job that builds + every hook in prek.toml; for one that builds a single hook the round trip + costs about what the build does. + default: 'true' + +runs: + using: composite + steps: + # requirements-dev.txt owns the pin; reading it here keeps CI from drifting + # away from what `.[dev]` installs. + - name: Resolve the pinned prek version + id: pin + shell: bash + run: | + version=$(sed -n 's/^prek==\([^ #]*\).*/\1/p' requirements-dev.txt) + if [[ -z "${version}" ]]; then + echo "::error file=requirements-dev.txt::no 'prek==' pin found" + exit 1 + fi + echo "version=${version}" >>"${GITHUB_OUTPUT}" + + - uses: ./.github/actions/python + + # prek builds one environment per hook repo under its cache root. Keying on + # prek.toml means a bumped `rev` builds fresh environments instead of reusing + # the ones pinned to the old revision, and `restore-keys` then hands that + # build the environments for the hooks that did *not* move -- prek's cache is + # keyed per repo+rev on disk, so a partial restore is additive, never stale. + # Without it, editing one `exclude` line rebuilds all nine. + - uses: actions/cache@v6 + if: inputs.cache == 'true' + with: + path: ~/.cache/prek + key: prek-${{ runner.os }}-${{ steps.pin.outputs.version }}-${{ hashFiles('prek.toml') }} + restore-keys: | + prek-${{ runner.os }}-${{ steps.pin.outputs.version }}- + + # Through the environment, never interpolated into the script: this value is + # read out of the pull request's own requirements-dev.txt, and `${{ }}` + # expands before bash ever sees it. + # https://docs.github.com/en/actions/reference/security/secure-use + - shell: bash + env: + PREK_VERSION: ${{ steps.pin.outputs.version }} + run: pip install "prek==${PREK_VERSION}" diff --git a/.github/actions/python/action.yml b/.github/actions/python/action.yml new file mode 100644 index 00000000..71620e8f --- /dev/null +++ b/.github/actions/python/action.yml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +name: Set up Python +description: > + Install the oldest Python pyproject.toml supports, so every job agrees on one + interpreter and none of them repeats the version. + +runs: + using: composite + steps: + # The floor, not `python-version-file: pyproject.toml`. That input resolves a + # range to the *highest* match, and `requires-python` is ">=3.12,<4.0", so it + # would build the extension against whatever 3.x the runner ships. The wheel + # this repo publishes is cp312 -- docker/Dockerfile builds it on + # nvcr.io/nvidia/pytorch, whose python is 3.12 -- and a gate that compiles a + # different ABI than the one shipped is not the gate docs/dev.md promises. + - name: Resolve the requires-python floor + id: floor + shell: bash + run: | + version=$(sed -n 's/^requires-python *= *"[^0-9]*\([0-9]*\.[0-9]*\).*/\1/p' pyproject.toml) + if [[ -z "${version}" ]]; then + echo "::error file=pyproject.toml::no 'requires-python' floor found" + exit 1 + fi + echo "version=${version}" >>"${GITHUB_OUTPUT}" + + - uses: actions/setup-python@v7 + with: + python-version: ${{ steps.floor.outputs.version }} diff --git a/.github/scripts/check-dco.sh b/.github/scripts/check-dco.sh new file mode 100755 index 00000000..becb26be --- /dev/null +++ b/.github/scripts/check-dco.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed 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. + +# Enforce the Developer Certificate of Origin on every commit a pull request +# adds: each needs a Signed-off-by trailer carrying the author's own address, +# which is what `git commit -s` writes. See CONTRIBUTING.md. +# +# Usage: check-dco.sh +set -euo pipefail + +if (($# != 2)); then + echo "usage: check-dco.sh " >&2 + exit 2 +fi +base="$1" +head="$2" + +# The base of a pull request is the tip of the target branch, which moves on +# without the branch. Only commits after the fork point belong to this PR, so +# ask git where the two diverged rather than diffing against the tip. +if ! merge_base="$(git merge-base "${base}" "${head}")"; then + echo "cannot find a merge base for ${base} and ${head} -- was the branch" \ + "checked out with fetch-depth: 0?" >&2 + exit 2 +fi + +# --no-merges: a merge from the target branch carries no contribution of its +# own, and the merge commits GitHub itself writes are never signed off. +# +# Read in a `while` over a process substitution rather than `mapfile`, which +# bash 3.2 -- what macOS still ships, and what a contributor testing this +# locally runs -- does not have. +checked=0 +failed=0 +while read -r sha; do + checked=$((checked + 1)) + author_email="$(git show --no-patch --format='%ae' "${sha}")" + subject="$(git show --no-patch --format='%s' "${sha}")" + # Trailers only. A "Signed-off-by:" written into the middle of a commit body + # is prose, and git does not treat it as a sign-off either. + signoffs="$(git show --no-patch --format='%(trailers:key=Signed-off-by,valueonly)' "${sha}")" + if grep -qiF "<${author_email}>" <<<"${signoffs}"; then + echo "ok ${sha:0:12} ${subject}" + continue + fi + failed=1 + echo "FAIL ${sha:0:12} ${subject}" + if [[ -z "${signoffs//[[:space:]]/}" ]]; then + echo " no Signed-off-by trailer" + else + echo " signed off by ${signoffs//$'\n'/, }, but authored by <${author_email}>" + fi +done < <(git rev-list --no-merges "${merge_base}..${head}") + +if ((checked == 0)); then + echo "no commits to check between ${merge_base:0:12} and ${head:0:12}" + exit 0 +fi +((failed)) || exit 0 + +cat >&2 <" +set -euo pipefail + +if (($# != 1)); then + echo "usage: check-pr-title.sh " >&2 + exit 2 +fi +title="$1" + +# `[PROJ-123]` (JIRA key) or `[5123456]` (NVBug ID), and the space behind it. +summary="${title}" +if [[ "${summary}" =~ ^\[([A-Za-z][A-Za-z0-9]*-)?[0-9]+\][[:space:]]*(.*)$ ]]; then + summary="${BASH_REMATCH[2]}" +fi + +# commitizen reads the message from a file, the way the commit-msg hook feeds it +# .git/COMMIT_EDITMSG. +message_file="$(mktemp)" +trap 'rm -f "${message_file}"' EXIT +printf '%s\n' "${summary}" >"${message_file}" + +echo "checking title: ${title}" +if [[ "${summary}" != "${title}" ]]; then + echo "tracker reference stripped, checking: ${summary}" +fi + +if prek run commitizen --stage commit-msg --commit-msg-filename "${message_file}"; then + exit 0 +fi + +cat >&2 <<'EOF' + +The pull request title must be a Conventional Commits subject, optionally +preceded by a tracker reference: + + feat: add a triangle-attention fallback for sm80 + fix(pipeline): stop dropping the last MSA row + [PROJ-123] docs: describe the CUBIN pack layout + +Types: build, bump, chore, ci, docs, feat, fix, perf, refactor, revert, style, +test. Append `!` before the colon for a breaking change. See AGENTS.md and +docs/coding.md, and edit the title -- this check re-runs on its own. +EOF +exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6af40db2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# The two gates that run on every push and need nothing but a checkout: the +# hooks prek.toml pins, and the slice of the suite that needs no GPU, no CUDA +# driver and no built extension. The wheel is wheel.yml, kept apart because it +# is the only one worth starting by hand. +# +# Everything heavier runs on internal hardware through Blossom, which a +# maintainer has to ask for by comment -- this is the signal a contributor gets +# without waiting. +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Only for pull requests: a superseded push to a branch is wasted work, but + # cancelling a run on main would leave that commit with no result. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + # Named `prek`, not `style`: the job name is the context the ruleset requires. + prek: + name: prek + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # No `lfs: true` and no submodules. Every hook reads text: the LFS-tracked + # binaries and the 3rdparty trees are either excluded in prek.toml or match + # no hook's file types, so the pointer files left behind are never opened. + # Nothing here talks to the remote after checkout, so drop the token too. + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/prek + - run: prek run --all-files --show-diff-on-failure --color=always + + contract: + name: contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # The LFS content is load-bearing, but only a slice of it. + # test_committed_corpus_materializes materializes the real committed CUBIN + # packs and says why: "An LFS pointer or a corrupt pack fails here, not at + # pip install time." Verified by rewriting the packs as pointers -- the + # test fails with "is a Git LFS pointer", which is it doing its job. + # + # `lfs: true` would fetch every object in the repo, measured at 88.2 MB + # against the 2.5 MB of packs these suites read; the rest is sample MSAs + # and structures nothing here opens. So fetch the packs by pattern. + # + # The credentials stay: this is an internal repo, and `git lfs pull` below + # authenticates with the header checkout leaves in the local git config. + # + # No submodules: neither suite imports bionemo_ir or reads 3rdparty/. + - uses: actions/checkout@v7 + with: + lfs: false + - name: Fetch the CUBIN packs + run: git lfs pull --include="cpp/kernels/cutedsl_*/cubins/packs/*.tar.xz" + + - uses: ./.github/actions/python + + # requirements-dev.txt owns the floor, the same way the prek action reads + # its pin, so a bump there moves CI with it. `shell: bash` sets pipefail, + # which turns a pattern that stops matching into a failed step rather than + # a silent no-op install. + - name: Install the declared pytest + shell: bash + run: grep -E '^pytest[<>=!~]' requirements-dev.txt | pip install -r /dev/stdin + + # Nothing here imports torch or the package itself, so there is no install + # step and no build. tests/pytest.ini supplies the options. + - run: pytest tests/contract tests/cubin diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 00000000..096f199d --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Checks on the pull request itself rather than its diff: the title, which +# becomes the squashed commit subject, and the sign-off CONTRIBUTING.md +# requires on every commit. +name: Pull request + +on: + pull_request: + # `edited` so retitling a PR re-runs the title check; without it a rejected + # title stays red after the author fixes it. Both jobs share this list, so + # each carries an `if` for the events it has nothing to say about -- a job + # skipped by a conditional reports success and satisfies a required check. + # https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks + types: [opened, edited, reopened, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + title: + name: Title + runs-on: ubuntu-latest + timeout-minutes: 10 + # `edited` also fires on a body edit, which cannot change the title. + if: github.event.action != 'edited' || github.event.changes.title + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + # No hook cache: this builds commitizen's environment and nothing else, + # and saving one environment costs about what building it does. + - uses: ./.github/actions/prek + with: + cache: 'false' + # Through the environment, never interpolated into the script: a PR title + # is untrusted input and `${{ }}` expands before bash ever sees it. + # https://docs.github.com/en/actions/reference/security/secure-use + - env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: .github/scripts/check-pr-title.sh "${PR_TITLE}" + + dco: + name: DCO + runs-on: ubuntu-latest + timeout-minutes: 10 + # The commits only change on synchronize; the rest re-check the same range. + if: github.event.action != 'edited' + steps: + # The full history. The check walks the range this branch adds to the + # base, and a shallow clone carries neither end of it. The default ref is + # the merge commit, whose parents are exactly the two SHAs below, so both + # are present without asking for either by name. + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: .github/scripts/check-dco.sh "${BASE_SHA}" "${HEAD_SHA}" diff --git a/.github/workflows/wheel.yml b/.github/workflows/wheel.yml new file mode 100644 index 00000000..acdf8cde --- /dev/null +++ b/.github/workflows/wheel.yml @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Builds the wheel, which needs no GPU and no driver. `cpp/CMakeLists.txt` +# declares `LANGUAGES CXX ASM`, so nvcc compiles nothing here -- the kernels are +# precompiled CUBINs assembled in as `.incbin` shards, and the only CUDA +# dependency is `CUDA::cuda_driver`, resolved against the libcuda stub the +# toolkit ships. Nothing links PyTorch. +# +# Its own file rather than a third job in ci.yml, because it is the one gate +# worth starting by hand: `workflow_dispatch` builds a wheel from any branch or +# tag and leaves it as an artifact, with no pull request to open first. +name: Wheel + +on: + pull_request: + push: + branches: [main] + # The Run workflow button, and `gh workflow run Wheel --ref <branch>`. Nothing + # in this file reads the pull_request payload, so it needs no inputs. It only + # appears once this file is on the default branch. + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + wheel: + name: wheel + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + # `lfs: true` would pull every LFS object in the repo -- measured at + # 88.2 MB, of which the 36 CUBIN packs are 2.5 MB. The build reads only + # the packs, so fetch only those. The credentials stay for the same reason + # as in ci.yml: `git lfs pull` needs them on an internal repo. + - uses: actions/checkout@v7 + with: + lfs: false + - name: Fetch the CUBIN packs + run: | + git lfs pull --include="cpp/kernels/cutedsl_*/cubins/packs/*.tar.xz" + git lfs ls-files -I "cpp/kernels/cutedsl_*/cubins/packs/*.tar.xz" | wc -l + + - uses: ./.github/actions/python + + # Headers and the libcuda stub, nothing more. `setup.py` reads the + # toolkit version for the wheel's `+cuXYZ` local version segment, so nvcc + # has to be on PATH rather than just the headers being present. + # + # The only third-party action here, so it is pinned to a commit rather + # than a tag, which is what NVIDIA ProdSec asks for and what NVIDIA/warp + # does across its workflows. The org allowlist carries + # `jimver/cuda-toolkit@*`, which a SHA satisfies. + # + # There is no first-party alternative: NVIDIA publishes no setup-CUDA + # action. The org pattern is to run the job inside `nvcr.io/nvidia/cuda` + # (NVIDIA-BioNeMo/nvMolKit does exactly that), which does not fit here -- + # that image ships neither git-lfs for the packs above nor a Python the + # setup-python tool cache recognises. + - uses: Jimver/cuda-toolkit@b8bf9c6c28f8a92fbb04dcfcaee872e60c57462d # v0.2.36 + with: + cuda: '13.2.0' + method: network + sub-packages: '["nvcc", "cudart"]' + + - name: Build the wheel + run: | + pip install --upgrade build + python -m build --wheel + ls -l dist/ + + # Prove the extension in the wheel actually loads, not just that it + # compiled. Two things make this fiddly: + # + # - The module is loaded straight from its file rather than as + # `bionemo_ir.libs._cutedsl_kernels`, because that would execute + # `bionemo_ir/__init__.py`, which imports torch. Nothing in this job + # installs torch and the extension does not link it. + # - At load time the shared object wants a real `libcuda.so.1`, which a + # runner with no GPU has not got. Point the loader at the toolkit's + # stub, the same trick the repo's own CPU harness uses. + - name: Load the extension out of the wheel + run: | + mkdir -p /tmp/cudastub + ln -sf "${CUDA_PATH}/lib64/stubs/libcuda.so" /tmp/cudastub/libcuda.so.1 + LD_LIBRARY_PATH="/tmp/cudastub:${LD_LIBRARY_PATH:-}" python - <<'PY' + import glob, importlib.util, tempfile, zipfile + + wheel = glob.glob("dist/*.whl")[0] + unpacked = tempfile.mkdtemp() + zipfile.ZipFile(wheel).extractall(unpacked) + + found = glob.glob(f"{unpacked}/bionemo_ir/libs/_cutedsl_kernels*.so") + if not found: + raise SystemExit(f"{wheel} carries no _cutedsl_kernels extension") + + spec = importlib.util.spec_from_file_location("_cutedsl_kernels", found[0]) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + print(f"loaded {found[0].removeprefix(unpacked)} from {wheel}") + PY + + # A month, not the 90-day default: this is a debugging aid and a dispatch + # deliverable, not a release artifact. + - uses: actions/upload-artifact@v7 + with: + name: wheel-${{ github.sha }} + path: dist/*.whl + if-no-files-found: error + retention-days: 30 From fb5c4b7fd0035bfd03c01b6736d4b8bf478876c7 Mon Sep 17 00:00:00 2001 From: Tai Le <taile@nvidia.com> Date: Wed, 19 Aug 2026 22:47:58 +0700 Subject: [PATCH 2/3] ci: trigger internal CI from a PR comment The GPU suite, the image build and the extension build cannot run on GitHub runners, so a maintainer asks for them with `/build-ci` and Blossom carries the request to internal Jenkins. GitHub keeps the trigger, the authorization and the result; nothing behind the firewall is exposed. Two gates guard it: the actor must be in `BLOSSOM_AUTHORIZED_USERS`, and `blossom-ci` re-checks them against Blossom's own database and Duo. The list is a repository variable rather than a literal in this file, so on- and offboarding is a settings edit. It is seeded from the `bioir` team but is deliberately not the team -- approving a run spends GPU hours, which is a narrower permission than reviewing code. The chain stays advisory. It only reports once someone asks, so requiring it would block every pull request until a maintainer typed the command. Secrets `BLOSSOM_KEY` and `CI_SERVER` and the self-hosted runner are not in place yet, so the first run of this will queue rather than start. Signed-off-by: Tai Le <taile@nvidia.com> --- .github/workflows/blossom-ci.yml | 173 +++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .github/workflows/blossom-ci.yml diff --git a/.github/workflows/blossom-ci.yml b/.github/workflows/blossom-ci.yml new file mode 100644 index 00000000..bda410f4 --- /dev/null +++ b/.github/workflows/blossom-ci.yml @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# The bridge to internal CI. GitHub owns the trigger, the authorization and the +# result; everything that needs a GPU, the internal registry or a secret runs on +# the Blossom Jenkins master behind the firewall. +# +# A maintainer comments `/build-ci` on a pull request. `Authorization` checks the +# actor against the list below and then against Blossom's own database plus Duo +# MFA; `Vulnerability scan` runs the security scan on the PR head; `Job trigger` +# webhooks Jenkins; Jenkins calls back with `workflow_dispatch`, which is what +# `Upload log` answers. +# +# This chain is advisory, never a required check: it only reports once someone +# asks for it, so requiring it would block every pull request until a maintainer +# types the command. See .ai.dump/github-prep/plan.md for the full split. +# +# The comment is the only trigger, and it is also the API: `gh pr comment <n> +# --body '/build-ci'` fires the same event with the same `github.actor`, so a +# script can start a run without anyone opening a browser. The Run workflow +# button is *not* a second trigger -- see `Upload log` below. +name: Blossom-CI + +on: + issue_comment: + types: [created] + # Jenkins dispatches this back at the end of the run to publish its log. + workflow_dispatch: + inputs: + platform: + description: 'runs-on argument' + required: false + args: + description: 'argument' + required: false + +# The repository default is `read`, so the scopes `blossom-ci` needs have to be +# named. They are not documented -- it is an opaque binary on the self-hosted +# runner -- so this is read off what it demonstrably does: post a commit status +# on the pull request head, and comment the Jenkins log link back. Narrow it +# further once a run has succeeded and the audit log shows what it really used; +# widen it only against a specific failure, never speculatively. +permissions: + contents: read + statuses: write + pull-requests: write + +# No `concurrency:` either. Cancelling a superseded run would drop the callback +# that publishes the Jenkins log while the Jenkins job itself keeps running. + +jobs: + # Two gates, in this order: the allowlist decides who may spend GPU hours, and + # `blossom-ci` AUTH then re-checks the actor against Blossom's own database and + # Duo MFA. Onboarding is therefore two steps either way -- the variable below, + # and the Blossom service desk. Someone added here but not there gets past the + # first gate and is stopped at the second. + # + # The list lives in the repository variable `BLOSSOM_AUTHORIZED_USERS` + # (Settings -> Secrets and variables -> Actions -> Variables), holding a JSON + # array of GitHub logins. It is seeded from the `bioir` team but is not the + # team: approving a run spends GPU hours on internal hardware, and not every + # maintainer needs that. Expect the two lists to diverge, and do not wire this + # to team membership. + # + # ["letientai299", "thanhnamitit", ...] + # + # A variable, not a secret: `secrets` is not one of the contexts available to + # a job-level `if:`, and `vars` is. + # https://docs.github.com/actions/reference/workflows-and-actions/contexts#context-availability + # + # `|| '[]'` makes an unset variable fail closed -- nobody is authorized -- + # rather than erroring on every comment. A malformed value still errors, which + # is what should happen to a typo in an authorization list. + Authorization: + name: Authorization + runs-on: blossom + # Short by design. This job talks to the Blossom database and Duo; if that + # round trip has not finished in five minutes it is broken, not slow, and + # the self-hosted runner has one executor to spare. + timeout-minutes: 5 + outputs: + args: ${{ env.args }} + + # `github.event.issue.pull_request` is what distinguishes a comment on a PR + # from a comment on an issue; without it the job starts on issues and fails + # with no ref to check out. + if: | + github.event.issue.pull_request && + github.event.comment.body == '/build-ci' && + contains(fromJSON(vars.BLOSSOM_AUTHORIZED_USERS || '[]'), github.actor) + steps: + - name: Check if comment is issued by authorized person + run: blossom-ci + env: + OPERATION: 'AUTH' + REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_KEY_DATA: ${{ secrets.BLOSSOM_KEY }} + + Vulnerability-scan: + name: Vulnerability scan + needs: [Authorization] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # The pull request head, named by the Authorization output rather than by + # the event, so the scan reads the code the maintainer approved for a run. + # + # No `lfs: true`: the tracked binaries are CUBIN packs, sample MSAs and + # structures -- 88.2 MB the scan does not read. It looks at declared + # dependencies and source. + - name: Checkout code + uses: actions/checkout@v7 + with: + # Nothing here talks to the remote after checkout, and this is the one + # job that runs unreviewed contributor code. + persist-credentials: false + repository: ${{ fromJson(needs.Authorization.outputs.args).repo }} + ref: ${{ fromJson(needs.Authorization.outputs.args).ref }} + + - name: Run blossom action + # SHA-pinned like every third-party action here, per NVIDIA ProdSec. + # The sibling repos all track `@main`; if Blossom SRE says the action + # must move in lockstep with their server side, this reverts to `@main` + # in one line. + uses: NVIDIA/blossom-action@2b0c950b993808dc31f80a1ccc32615902e39032 # main, 2026-07-13 + env: + REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_KEY_DATA: ${{ secrets.BLOSSOM_KEY }} + with: + args1: ${{ fromJson(needs.Authorization.outputs.args).args1 }} + args2: ${{ fromJson(needs.Authorization.outputs.args).args2 }} + args3: ${{ fromJson(needs.Authorization.outputs.args).args3 }} + + # CI_SERVER holds one `<jenkins-url>@<job-name>` pair, which is why one + # command maps to one Jenkins job. What that job runs is decided on the + # Jenkins side, not here. + Job-trigger: + name: Start ci job + needs: [Vulnerability-scan] + runs-on: blossom + # The webhook out, not the Jenkins run behind it. Jenkins owns its own + # timeout; this job is done once the trigger is acknowledged. + timeout-minutes: 5 + steps: + - name: Start ci job + run: blossom-ci + env: + OPERATION: 'START-CI-JOB' + CI_SERVER: ${{ secrets.CI_SERVER }} + REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The callback half: Jenkins finishes, dispatches this workflow, and this job + # publishes the log link onto the pull request. It is deliberately not part of + # the chain above -- it runs on a different event. + # + # `workflow_dispatch` is Jenkins' channel back, not a way for a human to start + # a run: the three jobs above need the `args` that `blossom-ci` AUTH builds + # from the comment payload, and a dispatch carries no comment. But the Run + # workflow button appears for anyone with write access all the same, so guard + # on the payload Jenkins always sends -- otherwise a curious click starts a job + # on the self-hosted runner with nothing to post. + Upload-Log: + name: Upload log + runs-on: blossom + timeout-minutes: 5 + if: github.event_name == 'workflow_dispatch' && github.event.inputs.args + steps: + - name: Jenkins log for pull request ${{ fromJson(github.event.inputs.args).pr }} (click here) + run: blossom-ci + env: + OPERATION: 'POST-PROCESSING' + CI_SERVER: ${{ secrets.CI_SERVER }} + REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} From e9050e4915428ab9f2ace2b8205d0474810b7f7d Mon Sep 17 00:00:00 2001 From: Tai Le <taile@nvidia.com> Date: Wed, 19 Aug 2026 22:48:09 +0700 Subject: [PATCH 3/3] ci: single-source the pack glob and CUDA tag Two values in .github/ were still written out by hand while everything around them is read from where it is declared. The LFS pack pattern appeared three times. Its source of truth is .gitattributes, which a contract test pins, so a copy that drifts fails quietly: the pull matches nothing, the packs stay pointers, and the error arrives later as "is a Git LFS pointer" rather than as a stale glob. It now lives in a composite action, next to the prek and python ones. The wheel job installed a fixed CUDA 13.2.0 while docker/Dockerfile declares 13.0.2 as the runtime. setup.py turns the toolkit's `nvcc --version` into the wheel's `+cuXYZ` local version segment, so that gap published a wheel labelled for a CUDA no other build path here produces. Reading RUNTIME_TAG closes it; the wheel this job builds is now `+cu130`. Signed-off-by: Tai Le <taile@nvidia.com> --- .github/actions/cubin-packs/action.yml | 25 +++++++++++++++++++++++++ .github/workflows/ci.yml | 3 +-- .github/workflows/wheel.yml | 23 ++++++++++++++++++----- 3 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 .github/actions/cubin-packs/action.yml diff --git a/.github/actions/cubin-packs/action.yml b/.github/actions/cubin-packs/action.yml new file mode 100644 index 00000000..15baa7bf --- /dev/null +++ b/.github/actions/cubin-packs/action.yml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +name: Fetch the CUBIN packs +description: > + Pull the LFS-tracked CUBIN packs and nothing else, so a job that needs them + does not pay for the whole LFS store and no workflow repeats the pattern. + +runs: + using: composite + steps: + # One home for the pattern. `.gitattributes` is the source of truth -- it is + # what marks these paths as LFS, and tests/contract/test_build_contract.py + # asserts the line -- and a copy that drifts from it fails quietly: the pull + # matches nothing, the packs stay pointers, and the error surfaces later as + # "is a Git LFS pointer" rather than as a stale glob. + # + # Callers check out with `lfs: false` and keep their credentials: this is an + # internal repo and the pull authenticates with the header checkout leaves in + # the local git config. + - shell: bash + env: + PACKS: cpp/kernels/cutedsl_*/cubins/packs/*.tar.xz + run: | + git lfs pull --include="${PACKS}" + echo "materialized $(git lfs ls-files -I "${PACKS}" | wc -l) packs" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6af40db2..7546627b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,8 +64,7 @@ jobs: - uses: actions/checkout@v7 with: lfs: false - - name: Fetch the CUBIN packs - run: git lfs pull --include="cpp/kernels/cutedsl_*/cubins/packs/*.tar.xz" + - uses: ./.github/actions/cubin-packs - uses: ./.github/actions/python diff --git a/.github/workflows/wheel.yml b/.github/workflows/wheel.yml index acdf8cde..f69ec991 100644 --- a/.github/workflows/wheel.yml +++ b/.github/workflows/wheel.yml @@ -41,13 +41,26 @@ jobs: - uses: actions/checkout@v7 with: lfs: false - - name: Fetch the CUBIN packs - run: | - git lfs pull --include="cpp/kernels/cutedsl_*/cubins/packs/*.tar.xz" - git lfs ls-files -I "cpp/kernels/cutedsl_*/cubins/packs/*.tar.xz" | wc -l + - uses: ./.github/actions/cubin-packs - uses: ./.github/actions/python + # Not a literal, for the same reason the Python floor is not one. `setup.py` + # turns the toolkit's `nvcc --version` into the wheel's `+cuXYZ` local + # version segment, so a toolkit chosen independently of the runtime image + # publishes a wheel labelled for a CUDA no other build path here produces. + # docker/Dockerfile's RUNTIME_TAG is where that version is declared. + - name: Resolve the CUDA version from the runtime image tag + id: cuda + shell: bash + run: | + version=$(sed -n 's/^ARG RUNTIME_TAG=\([0-9]*\.[0-9]*\.[0-9]*\).*/\1/p' docker/Dockerfile) + if [[ -z "${version}" ]]; then + echo "::error file=docker/Dockerfile::no 'ARG RUNTIME_TAG=<x.y.z>-...' found" + exit 1 + fi + echo "version=${version}" >>"${GITHUB_OUTPUT}" + # Headers and the libcuda stub, nothing more. `setup.py` reads the # toolkit version for the wheel's `+cuXYZ` local version segment, so nvcc # has to be on PATH rather than just the headers being present. @@ -64,7 +77,7 @@ jobs: # setup-python tool cache recognises. - uses: Jimver/cuda-toolkit@b8bf9c6c28f8a92fbb04dcfcaee872e60c57462d # v0.2.36 with: - cuda: '13.2.0' + cuda: ${{ steps.cuda.outputs.version }} method: network sub-packages: '["nvcc", "cudart"]'