Skip to content
Open
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
237 changes: 237 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
# Release CD — the automation half of the release contract in docs/RELEASE.md.
#
# Trigger: pushing a release tag (git tag -s vX.Y.Z && git push origin vX.Y.Z). The gates run
# in order — version guard -> unit tests -> HF weights verification -> build -> PyPI publish
# (trusted publishing) -> GitHub Release — so a half-released state is impossible: nothing
# publishes unless the tag matches pyproject.toml, the suite is green, and the blessed
# weights tag already exists on the HF model repo (CD verifies weights, never creates them —
# weights only come from cluster training runs).
#
# One-time maintainer prerequisites (see docs/RELEASE.md):
# - PyPI: create the `aetherscan` project with GitHub as a trusted publisher
# (repository zachtheyek/Aetherscan, workflow release.yml, environment pypi) and create
# the `pypi` environment in this repo's settings. No API token is stored anywhere.
# - For the TestPyPI dry run: the same trusted-publisher setup on test.pypi.org with
# environment `testpypi`.
name: Release

on:
push:
tags: ["v*"]
# Dry-run path (recommended before the first real release): manual dispatch publishes to
# test.pypi.org. Real releases only ever happen via tag pushes — a manual run with
# test_pypi unchecked fails the guard.
workflow_dispatch:
inputs:
test_pypi:
description: "Publish to test.pypi.org (dry run)"
type: boolean
default: true

permissions:
contents: read

env:
# The HF model repo carrying the release weights. Must match config.hf.repo_id's default
# (src/aetherscan/config.py).
HF_WEIGHTS_REPO: zachtheyek/aetherscan

jobs:
# Gate 1: fail loudly before anything runs if the pushed tag doesn't equal
# "v" + pyproject.toml's [project].version (i.e. the release PR's version bump didn't
# land on the tagged commit), or if a manual run tries to target the real PyPI.
guard:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.resolve.outputs.version }}
dry_run: ${{ steps.resolve.outputs.dry_run }}
steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Resolve version and enforce tag match
id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
TEST_PYPI: ${{ inputs.test_pypi }}
run: |
VERSION=$(python3 -c "
import tomllib
with open('pyproject.toml', 'rb') as f:
print(tomllib.load(f)['project']['version'])
")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
if [ "$TEST_PYPI" != "true" ]; then
echo "::error::Manual runs only support the TestPyPI dry run (test_pypi: true)." \
"Real releases must go through a signed tag push (docs/RELEASE.md step 5)."
exit 1
fi
echo "dry_run=true" >> "$GITHUB_OUTPUT"
echo "Dry run: will publish aetherscan $VERSION to test.pypi.org"
exit 0
fi
echo "dry_run=false" >> "$GITHUB_OUTPUT"
if [ "$REF_TYPE" != "tag" ]; then
echo "::error::Release runs must be triggered by a tag push, got ref type '$REF_TYPE'."
exit 1
fi
if [ "$REF_NAME" != "v$VERSION" ]; then
echo "::error::Tag '$REF_NAME' does not match pyproject.toml version '$VERSION'" \
"(expected tag 'v$VERSION'). Land the release PR that bumps the version," \
"then tag its merge commit (docs/RELEASE.md steps 4-5)."
exit 1
fi
echo "Version guard passed: $REF_NAME == v$VERSION"

# Gate 2: a release build must not outrun a red suite. Calls tests.yml as a reusable
# workflow — the same suite and selection as regular CI, by construction.
tests:
needs: guard
uses: ./.github/workflows/tests.yml
Comment on lines +91 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good design: tests and verify-weights both depend only on guard, so they run in parallel. The build job then fans-in on all three. This means a failing test suite doesn't block the fast HF tag check and vice versa — the failure surfaces as early as possible.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — that fan-out/fan-in is deliberate: the tag check is seconds while the suite is minutes, so a skipped blessing step surfaces almost immediately instead of hiding behind a full test run.


# Gate 3: verify the blessed weights tag exists on the HF model repo (public read, no
# token). Verify, never create: if this fails, the release runbook's weight-blessing step
# was skipped — the fix is utils/hf_tag_release.py, not anything in CI.
verify-weights:
needs: guard
runs-on: ubuntu-latest
steps:
- name: Skip on dry run
if: needs.guard.outputs.dry_run == 'true'
run: |
echo "Dry run: skipping HF weights verification — pre-release versions" \
"(${{ needs.guard.outputs.version }}) never have a blessed weights tag."

- name: Set up Python
if: needs.guard.outputs.dry_run != 'true'
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Verify HF weights tag exists
if: needs.guard.outputs.dry_run != 'true'
env:
RELEASE_TAG: v${{ needs.guard.outputs.version }}
run: |
# Range mirrors requirements-container.txt
pip install "huggingface_hub>=1.21,<1.22"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The huggingface_hub version installed here (>=1.21,<1.22) mirrors requirements-container.txt — good. Worth noting that this inline pip install doesn't benefit from setup-python's pip cache (no cache-dependency-path references this workflow for this job). Not a real problem since this step is fast, but if the install ever grows, a pinned requirements file or a cache key would help.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it's negligible today: one pure-Python wheel installed once per release run. Left uncached deliberately — a cache key would add maintenance for no measurable win at this size. If this step ever grows a real dependency set, moving it to a pinned requirements file + setup-python cache is the right shape.

python - <<'EOF'
import os
import sys

from huggingface_hub import HfApi

repo_id = os.environ["HF_WEIGHTS_REPO"]
tag = os.environ["RELEASE_TAG"]
tags = [ref.name for ref in HfApi().list_repo_refs(repo_id, repo_type="model").tags]
if tag in tags:
print(f"Weights tag '{tag}' exists on https://huggingface.co/{repo_id} — OK")
sys.exit(0)
print(
f"::error::Weights tag '{tag}' not found on https://huggingface.co/{repo_id} "
f"(existing tags: {', '.join(sorted(tags)) or 'none'}). CD verifies release "
f"weights but never creates them. Bless the trained weights first — "
f"python utils/hf_tag_release.py --save-tag <final_vN> --release {tag} — "
f"then re-run this workflow. The git tag is already correct; do not re-push it. "
f"See docs/RELEASE.md step 3."
)
sys.exit(1)
EOF

# Build the sdist + wheel from the tagged source, and smoke the wheel: it must install
# and report exactly the guarded version.
build:
needs: [guard, tests, verify-weights]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Build sdist and wheel
run: |
python -m pip install --upgrade pip build
python -m build

- name: Smoke the wheel (installs and reports the guarded version)
env:
EXPECTED_VERSION: ${{ needs.guard.outputs.version }}
run: |
# --no-deps: the full dependency tree (TF + CUDA wheels) is exercised by the test
# jobs; here we only check packaging metadata and importability.
pip install --no-deps dist/*.whl
INSTALLED=$(python -c "import aetherscan; print(aetherscan.__version__)")
if [ "$INSTALLED" != "$EXPECTED_VERSION" ]; then
echo "::error::Installed wheel reports version '$INSTALLED', expected '$EXPECTED_VERSION'."
exit 1
fi
echo "Wheel smoke passed: aetherscan.__version__ == $INSTALLED"

- name: Upload distribution artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

# Publish via PyPI trusted publishing (OIDC): the `pypi` / `testpypi` GitHub environment
# plus id-token permission is the whole credential — no stored API token.
publish:
needs: [guard, build]
runs-on: ubuntu-latest
environment:
name: ${{ needs.guard.outputs.dry_run == 'true' && 'testpypi' || 'pypi' }}
url: ${{ needs.guard.outputs.dry_run == 'true' && 'https://test.pypi.org/p/aetherscan' || 'https://pypi.org/p/aetherscan' }}
permissions:
id-token: write
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Publish to TestPyPI (dry run)
if: needs.guard.outputs.dry_run == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/

- name: Publish to PyPI
if: needs.guard.outputs.dry_run != 'true'
uses: pypa/gh-action-pypi-publish@release/v1

# Decorate the tag into a GitHub Release with generated notes and the built artifacts
# attached. The generated notes are a starting point — curate the body from the per-PR
# claude-release-notes comments afterwards (docs/RELEASE.md step 6).
github-release:
needs: [guard, publish]
if: needs.guard.outputs.dry_run != 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Download distribution artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: v${{ needs.guard.outputs.version }}
run: |
gh release create "$RELEASE_TAG" dist/* \
--verify-tag \
--title "$RELEASE_TAG" \
--generate-notes
5 changes: 4 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
# Unit test suite (pytest)
name: Tests

# Run on every PR and on pushes to master
# Run on every PR and on pushes to master. Also callable as a reusable workflow so the
# release CD (release.yml) runs the exact same suite as a publish gate — by construction,
# not by copy-paste.
on:
push:
branches: ["master"]
pull_request:
branches: ["**"]
workflow_dispatch:
workflow_call:

permissions:
contents: read
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ outputs/
# Container artifacts
*.sif

# Python packaging artifacts (python -m build)
dist/
*.egg-info/


### Global gitignore (https://github.com/zachtheyek/dotfiles/blob/master/git/.gitignore_global)

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -899,9 +899,10 @@ options:
--hf-revision HF_REVISION
HuggingFace revision (tag, branch, or commit hash) to
pin the model download to when no local artifact paths
are given (default: the repo's latest release tag —
highest semver vX.Y.Z tag, falling back to the highest
final_vX training tag)
are given (default: v{package version} when running as
an installed release, else the repo's latest release
tag — highest semver vX.Y.Z tag, falling back to the
highest final_vX training tag)
--save-tag SAVE_TAG Tag for current pipeline run. Current timestamp used
(YYYYMMDD_HHMMSS) if none specified
--force-tag, --no-force-tag
Expand Down
3 changes: 2 additions & 1 deletion environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ dependencies:
# nvidia-nccl-cu12 2.21.x, etc. -- the exact ABI the TF 2.17 wheel was built
# against. Driver requirement: NVIDIA driver >= 525.60 on the host.
- tensorflow[and-cuda]==2.17.*
- setigen
# Range mirrors requirements-container.txt (see the rationale there).
- setigen>=2.6,<3
- imageio>=2.34,<3
- umap-learn>=0.5.6,<0.6
- shap>=0.46.0,<0.47
Expand Down
61 changes: 48 additions & 13 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# TODO: add build backend (uv)
# TODO: How to release as package on PyPI & display on GitHub? How to tag new releases on GitHub?
# [build-system]
# requires = ["hatchling"]
# build-backend = "hatchling.build"
#
# [tool.hatch.build.targets.wheel]
# packages = ["src/aetherscan"]
# Packaging + release process: docs/RELEASE.md (version coupling, CD gates, runbook).
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/aetherscan"]

[project]
name = "aetherscan"
version = "0.1.0"
# Single source of truth for the package version (src/aetherscan/__init__.py reads it back
# via importlib.metadata). Kept a pre-release between releases; the release PR bumps it to
# the final X.Y.Z, and the CD workflow (.github/workflows/release.yml) enforces that the
# pushed git tag matches it exactly.
version = "0.9.0.dev0"
authors = [
{ name = "Zach Yek", email = "xiaoxiyek1.5@gmail.com" },
]
Expand All @@ -20,7 +23,34 @@ description = "Breakthrough Listen's first end-to-end production-grade DL pipeli
readme = "README.md"
requires-python = ">=3.10,<3.13"
dependencies = [
# TODO: sync with environment.yml files (include for uv)
# Mirrors requirements-container.txt's ranges — keep the two (and environment.yml /
# aetherscan.def) in lockstep per SECURITY.md's Version Selection Policy. Documented
# ceilings (numpy<2.0, setuptools<81, the NGC TF 2.17 ABI) apply to the package exactly
# as to the container.
"numpy>=1.26,<2.0",
"scipy>=1.13,<1.14",
"scikit-learn>=1.5,<1.6",
"matplotlib>=3.9,<3.10",
"astropy>=6.1,<7",
"scikit-image>=0.24,<0.25",
"joblib>=1.4,<2",
"imageio>=2.34,<3",
"umap-learn>=0.5.6,<0.6",
"shap>=0.46.0,<0.47",
"slack-sdk>=3.31.0,<4",
"python-dotenv>=1.0,<2",
"huggingface_hub>=1.21,<1.22",
# setuptools provides pkg_resources, which blimpy (transitive via setigen) imports at
# module load; <81 because setuptools 81 removed the vendored pkg_resources.
"setuptools>=70,<81",
"setigen>=2.6,<3",
# The NGC base image provides these implicitly (so requirements-container.txt omits
# them); a pip install has no base image to lean on and must declare them. Ranges
# mirror environment.yml's pins.
"tensorflow[and-cuda]==2.17.*",
"h5py>=3.11,<3.12",
"hdf5plugin",
"psutil>=6.0,<6.1",
]
license = "BSD-3-Clause"
license-files = ["LICENSE"]
Expand All @@ -35,12 +65,17 @@ keywords = [
"Data Methods",
]
classifiers = [
"Development Status :: 2 - Pre-Alpha", # TODO: update development status on v1 release
# TODO: v1 release PR — flip to "Development Status :: 4 - Beta" or
# "Development Status :: 5 - Production/Stable" (maintainer's call at release time;
# revisit in every release PR per docs/RELEASE.md)
"Development Status :: 2 - Pre-Alpha",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.12",
"Operating System :: Unix",
"Environment :: GPU :: NVIDIA CUDA :: 12 :: 12.4",
"Environment :: GPU :: NVIDIA CUDA :: 12 :: 12.8",
# NOTE: the NGC 25.02 container runs CUDA 12.8, but the trove classifier list currently
# tops out at "12 :: 12.6" — the bare ":: 12" parent is the closest valid classifier
"Environment :: GPU :: NVIDIA CUDA :: 12",
"License :: OSI Approved :: BSD License",
"Intended Audience :: Science/Research",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
Expand All @@ -53,7 +88,7 @@ Homepage = "https://github.com/zachtheyek/Aetherscan"
Issues = "https://github.com/zachtheyek/Aetherscan/issues"

[project.optional-dependencies]
dev = ["ruff"] # Python linter
dev = ["ruff", "pre-commit", "pytest"]

[tool.pytest.ini_options]
# Make `import aetherscan` work without a PYTHONPATH=src prefix
Expand Down
4 changes: 3 additions & 1 deletion requirements-container.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ huggingface_hub>=1.21,<1.22
# setuptools 81 removed pkg_resources as a vendored submodule -- revisit
# once blimpy migrates off pkg_resources (upstream issue).
setuptools>=70,<81
setigen
# Range covers the proven 2.6/2.7 line (2.7.0 in current use) and blocks a future
# major-version break; keep in lockstep with environment.yml and pyproject.toml.
setigen>=2.6,<3
Loading
Loading