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
168 changes: 168 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
name: Package

on:
pull_request:
paths:
- "lib/python/base_cli/**"
- "pyproject.toml"
- "VERSION"
- "README.md"
- "LICENSE"
- "scripts/validate_package_artifact.py"
- "docs/releasing.md"
- ".github/workflows/package.yml"
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
inputs:
publish_target:
description: "Protected publication target"
required: true
type: choice
options:
- testpypi
- pypi
default: testpypi

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
PACKAGE_NAME: base-cli

jobs:
build:
name: Build and validate distributions
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
version: ${{ steps.metadata.outputs.version }}
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.13"

- name: Read package version
id: metadata
run: echo "version=$(tr -d '\\r\\n' < VERSION)" >> "$GITHUB_OUTPUT"

- name: Validate release ref
env:
PUBLISH_TARGET: ${{ inputs.publish_target || '' }}
run: |
if [[ "$GITHUB_REF_TYPE" == "tag" && "$GITHUB_REF_NAME" != "v${{ steps.metadata.outputs.version }}" ]]; then
echo "Release tag must be v${{ steps.metadata.outputs.version }}; got $GITHUB_REF_NAME" >&2
exit 1
fi
if [[ "$PUBLISH_TARGET" == "pypi" && "$GITHUB_REF_TYPE" != "tag" ]]; then
echo "PyPI publication requires dispatching this workflow from the matching version tag." >&2
exit 1
fi

- name: Validate repository baseline
run: ./tests/validate.sh

- name: Install build and validation tools
run: python -m pip install --upgrade build twine

- name: Build sdist and wheel
run: python -m build --sdist --wheel --outdir dist

- name: Validate artifact contents and metadata
run: python scripts/validate_package_artifact.py dist

- name: Validate package indexes
run: python -m twine check dist/*

- name: Upload reviewed distributions
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: base-cli-dist-${{ github.run_id }}
path: dist/*
if-no-files-found: error
retention-days: 14

smoke:
name: Install smoke test (Python ${{ matrix.python-version }})
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: ${{ matrix.python-version }}

- name: Download reviewed distributions
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: base-cli-dist-${{ github.run_id }}
path: dist

- name: Install wheel and runtime dependencies
run: python -m pip install dist/base_cli-*.whl

- name: Verify installed package
env:
EXPECTED_VERSION: ${{ needs.build.outputs.version }}
run: |
python - <<'PY'
import base_cli
import importlib.metadata

expected = __import__("os").environ["EXPECTED_VERSION"]
assert base_cli.__version__ == expected, (base_cli.__version__, expected)
assert importlib.metadata.version("base-cli") == expected
assert hasattr(base_cli, "App")
print(f"base-cli {base_cli.__version__} installed successfully")
PY

publish:
name: Publish reviewed distribution
needs: [build, smoke]
if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: ${{ github.event_name == 'push' && 'pypi' || inputs.publish_target }}
url: ${{ github.event_name == 'push' && 'https://pypi.org/p/base-cli' || 'https://test.pypi.org/p/base-cli' }}
permissions:
contents: read
id-token: write
steps:
- name: Download reviewed distributions
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: base-cli-dist-${{ github.run_id }}
path: dist

- name: Publish to TestPyPI
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_target == 'testpypi' }}
uses: pypa/gh-action-pypi-publish@4bb033805d9e19112d8c697528791ff53f6c2f74
with:
packages-dir: dist
repository-url: https://test.pypi.org/legacy/

- name: Publish to PyPI
if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.publish_target == 'pypi') }}
uses: pypa/gh-action-pypi-publish@4bb033805d9e19112d8c697528791ff53f6c2f74
with:
packages-dir: dist
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ and versions are tracked in the repo-root `VERSION` file.
### Added

- Initialized the repository with the Base-managed repo baseline.
- Added the guarded package build, artifact validation, and protected
TestPyPI/PyPI publication workflow.
- Exposed `base_cli.__version__` from the repository and installed package
version contract.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Install it with:
python -m pip install base-cli
```

Release builds, TestPyPI rehearsals, and protected PyPI publication are
documented in [`docs/releasing.md`](docs/releasing.md). The package exposes
`base_cli.__version__`, which matches the distribution version.

The package is distributed under the Apache License 2.0. Base itself remains
licensed separately under AGPL-3.0-or-later.

Expand Down
69 changes: 69 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Releasing `base-cli`

The `base-cli` distribution is built and published from the standalone
`basefoundry/base-cli` repository. The package name on PyPI is `base-cli`; the
Python import name is `base_cli`.

## Version and tag contract

`VERSION` is the release version source of truth. The build backend reads it for
the wheel and sdist metadata, and `base_cli.__version__` reports the same value
from a source checkout or from installed distribution metadata.

Production releases use a matching annotated-style tag such as `v0.1.0`.
The Package workflow rejects a tag that does not exactly match `v${VERSION}`.

## Validation workflow

Pull requests and pushes to `main` build one sdist and one wheel, run `twine
check`, inspect metadata and package data, and install the reviewed wheel across
Python 3.10 through 3.14. The publish job downloads that same artifact; it does
not rebuild during publication.

## TestPyPI rehearsal

1. Dispatch **Package** from the branch or tag to be rehearsed and choose
`testpypi`.
2. Approve the protected `testpypi` environment when prompted.
3. Verify the published artifact from a clean environment:

```bash
python -m venv /tmp/base-cli-smoke
/tmp/base-cli-smoke/bin/python -m pip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
base-cli
/tmp/base-cli-smoke/bin/python -c \
'import base_cli; print(base_cli.__version__)'
```

The `testpypi` GitHub environment must be configured with PyPI trusted
publishing for this repository and workflow before the dispatch can upload.

## Production release

1. Update `VERSION` and the changelog in a reviewed pull request.
2. Merge to `main` and create the matching `v${VERSION}` tag.
3. Approve the protected `pypi` environment. The workflow verifies the tag,
builds and tests the artifact, then publishes the exact artifact to PyPI via
trusted publishing.
4. Verify installation from PyPI:

```bash
python -m venv /tmp/base-cli-smoke
/tmp/base-cli-smoke/bin/python -m pip install --upgrade base-cli
/tmp/base-cli-smoke/bin/python -c \
'import base_cli; import importlib.metadata as m; assert base_cli.__version__ == m.version("base-cli"); print(base_cli.__version__)'
```

The `pypi` GitHub environment must require approval and be configured with the
PyPI trusted publisher for `.github/workflows/package.yml`. No long-lived PyPI
token is stored in the repository.

## Recovery

PyPI versions cannot be overwritten. If validation fails, fix the branch and
rerun the workflow before creating a tag. If TestPyPI succeeds but a production
publish fails, inspect the workflow logs and rerun the same approved tag only
after confirming that neither artifact nor metadata needs correction. A version
that was published successfully must be incremented for the next release.
23 changes: 23 additions & 0 deletions lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
from __future__ import annotations

from importlib.metadata import PackageNotFoundError, version as distribution_version
from pathlib import Path


def _resolve_version() -> str:
"""Return the checkout version or the installed distribution version."""

for parent in Path(__file__).resolve().parents:
version_file = parent / "VERSION"
if version_file.is_file():
value = version_file.read_text(encoding="utf-8").splitlines()[0].strip()
if value:
return value

try:
return distribution_version("base-cli")
except PackageNotFoundError:
return "0.0.0"


__version__ = _resolve_version()

from . import command_filters, command_protocol, history, testing
from .app import App, argument, command, delegated_display_command, option, run_app
from .command_filters import command_matches, normalize_command_filter, normalize_command_filters
Expand All @@ -21,6 +43,7 @@

__all__ = [
"App",
"__version__",
"CommandProtocolError",
"Context",
"ExitCode",
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "base-cli"
version = "0.1.0"
dynamic = ["version"]
description = "A small, consistent Python CLI framework for Base and Base-supported projects"
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -47,6 +47,9 @@ package-dir = {"" = "lib/python"}
[tool.setuptools.packages.find]
where = ["lib/python"]

[tool.setuptools.dynamic]
version = {file = "VERSION"}

[tool.setuptools.package-data]
base_cli = ["py.typed"]

Expand Down
Loading
Loading