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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ on:
- "scripts/validate_package_artifact.py"
- "scripts/validate_installed_package.py"
- "scripts/validate_examples.py"
- "scripts/generate_release_metadata.py"
- "scripts/validate_release_metadata.py"
- "examples/**"
- "compatibility/**"
- "docs/releasing.md"
Expand Down Expand Up @@ -95,14 +97,37 @@ jobs:
- name: Validate package indexes
run: python -m twine check dist/*

- name: Generate release checksums and SPDX SBOM
env:
SOURCE_REVISION: ${{ github.sha }}
SOURCE_DATE_EPOCH: ${{ github.event.head_commit.timestamp || '0' }}
run: python scripts/generate_release_metadata.py dist

- name: Validate release checksums and SPDX SBOM
env:
SOURCE_REVISION: ${{ github.sha }}
run: python scripts/validate_release_metadata.py dist

- name: Upload reviewed distributions
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: base-cli-dist-${{ github.run_id }}
path: dist/*
path: |
dist/*.whl
dist/*.tar.gz
if-no-files-found: error
retention-days: 14

- name: Upload release metadata
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: base-cli-release-metadata-${{ github.run_id }}
path: |
dist/SBOM.spdx.json
dist/SHA256SUMS
if-no-files-found: error
retention-days: 90

smoke:
name: Install smoke test (Python ${{ matrix.python-version }})
needs: build
Expand Down Expand Up @@ -184,3 +209,37 @@ jobs:
uses: pypa/gh-action-pypi-publish@4bb033805d9e19112d8c697528791ff53f6c2f74
with:
packages-dir: dist

attest:
name: Attest reviewed release
needs: [build, smoke]
if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Download reviewed distributions
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: base-cli-dist-${{ github.run_id }}
path: dist

- name: Download release metadata
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: base-cli-release-metadata-${{ github.run_id }}
path: dist

- name: Attest artifact provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-checksums: dist/SHA256SUMS

- name: Attest SPDX SBOM
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-checksums: dist/SHA256SUMS
sbom-path: dist/SBOM.spdx.json
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ and versions are tracked in the repo-root `VERSION` file.

- Add a framework choice guide, five-minute evaluation path, and clearer
production-lifecycle positioning for Click and Typer adopters.
- Add deterministic SPDX SBOMs, artifact checksums, and OIDC-backed GitHub
attestations to protected release workflows.

### Changed

Expand Down
34 changes: 33 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,39 @@ redaction, protocol framing, persistence, concurrency, retention, and signal
cleanup.

The publish job downloads that same reviewed artifact; it does not rebuild
during publication.
during publication. The build also emits a deterministic `SHA256SUMS` file and
an SPDX 2.3 `SBOM.spdx.json` release artifact. On tag and protected dispatch
runs, GitHub's OIDC-backed `actions/attest` job records both build provenance
and an SBOM attestation for the exact artifact digests; no PyPI token or other
long-lived publish secret is used.

## Independent verification

Download the release metadata artifact from the successful Package workflow
run (the artifact is named `base-cli-release-metadata-<run-id>`), alongside
the wheel or sdist you downloaded from PyPI:

```bash
gh run download <run-id> \
--repo basefoundry/base-cli \
--name base-cli-release-metadata-<run-id> \
--dir release-metadata
sha256sum -c release-metadata/SHA256SUMS
```

The SPDX document's namespace and comment include the source revision used by
the workflow. For a tagged release, verify the matching GitHub attestations
with the GitHub CLI:

```bash
gh attestation verify base_cli-<version>-py3-none-any.whl \
--repo basefoundry/base-cli
```

The same command can verify the sdist. A clean-room verifier should compare
the downloaded artifact's digest with `SHA256SUMS`, confirm the SBOM namespace
contains the expected tag commit, and inspect the attestation's workflow and
repository identity before installation.

## Documentation site

Expand Down
150 changes: 150 additions & 0 deletions scripts/generate_release_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Create deterministic release checksums and an SPDX 2.3 dependency SBOM."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import tomllib # type: ignore[import-untyped]

PACKAGE_NAME = "base-cli"
SBOM_NAME = "SBOM.spdx.json"
CHECKSUMS_NAME = "SHA256SUMS"


def _root() -> Path:
return Path(__file__).resolve().parents[1]


def _revision(root: Path) -> str:
value = os.environ.get("SOURCE_REVISION") or os.environ.get("GITHUB_SHA")
if value:
return value
try:
return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip()
except (OSError, subprocess.CalledProcessError):
return "unknown"


def _created_at() -> str:
try:
epoch = int(os.environ.get("SOURCE_DATE_EPOCH", "0"))
except ValueError:
epoch = 0
return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat().replace("+00:00", "Z")


def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()


def _spdx_id(value: str) -> str:
return "SPDXRef-" + "".join(character if character.isalnum() else "-" for character in value)


def _dependency_packages(project: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
packages: list[dict[str, Any]] = []
relationships: list[dict[str, str]] = []
source_id = _spdx_id(PACKAGE_NAME)
dependencies: list[tuple[str, str]] = []
dependencies.extend(("runtime", value) for value in project.get("project", {}).get("dependencies", []))
for extra, values in project.get("project", {}).get("optional-dependencies", {}).items():
dependencies.extend((extra, value) for value in values)
for extra, requirement in dependencies:
name = requirement.split(";", 1)[0].split("[", 1)[0].strip()
for delimiter in ("<", ">", "=", "!", "~", " "):
name = name.split(delimiter, 1)[0].strip()
dependency_id = _spdx_id(f"{extra}-{name}")
packages.append(
{
"SPDXID": dependency_id,
"name": name,
"versionInfo": requirement,
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
}
)
relationships.append(
{
"spdxElementId": source_id,
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": dependency_id,
}
)
return packages, relationships


def generate(dist: Path, root: Path) -> None:
artifacts = sorted((*dist.glob("*.whl"), *dist.glob("*.tar.gz")))
if len(artifacts) != 2:
raise SystemExit(f"expected one wheel and one sdist in {dist}, found {len(artifacts)}")
version = (root / "VERSION").read_text(encoding="utf-8").splitlines()[0].strip()
revision = _revision(root)
project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
source_id = _spdx_id(PACKAGE_NAME)
dependency_packages, relationships = _dependency_packages(project)
(dist / CHECKSUMS_NAME).write_text(
"\n".join(f"{_sha256(path)} {path.name}" for path in artifacts) + "\n", encoding="utf-8"
)
sbom: dict[str, Any] = {
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": f"{PACKAGE_NAME}-{version}",
"documentNamespace": f"https://basefoundry.github.io/base-cli/sbom/{version}/{revision}",
"creationInfo": {
"created": _created_at(),
"creators": ["Tool: base-cli release metadata generator"],
"comment": f"Source revision: {revision}",
},
"documentComment": f"Source revision: {revision}; artifacts are listed in SHA256SUMS.",
"packages": [
{
"SPDXID": source_id,
"name": PACKAGE_NAME,
"versionInfo": version,
"downloadLocation": "https://pypi.org/project/base-cli/",
"filesAnalyzed": False,
"licenseConcluded": "Apache-2.0",
"licenseDeclared": "Apache-2.0",
"copyrightText": "NOASSERTION",
},
*dependency_packages,
],
"relationships": [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relationshipType": "DESCRIBES",
"relatedSpdxElement": source_id,
},
*relationships,
],
}
(dist / SBOM_NAME).write_text(json.dumps(sbom, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"Generated {SBOM_NAME} and {CHECKSUMS_NAME} for {PACKAGE_NAME} {version} at {revision}.")


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("dist", type=Path, help="directory containing the wheel and sdist")
args = parser.parse_args()
if not args.dist.is_dir():
raise SystemExit(f"distribution directory does not exist: {args.dist}")
generate(args.dist, _root())


if __name__ == "__main__":
main()
69 changes: 69 additions & 0 deletions scripts/validate_release_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Validate release checksums, SPDX metadata, and source revision binding."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
from pathlib import Path
from typing import Any

SBOM_NAME = "SBOM.spdx.json"
CHECKSUMS_NAME = "SHA256SUMS"


def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()


def _fail(message: str) -> None:
raise SystemExit(f"release metadata validation failed: {message}")


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("dist", type=Path)
args = parser.parse_args()
checksums_path = args.dist / CHECKSUMS_NAME
sbom_path = args.dist / SBOM_NAME
if not checksums_path.is_file() or not sbom_path.is_file():
_fail(f"{SBOM_NAME} and {CHECKSUMS_NAME} are required")
rows: dict[str, str] = {}
for line in checksums_path.read_text(encoding="utf-8").splitlines():
parts = line.split()
if len(parts) != 2 or len(parts[0]) != 64:
_fail(f"invalid checksum row: {line!r}")
rows[parts[1]] = parts[0]
artifacts = sorted((*args.dist.glob("*.whl"), *args.dist.glob("*.tar.gz")))
if set(rows) != {path.name for path in artifacts} or len(artifacts) != 2:
_fail("SHA256SUMS must cover exactly one wheel and one sdist")
for path in artifacts:
if _sha256(path) != rows[path.name]:
_fail(f"checksum mismatch for {path.name}")
try:
sbom: dict[str, Any] = json.loads(sbom_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
_fail(f"invalid SPDX JSON: {exc}")
if sbom.get("spdxVersion") != "SPDX-2.3":
_fail("SBOM must use SPDX-2.3")
if sbom.get("dataLicense") != "CC0-1.0":
_fail("SBOM data license must be CC0-1.0")
expected_revision = os.environ.get("SOURCE_REVISION") or os.environ.get("GITHUB_SHA")
if expected_revision and expected_revision not in str(sbom.get("documentNamespace")):
_fail("SBOM namespace is not bound to SOURCE_REVISION")
if expected_revision and expected_revision not in str(sbom.get("documentComment")):
_fail("SBOM comment is not bound to SOURCE_REVISION")
packages = sbom.get("packages")
if not isinstance(packages, list) or not any(package.get("name") == "base-cli" for package in packages):
_fail("SBOM does not describe base-cli")
print(f"Validated {len(artifacts)} artifact hashes and SPDX SBOM {sbom_path}.")


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions tests/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ required_files=(
scripts/validate_docs.py
scripts/validate_examples.py
scripts/validate_consumers.py
scripts/generate_release_metadata.py
scripts/validate_release_metadata.py
scripts/benchmark_runtime.py
tests/conftest.py
compatibility/README.md
Expand Down
Loading