Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4d3c198
Add manual support hotfix wheel image publisher
zozozeezee Nov 3, 2026
ce4beb7
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 3, 2026
4a7077d
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 3, 2026
d8a9b61
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 4, 2026
c892bd9
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 4, 2026
277e501
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 4, 2026
02197a2
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 4, 2026
d258191
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 4, 2026
f580441
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 4, 2026
476ee9b
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 4, 2026
5dff643
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 4, 2026
76f4d35
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 5, 2026
df2dcf5
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 5, 2026
7791e6b
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 5, 2026
58f4db7
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 6, 2026
eaf9d73
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 6, 2026
4e27c93
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 6, 2026
70354dc
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 6, 2026
6c8912c
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 6, 2026
49bd34f
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 6, 2026
45c5bc7
Merge branch 'main' into support-hotfix-image
github-actions[bot] Nov 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/hotfix-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Publish support hotfix image

on:
workflow_dispatch:

permissions:
contents: read
packages: write

jobs:
publish:
if: github.repository == 'NVIDIA/SkillSpector'
runs-on: ubuntu-latest
env:
IMAGE: ghcr.io/nvidia/skillspector-hotfix
SOURCE_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Build package image from the selected branch commit
id: build
shell: bash
run: |
BUILD_CONTEXT=$(mktemp -d)
trap 'rm -rf "$BUILD_CONTEXT"' EXIT
# Only committed public package inputs enter the build. Reproduce
# support failures with synthetic examples; never upload case data.
git archive --format=tar "$SOURCE_SHA" \
Dockerfile.hotfix pyproject.toml README.md LICENSE src/skillspector \
| tar -xf - -C "$BUILD_CONTEXT"
docker build --platform linux/amd64 -f "$BUILD_CONTEXT/Dockerfile.hotfix" \
--label "org.opencontainers.image.source=https://github.com/NVIDIA/SkillSpector" \
--label "org.opencontainers.image.revision=$SOURCE_SHA" \
-t "$IMAGE:sha-$SOURCE_SHA" "$BUILD_CONTEXT"
- name: Publish candidate, without changing any release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_ACTOR: ${{ github.actor }}
run: |
printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GH_ACTOR" --password-stdin
trap 'docker logout ghcr.io' EXIT
docker push "$IMAGE:sha-$SOURCE_SHA"
DIGEST_REF=$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE:sha-$SOURCE_SHA")
case "$DIGEST_REF" in "$IMAGE"@sha256:*) ;; *) echo 'Missing published digest'; exit 1 ;; esac
printf 'SKILLSPECTOR_HOTFIX_IMAGE=%s\n' "$DIGEST_REF" > hotfix.env
{
printf '### Support hotfix candidate\n\nCommit: `%s`\n\n' "$SOURCE_SHA"
printf 'Review and approve this candidate in your validation system before use.\n\n```text\n'
cat hotfix.env
printf '```\n'
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: support-hotfix-image
path: hotfix.env
retention-days: 7
9 changes: 9 additions & 0 deletions Dockerfile.hotfix
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# A package transport image for validation systems, not a scanner runtime.
FROM python:3.12-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS wheel
WORKDIR /src
COPY pyproject.toml README.md LICENSE ./
COPY src/skillspector/ src/skillspector/
RUN python -m pip wheel --no-deps --no-cache-dir --wheel-dir /wheels .

FROM scratch
COPY --from=wheel /wheels/ /opt/hotfix/wheels/
150 changes: 150 additions & 0 deletions tests/unit/test_hotfix_image_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Exercise the publisher's committed-source boundary without a registry."""

from __future__ import annotations

import os
import re
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path

import yaml

ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = ROOT / ".github/workflows/hotfix-image.yml"


def test_manual_publisher_keeps_checkout_credentials_out_of_the_build() -> None:
assert WORKFLOW.exists(), "Missing manual hotfix publisher"
workflow = yaml.load(WORKFLOW.read_text(), Loader=yaml.BaseLoader)
assert set(workflow["on"]) == {"workflow_dispatch"}
job = workflow["jobs"]["publish"]
assert job["if"] == "github.repository == 'NVIDIA/SkillSpector'"
assert workflow["permissions"] == {"contents": "read", "packages": "write"}
actions = [step for step in job["steps"] if "uses" in step]
assert all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", step["uses"]) for step in actions)
checkout = next(step for step in actions if step["uses"].startswith("actions/checkout@"))
assert checkout["with"]["persist-credentials"] == "false"
upload = next(step for step in actions if step["uses"].startswith("actions/upload-artifact@"))
assert upload["with"]["path"] == "hotfix.env"


def test_build_context_and_wheel_exclude_unrelated_and_uncommitted_files(tmp_path: Path) -> None:
assert WORKFLOW.exists(), "Missing manual hotfix publisher"
dockerfile = ROOT / "Dockerfile.hotfix"
assert dockerfile.exists(), "Missing package transport Dockerfile"
workflow = yaml.load(WORKFLOW.read_text(), Loader=yaml.BaseLoader)
build = next(step for step in workflow["jobs"]["publish"]["steps"] if step.get("id") == "build")

checkout = tmp_path / "checkout"
checkout.mkdir()
for name in ("Dockerfile.hotfix", "pyproject.toml", "README.md", "LICENSE"):
shutil.copyfile(ROOT / name, checkout / name)
shutil.copytree(
ROOT / "src/skillspector",
checkout / "src/skillspector",
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
)
fixture = checkout / "tests/customer-fixture.txt"
fixture.parent.mkdir()
fixture.write_text("unrelated-committed-fixture\n")
subprocess.run(["git", "init", "-q", str(checkout)], check=True)
subprocess.run(["git", "add", "."], cwd=checkout, check=True)
subprocess.run(
[
"git",
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
"Public package fixture",
],
cwd=checkout,
check=True,
)
sha = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=checkout, text=True).strip()
(checkout / "README.md").write_text("uncommitted-readme-marker\n")
(checkout / "src/skillspector/untracked-fixture.txt").write_text("untracked-fixture-marker\n")
(checkout / ".env").write_text("SYNTHETIC_VALUE=not-for-the-build\n")

# Docker is the external boundary; execute the real context preparation,
# then capture its actual input instead of contacting a daemon or registry.
commands = tmp_path / "bin"
commands.mkdir()
docker = commands / "docker"
docker.write_text(
f"#!{sys.executable}\n"
"import os, shutil, sys\n"
"assert sys.argv[1] == 'build'\n"
"shutil.copytree(sys.argv[-1], os.environ['CAPTURED_CONTEXT'])\n"
)
docker.chmod(0o755)
context = tmp_path / "context"
subprocess.run(
["bash", "-euo", "pipefail", "-c", build["run"]],
cwd=checkout,
env={
**os.environ,
"PATH": f"{commands}{os.pathsep}{os.environ['PATH']}",
"SOURCE_SHA": sha,
"IMAGE": "ghcr.io/nvidia/skillspector-hotfix",
"CAPTURED_CONTEXT": str(context),
},
check=True,
capture_output=True,
text=True,
)
assert {path.name for path in context.iterdir()} == {
"Dockerfile.hotfix",
"pyproject.toml",
"README.md",
"LICENSE",
"src",
}
assert {path.name for path in (context / "src").iterdir()} == {"skillspector"}
assert (context / "README.md").read_bytes() == (ROOT / "README.md").read_bytes()
assert not (context / "src/skillspector/untracked-fixture.txt").exists()

wheels = tmp_path / "wheels"
subprocess.run(
[
sys.executable,
"-m",
"build",
"--no-isolation",
"--wheel",
"--outdir",
str(wheels),
str(context),
],
check=True,
capture_output=True,
text=True,
)
built = list(wheels.glob("*.whl"))
assert len(built) == 1
with zipfile.ZipFile(built[0]) as wheel:
assert "skillspector/__init__.py" in wheel.namelist()
licenses = [
name for name in wheel.namelist() if name.endswith(".dist-info/licenses/LICENSE")
]
assert len(licenses) == 1
assert wheel.read(licenses[0]) == (ROOT / "LICENSE").read_bytes()
assert all(
name.startswith("skillspector/") or re.match(r"skillspector-[^/]+\.dist-info/", name)
for name in wheel.namelist()
)
for name in wheel.namelist():
data = wheel.read(name)
assert b"unrelated-committed-fixture" not in data
assert b"uncommitted-readme-marker" not in data
assert b"untracked-fixture-marker" not in data