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
103 changes: 103 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
70 changes: 70 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions docs/trusted-publishing.md
Original file line number Diff line number Diff line change
@@ -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.