diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4b7e6fe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,103 @@ +name: Publish Python package + +on: + workflow_dispatch: + inputs: + target: + description: Package index to publish to + required: true + type: choice + options: + - testpypi + - pypi + +permissions: + contents: read + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build and verify release + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + cache: pip + - name: Validate version tag + env: + RELEASE_REF_TYPE: ${{ github.ref_type }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ "${RELEASE_REF_TYPE}" != "tag" ]]; then + echo "Run this workflow from an existing version tag, not a branch." >&2 + exit 1 + fi + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Tag must match vX.Y.Z; received ${RELEASE_TAG}." >&2 + exit 1 + fi + + package_version="$(python -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"])')" + if [[ "v${package_version}" != "${RELEASE_TAG}" ]]; then + echo "Tag ${RELEASE_TAG} does not match package version ${package_version}." >&2 + exit 1 + fi + + git fetch --no-tags origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor HEAD refs/remotes/origin/main; then + echo "Release tag must point to a commit contained in main." >&2 + exit 1 + fi + - name: Install release tooling + run: python -m pip install --upgrade pip "build>=1.2.2" "twine>=6.1" + - name: Build and inspect distributions + run: | + python -m build + python -m twine check dist/* + sha256sum dist/* + - name: Smoke-test the wheel + run: | + python -m venv .release-venv + .release-venv/bin/python -m pip install dist/*.whl + .release-venv/bin/creator-toolkit --help + - name: Upload release distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + publish: + name: Publish to ${{ inputs.target }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: ${{ inputs.target }} + url: ${{ inputs.target == 'pypi' && 'https://pypi.org/p/creator-toolkit-cli' || 'https://test.pypi.org/p/creator-toolkit-cli' }} + permissions: + id-token: write + steps: + - name: Download release distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-package-distributions + path: dist/ + - name: Publish with a short-lived OIDC credential + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: ${{ inputs.target == 'pypi' && 'https://upload.pypi.org/legacy/' || 'https://test.pypi.org/legacy/' }} + packages-dir: dist/ + print-hash: true diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f505f..2c1de48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project are documented in this file. The format is b - Machine-readable JSON output for every scriptable subcommand. - Installed CLI smoke coverage on Windows, macOS, and Linux. - Reproducible animated README demo with real title, tag, and rename-preview workflows. +- Manually approved Trusted Publishing workflow and release checklist for PyPI and TestPyPI. - Python 3.11–3.13 test matrix, linting, coverage, and distribution checks. ### Changed diff --git a/README.md b/README.md index ac30425..9427e5a 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,9 @@ pytest See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contribution workflow. Bug reports and focused pull requests are welcome. +Maintainers should follow the [release checklist](docs/releasing.md) and complete the +[one-time trusted publishing setup](docs/trusted-publishing.md) before publishing a version. + ## License [MIT](LICENSE) © 2026 HoungDev contributors. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..f8a908d --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,70 @@ +# Release checklist + +Use this checklist for `v0.1.0` and later releases. Publishing is always a separate, manually +approved action; merging a pull request never uploads a package. + +## Prepare the release + +- [ ] Confirm the [trusted publishing setup](trusted-publishing.md) on PyPI, TestPyPI, and GitHub. +- [ ] Update `project.version` in `pyproject.toml`. +- [ ] Move the relevant changelog entries from **Unreleased** to a dated version section. +- [ ] Open and merge a release-preparation pull request with all required checks green. +- [ ] Build from a clean checkout with `python -m build` and run `python -m twine check dist/*`. +- [ ] Install the wheel in a fresh environment and run `creator-toolkit --help` plus one command. + +## Create the version tag + +From an up-to-date, clean `main` branch: + +```bash +git tag -a v0.1.0 -m "Creator Toolkit CLI v0.1.0" +git push origin v0.1.0 +``` + +Do not move or reuse a published tag. The release workflow rejects branches, malformed tags, +version mismatches, and tags whose commit is not contained in `main`. + +## TestPyPI dry run + +Run the workflow from the exact version tag, then approve the `testpypi` environment: + +```bash +gh workflow run release.yml --ref v0.1.0 -f target=testpypi +gh run list --workflow release.yml --limit 1 +``` + +After the run succeeds, install from TestPyPI in a fresh environment: + +```bash +python -m venv .testpypi-smoke +.testpypi-smoke/bin/python -m pip install --index-url https://test.pypi.org/simple/ creator-toolkit-cli==0.1.0 +.testpypi-smoke/bin/creator-toolkit --help +.testpypi-smoke/bin/creator-toolkit title "creator workflow" +``` + +On Windows, use `.testpypi-smoke\Scripts\python.exe` and +`.testpypi-smoke\Scripts\creator-toolkit.exe`. Do not approve production publishing until this +smoke test passes. + +## Production publication + +Run the same pinned workflow and approve the `pypi` environment only after reviewing the tag, +build checks, TestPyPI result, and environment deployment request: + +```bash +gh workflow run release.yml --ref v0.1.0 -f target=pypi +gh run list --workflow release.yml --limit 1 +``` + +Then verify the PyPI metadata, SHA-256 hashes, publish attestations, and a clean `pip install`. +Create the GitHub release from the existing tag only after those checks succeed. + +## If a release is broken + +PyPI files and version numbers are immutable. Never overwrite a file, move the tag, or reuse the +version. Instead: + +1. Yank the affected release on PyPI so resolvers avoid it unless explicitly requested. +2. Mark the GitHub release and changelog with a concise impact notice and workaround. +3. Fix the problem on a new branch and publish a higher patch version through the same checklist. +4. For a security issue, coordinate through a private GitHub security advisory before disclosure. diff --git a/docs/trusted-publishing.md b/docs/trusted-publishing.md new file mode 100644 index 0000000..b568536 --- /dev/null +++ b/docs/trusted-publishing.md @@ -0,0 +1,60 @@ +# Trusted publishing setup + +Creator Toolkit publishes without stored PyPI API tokens. The release workflow exchanges a +GitHub OpenID Connect identity for a short-lived PyPI credential, and the publish job is isolated +from the build job. + +This is a one-time maintainer setup. It does not publish a package. + +## 1. Protect the GitHub environments + +Create two repository environments under **Settings > Environments**: + +| Environment | Required reviewer | Allowed deployment tags | +| --- | --- | --- | +| `testpypi` | A trusted maintainer | `v*.*.*` | +| `pypi` | A trusted maintainer | `v*.*.*` | + +Require approval for both environments. A solo maintainer may leave "prevent self-review" off; +projects with another trusted maintainer should enable it. Do not add PyPI passwords, API tokens, +or other publishing secrets to either environment. + +## 2. Register the pending publishers + +The `creator-toolkit-cli` project does not yet exist on either package index. Sign in to each +index, open the account-level **Publishing** page, and add a pending GitHub publisher with these +exact values: + +| Setting | PyPI | TestPyPI | +| --- | --- | --- | +| Project name | `creator-toolkit-cli` | `creator-toolkit-cli` | +| Owner | `HoungDev` | `HoungDev` | +| Repository | `creator-toolkit-cli` | `creator-toolkit-cli` | +| Workflow filename | `release.yml` | `release.yml` | +| Environment | `pypi` | `testpypi` | + +- [PyPI pending publisher settings](https://pypi.org/manage/account/publishing/) +- [TestPyPI pending publisher settings](https://test.pypi.org/manage/account/publishing/) +- [Official pending publisher documentation](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/) + +A pending publisher does not reserve the package name. Recheck availability immediately before +the first release, and make sure the project name exactly matches `project.name` in +`pyproject.toml`. + +## 3. Verify the trust boundary + +Before the first run, confirm all of the following: + +- `.github/workflows/release.yml` is the only workflow registered with either package index. +- The publisher environment name exactly matches `pypi` or `testpypi`; whitespace matters. +- Only the publish job has `id-token: write`. +- The publish job only downloads the previously built artifact and invokes the pinned PyPA action. +- Environment deployment rules restrict releases to `v*.*.*` tags. + +As an additional defense, add a repository tag ruleset for `v*` that limits tag creation to +maintainers and blocks tag updates or deletion. Verify its bypass list before enabling it so the +maintainer who performs releases is not accidentally locked out. + +The action generates and uploads PyPI publish attestations by default. Review the trusted +publisher configuration whenever the repository, workflow file, environments, or maintainer +access changes.