diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml deleted file mode 100644 index 03a7644..0000000 --- a/.github/workflows/publish-pypi.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: publish-pypi -run-name: "publish-pypi | ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} | ${{ github.sha }}" - -# Publishes the actop package to PyPI using PyPI Trusted Publishing (OIDC) — -# no API token is stored in the repo. -# -# MANUAL ONLY — this workflow does NOT run on tag push. Publishing is a -# deliberate step taken AFTER the tag and its CI have landed, because the -# automatic form was self-defeating: a tag push started the publish at the same -# moment the tag appeared, so the run raced the release it was meant to ship and -# never published against a settled tag. Making it a dispatch that takes the tag -# as an input removes the ordering problem entirely — the tag already exists and -# is named explicitly. -# -# Publish with (after `scripts/tag_release.sh` and green CI): -# gh workflow run publish-pypi.yml --ref vX.Y.Z -f tag_name=vX.Y.Z -# -# `--ref` must be the TAG, not a branch: the `pypi` environment's deployment -# policy only admits refs matching `v*`, so a dispatch from main is rejected -# before the job starts. Pointing it at the tag satisfies the policy and keeps -# the "only a tagged commit ever reaches PyPI" guarantee that the old automatic -# trigger provided. -# -# Local fallback, from a clean checkout of the tag (needs a PyPI API token — -# OIDC is only available to the workflow above): -# python -m build && python -m twine upload --skip-existing dist/* -# -# One-time PyPI-side setup (do this once, signed in as the project owner): -# 1. https://pypi.org/manage/account/publishing/ -> "Add a new pending publisher" -# 2. Project name: actop -# Owner: binlecode -# Repository name: actop -# Workflow name: publish-pypi.yml -# Environment name: pypi -# After the first successful publish the pending publisher becomes a normal -# trusted publisher for the project. - -on: - workflow_dispatch: - inputs: - tag_name: - description: "Release tag to publish (for example: v1.0.0)" - required: true - type: string - -permissions: - contents: read - -jobs: - build-and-publish: - runs-on: ubuntu-latest - environment: pypi - permissions: - id-token: write # required for OIDC trusted publishing - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - - name: Validate tag matches pyproject.toml version - env: - TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} - run: | - set -euo pipefail - VERSION="${TAG_NAME#v}" - PROJECT_VERSION="$( - awk ' - /^\[project\]/ { in_project=1; next } - /^\[/ && in_project { in_project=0 } - in_project && $1 == "version" { gsub(/"/, "", $3); print $3; exit } - ' pyproject.toml - )" - if [ "$PROJECT_VERSION" != "$VERSION" ]; then - echo "Tag version ($VERSION) does not match pyproject.toml version ($PROJECT_VERSION)" >&2 - exit 1 - fi - - - name: Build sdist and wheel - run: | - set -euo pipefail - python -m pip install --upgrade build - python -m build - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 - with: - skip-existing: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 475376d..ef51413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,23 @@ This project follows a Keep a Changelog-style format and uses version tags for r ### Changed +- **PyPI publishing moved out of CI/CD into the local tag cut.** + `.github/workflows/publish-pypi.yml` is deleted and OIDC Trusted Publishing is + no longer used; `scripts/tag_release.sh` now builds the sdist + wheel and + uploads them with `twine` immediately after pushing the tag and creating the + GitHub Release, so cutting and publishing a release are one act on the + maintainer's machine. Credentials come from `TWINE_USERNAME` / `TWINE_PASSWORD` + in the environment. The script pre-flights the credentials, the `.venv`, and + the `build`/`twine` modules **before** it pushes anything, because a + credential failure discovered after the tag is pushed would leave a tagged, + released, unpublished version that cannot be re-cut. It also wipes `dist/` + before building (the upload glob would otherwise ship stale artifacts), + verifies the built filenames match the tag, and runs `twine check` before + uploading. New `release` optional-dependency group (`build`, `twine`), kept out + of `dev` so Linux CI stays lean. Maintainer-facing only — no change to the + published package. + + - **`record-tui-gif` skill: prose re-synced with the bundled tape.** `SKILL.md` still described a pre-v1.9 stack-based recipe and contradicted `actop-demo.tape`'s own comments: it recommended `Width 1400` (which the tape diff --git a/CLAUDE.md b/CLAUDE.md index c9e96a0..6f1ecf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ This file is the single source of truth for repository guidelines, used by Claud ## Release Process -`main` is **PR-only** (branch protection + `.githooks/pre-commit` redaction check and `.githooks/pre-push` guard; run `git config core.hooksPath .githooks` once). The Homebrew formula lives in the separate tap repo `binlecode/homebrew-actop` (not this repo); CI syncs it on tag. **PyPI publishing is NOT part of the tag flow** — it is a manual step taken after the tag and its CI have landed (`gh workflow run publish-pypi.yml --ref vX.Y.Z -f tag_name=vX.Y.Z`). The old tag-triggered publish raced the tag that started it and shipped against an unsettled ref; a dispatch naming the tag explicitly has no such ordering problem. CI/CD mechanics are owned by the workflow files themselves (`.github/workflows/main-ci.yml`, `release-formula.yml`, `publish-pypi.yml`, `guard-release.yml`) — each carries its one-time setup inline. `guard-release.yml` is the post-push integrity gate: every tag push triggers a scan that verifies every `v*` tag has a corresponding GitHub Release object, catching the "silent link breakage" that caused the v0.8.7–v1.6.6 release-object gap. +`main` is **PR-only** (branch protection + `.githooks/pre-commit` redaction check and `.githooks/pre-push` guard; run `git config core.hooksPath .githooks` once). The Homebrew formula lives in the separate tap repo `binlecode/homebrew-actop` (not this repo); CI syncs it on tag. **PyPI publishing is LOCAL-ONLY and part of the tag flow** — `scripts/tag_release.sh` builds the sdist + wheel and uploads them with `twine` right after it pushes the tag and creates the GitHub Release, so cutting a release and publishing it are one act. **No workflow publishes to PyPI**: there is no `publish-pypi.yml`, and OIDC Trusted Publishing is not used. Credentials come from the environment (`TWINE_USERNAME` / `TWINE_PASSWORD`, exported by `~/.zshrc`); the script verifies both are present *before* it pushes anything, because a credential failure found after the tag is pushed leaves a half-cut release. CI/CD mechanics are owned by the workflow files themselves (`.github/workflows/main-ci.yml`, `release-formula.yml`, `guard-release.yml`) — each carries its one-time setup inline. `guard-release.yml` is the post-push integrity gate: every tag push triggers a scan that verifies every `v*` tag has a corresponding GitHub Release object, catching the "silent link breakage" that caused the v0.8.7–v1.6.6 release-object gap. **RULE — cutting a release REQUIRES a GitHub Release, not just a tag.** A release is cut with `scripts/tag_release.sh`, which pushes the git tag **and** creates the GitHub Release for it via `gh release create` (release notes pulled from the matching `CHANGELOG.md` section). A bare `git tag` + push is **not** a release: it never surfaces on the `/releases` page or as `Latest`, and leaves the Releases history silently behind the code. Never cut a release by hand-tagging alone — if the `gh release create` step fails, finish it manually (see playbooks below) before calling the release cut done. @@ -95,18 +95,16 @@ gh pr create --base main --fill gh pr merge --merge --delete-branch git checkout main && git pull --ff-only -# 4. Cut the release — tag + GitHub Release (RULE: tag alone is not a release) +# 4. Cut the release — tag + GitHub Release + PyPI publish, all from here. +# Needs TWINE_USERNAME/TWINE_PASSWORD in the env and .[release] installed; +# the script pre-flights both before it pushes anything. +# NOTE: this uploads to PyPI. The version string is burned permanently. scripts/tag_release.sh "X.Y.Z" # 5. Monitor CI — wait for release-formula and guard-release to pass gh run list -R binlecode/actop --limit 10 -# 6. Publish to PyPI — MANUAL, only after the tag exists and CI is green. -# --ref must be the tag: the `pypi` environment admits only `v*` refs. -gh workflow run publish-pypi.yml --ref "vX.Y.Z" -f tag_name="vX.Y.Z" -gh run watch -R binlecode/actop "$(gh run list -R binlecode/actop --workflow publish-pypi.yml --limit 1 --json databaseId --jq '.[0].databaseId')" - -# 7. Verify +# 6. Verify pipx install actop && actop --version # PyPI brew update && brew upgrade binlecode/actop/actop # Homebrew (tap repo synced) ``` @@ -125,21 +123,27 @@ brew update && brew upgrade binlecode/actop/actop # Homebrew (tap repo synced Never leave a release cut as a bare tag — verify it appears on `/releases` before moving on. - **`release-formula` fails with tag/version mismatch:** tag `vX.Y.Z` doesn't match `pyproject.toml` in the tag commit. Fix in a new PR, merge, then create a **new** tag (never reuse the old tag). - **`release-formula` fails at tap checkout/push:** missing or under-scoped `HOMEBREW_TAP_TOKEN`. Confirm the secret exists on `binlecode/actop` and the PAT has **Contents: Read/write** on `binlecode/homebrew-actop`. Re-run the failed job. -- **`publish-pypi` fails with OIDC / trusted-publisher error:** verify the trusted publisher on pypi.org matches repo `binlecode/actop`, workflow `publish-pypi.yml`, environment `pypi`; confirm the dispatch used `--ref vX.Y.Z` (the `pypi` environment's deploy policy is tag-only, so a dispatch from `main` is rejected before the job starts). `skip-existing` makes re-runs safe. **Break-glass:** publish that version manually with the token-driven Flow B (`twine upload --skip-existing`), then restore OIDC for subsequent releases. +- **`tag_release.sh` aborts in pre-flight (missing `build`/`twine`, or empty `TWINE_*`):** nothing was pushed — that is the point of checking first. Install the tooling (`.venv/bin/python -m pip install -e ".[release]"`) or fix the credential export (`env | grep -c TWINE_PASSWORD`, expect `1`; a renamed token file silently yields an empty value because `~/.zshrc` uses `2>/dev/null`), then re-run. +- **PyPI upload fails *after* the tag and GitHub Release exist:** do **not** re-cut the tag — it and the Release are already correct, and the version cannot be reused on PyPI anyway. Fix the cause and re-run only the upload from the tag's checkout: `.venv/bin/python -m twine upload --skip-existing dist/*`. `--skip-existing` makes repeats safe. +- **Build produced the wrong version:** the script checks for `dist/actop-X.Y.Z.tar.gz` and the matching wheel and aborts before uploading if either is missing, so a stale `dist/` cannot be shipped (it wipes `dist/` first for the same reason). Re-run the build from a clean checkout of the tag. - **Emergency rerun without resource refresh:** `workflow_dispatch` on `release-formula` with `refresh_resources=false` as a temporary workaround; follow up with a normal run (`refresh_resources=true`). -### PyPI publishing — two flows +### PyPI publishing — local only -OIDC Trusted Publishing is the default (no stored secret; the `pypi` environment on `binlecode/actop` is tag-only, and the workflow file documents the one-time publisher registration). It is **dispatched manually against the tag**, never fired by the tag push. The token-driven flow is the fallback / bootstrap and the only place a long-lived PyPI credential appears: +There is **one** flow: local, token-driven, run by `scripts/tag_release.sh` as part of cutting the tag. No workflow publishes to PyPI — `publish-pypi.yml` was removed and OIDC Trusted Publishing is not used, so the long-lived project-scoped token is the *only* publishing credential. The steps the script runs, should you ever need them by hand from a clean checkout of the tag: ```bash -python -m build # produces dist/*.tar.gz and dist/*.whl -python -m twine upload --skip-existing dist/* # credentials already in the env +rm -rf dist # stale artifacts would be uploaded by the glob +.venv/bin/python -m build # produces dist/*.tar.gz and dist/*.whl +.venv/bin/python -m twine check dist/* # catches what PyPI would reject +.venv/bin/python -m twine upload --skip-existing dist/* # credentials already in the env ``` -**The break-glass credential comes from the system environment — check there first, do not hunt for the token file.** `~/.zshrc` exports `TWINE_USERNAME=__token__` and `TWINE_PASSWORD=$(cat $HOME/env-secrets/api_keys/pypi-org-api-token-binlecode 2>/dev/null)` in every login shell, so `twine upload` authenticates with no flags, no prompt, and nothing pasted on a command line. Confirm it is present with `env | grep -c TWINE_PASSWORD` (expect `1`) — never echo the value. The file under `~/env-secrets/` is only the backing store for that export; the env var is the interface. +Requires the `release` extra (`pip install -e ".[release]"`), which is deliberately outside `dev` so Linux CI stays lean. + +**The credential comes from the system environment — check there first, do not hunt for the token file.** `~/.zshrc` exports `TWINE_USERNAME=__token__` and `TWINE_PASSWORD=$(cat $HOME/env-secrets/api_keys/pypi-org-api-token-binlecode 2>/dev/null)` in every login shell, so `twine upload` authenticates with no flags, no prompt, and nothing pasted on a command line. Confirm it is present with `env | grep -c TWINE_PASSWORD` (expect `1`) — never echo the value. The file under `~/env-secrets/` is only the backing store for that export; the env var is the interface. -**Hygiene — keep the token; do NOT delete it after OIDC works.** It stays (mode `600`) as the standing break-glass credential and is **refreshed when it expires**, not revoked. Flow B *is* the documented recovery path when `publish-pypi` fails on an OIDC / trusted-publisher error (see the failure playbook above), so deleting the token removes the fallback exactly when a broken release needs it. Worse, the export is written with `2>/dev/null`: delete the file and `TWINE_PASSWORD` silently becomes the empty string, turning a missing credential into a confusing PyPI auth failure instead of an obvious missing-file error. An earlier version of this section said to delete the token once OIDC was confirmed; that was wrong and is retired. +**Hygiene — keep the token; never delete it.** It stays (mode `600`) and is **refreshed when it expires**, not revoked. It is now the *only* way to publish at all: with `publish-pypi.yml` gone there is no OIDC fallback, so deleting the token does not remove a spare path — it removes the sole path, and releases stop shipping. Worse, the export is written with `2>/dev/null`: delete or rename the file and `TWINE_PASSWORD` silently becomes the empty string, turning a missing credential into a confusing PyPI auth failure instead of an obvious missing-file error. (`tag_release.sh` now pre-flights for exactly this before pushing anything.) An earlier version of this section said to delete the token once OIDC was confirmed; that was wrong and is retired. Rules for the retained token: never commit it or paste it into a PR/issue (`.githooks/pre-commit` redaction is a backstop, not the control); never echo it — rely on the env export; keep it **project-scoped to `actop`**, never account-wide, so a leak cannot reach other projects. On refresh, **write the new token into the existing filename** — `~/.zshrc` names `pypi-org-api-token-binlecode` literally, not by glob, so renaming the file (e.g. to add an `-exp--` suffix like the other dated keys) breaks the export and, because of the `2>/dev/null`, does so silently. To adopt a dated name, edit the `.zshrc` path in the same commit as the rename. Revoke on pypi.org only if the token leaks or is replaced. @@ -154,7 +158,8 @@ gh run list -R binlecode/actop --limit 12 gh run view -R binlecode/actop --log-failed git ls-remote --tags origin "v*" gh secret list -R binlecode/actop # confirm HOMEBREW_TAP_TOKEN -gh api /repos/binlecode/actop/environments/pypi/deployment-branch-policies +env | grep -c TWINE_PASSWORD # expect 1 (never echo the value) +.venv/bin/python -c "import build, twine" # release extra installed? ``` ## SDLC & Architectural Documentation diff --git a/pyproject.toml b/pyproject.toml index bf85cd3..34363b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,11 @@ dependencies = [ # the rule set is codified in [tool.ruff.lint] below, so CI no longer depends # on ruff's default select (whose 0.16.0 expansion had broken the gate). dev = ["ruff", "pytest"] +# Release tooling: PyPI publishing is local-only (scripts/tag_release.sh runs +# `python -m build` + `twine upload` as part of the tag cut), so the maintainer +# cutting a release needs these. Kept out of `dev` so CI stays lean — no +# workflow publishes to PyPI. +release = ["build", "twine"] pandas = ["pandas>=1.3"] # ANE load generator (scripts/ane_load.py) only; macOS-only + heavy, kept out # of `dev` so Linux CI (`pip install -e ".[dev]"`) stays lean and green. diff --git a/scripts/tag_release.sh b/scripts/tag_release.sh index cb4f5d8..738422c 100755 --- a/scripts/tag_release.sh +++ b/scripts/tag_release.sh @@ -26,15 +26,31 @@ Behavior: - Creates and pushes tag vX.Y.Z - Creates the GitHub Release for vX.Y.Z (gh release create) with the notes from the matching CHANGELOG.md section + - Builds the sdist + wheel and publishes them to PyPI from THIS machine RULE: a release cut REQUIRES the GitHub Release, not just the git tag. This script always creates both; a bare `git tag` + push is NOT a release (it never surfaces on /releases or as Latest). If the gh step fails, fix auth and finish the release manually before moving on. +PyPI publishing is LOCAL-ONLY and part of this script — no workflow publishes to +PyPI (there is no publish-pypi.yml; OIDC Trusted Publishing is not used). The +upload is hooked to the tagging event here so a cut release and a published +release are the same act, and it runs on the maintainer's machine with the +long-lived project-scoped token. + +Credentials come from the environment: TWINE_USERNAME=__token__ and +TWINE_PASSWORD are exported by ~/.zshrc. This script verifies both are present +BEFORE it pushes anything, because a credential failure discovered after the tag +is pushed leaves a half-cut release. It never prints their values. + This tag push triggers release-formula.yml (syncs the formula in the tap repo -binlecode/homebrew-actop) and publish-pypi.yml (publishes to PyPI via OIDC). -Formula sync is CI-driven and lives in the tap repo — there is no formula in this repo. +binlecode/homebrew-actop) and guard-release.yml (verifies every v* tag has a +GitHub Release). Formula sync is CI-driven and lives in the tap repo — there is +no formula in this repo. + +NOTE: PyPI version strings are permanent. They can be yanked but never reused, +so a completed run of this script burns vX.Y.Z on PyPI for good. EOF } @@ -112,6 +128,37 @@ if [[ "$CURRENT_BRANCH" != "main" ]]; then exit 1 fi +# --------------------------------------------------------------------------- +# Publish pre-flight. Everything the local PyPI upload needs is verified HERE, +# before any push, tag, or release. The upload is the last step but its +# prerequisites are checked first: discovering a missing token after the tag is +# pushed would leave a tagged, released, unpublished version -- and the tag +# cannot simply be re-cut, since the GitHub Release already exists. +# --------------------------------------------------------------------------- +PY=".venv/bin/python" +if [[ ! -x "$PY" ]]; then + echo "Error: ${PY} not found. This repo publishes from its own virtualenv." >&2 + echo " python3 -m venv .venv && .venv/bin/python -m pip install -e \".[dev,release]\"" >&2 + exit 1 +fi + +for mod in build twine; do + if ! "$PY" -c "import ${mod}" >/dev/null 2>&1; then + echo "Error: python module '${mod}' is not installed in .venv (needed to publish)." >&2 + echo " ${PY} -m pip install -e \".[release]\"" >&2 + exit 1 + fi +done + +# Presence only -- never echo, log, or interpolate these values. +if [[ -z "${TWINE_USERNAME:-}" || -z "${TWINE_PASSWORD:-}" ]]; then + echo "Error: TWINE_USERNAME / TWINE_PASSWORD are not both set in the environment." >&2 + echo "They are exported by ~/.zshrc from ~/env-secrets/api_keys/; that export uses" >&2 + echo "2>/dev/null, so a missing or renamed token file yields an EMPTY value here." >&2 + echo "Check with: env | grep -c TWINE_PASSWORD (expect 1)" >&2 + exit 1 +fi + echo "Syncing local main with origin/main (fast-forward only)..." git pull --ff-only origin main @@ -151,5 +198,49 @@ else gh release create "${TAG}" --title "actop ${VERSION}" --notes "${RELEASE_NOTES}" fi -echo "Done. Tag ${TAG} pushed and GitHub Release created." +echo "Tag ${TAG} pushed and GitHub Release created." + +# --------------------------------------------------------------------------- +# Local PyPI publish. Hooked to the tagging event: cutting a release and +# publishing it are one act, run from this machine. No workflow does this. +# --------------------------------------------------------------------------- +echo "Building sdist + wheel for ${VERSION}..." +# Wipe dist/ first. `twine upload dist/*` is a glob, so stale artifacts from an +# earlier version sitting in dist/ would be uploaded alongside this release. +rm -rf dist +"$PY" -m build + +# The build must have produced exactly this version. Guards against publishing a +# stale artifact if the build silently picked up a different version. +missing="" +for f in "dist/actop-${VERSION}.tar.gz" "dist/actop-${VERSION}-py3-none-any.whl"; do + [[ -f "$f" ]] || missing="${missing} ${f}" +done +if [[ -n "$missing" ]]; then + echo "Error: expected build artifacts not found:${missing}" >&2 + echo "dist/ contains:" >&2 + ls -1 dist >&2 || true + echo "The tag and GitHub Release for ${TAG} already exist; fix the build and" >&2 + echo "publish manually with: ${PY} -m twine upload --skip-existing dist/*" >&2 + exit 1 +fi + +# Metadata / README rendering check -- catches what PyPI would reject on upload. +echo "Checking artifact metadata..." +"$PY" -m twine check dist/* + +# --skip-existing makes a re-run after a partial failure safe: an already +# uploaded file is skipped rather than failing the whole upload. +echo "Uploading to PyPI (credentials from the environment)..." +if ! "$PY" -m twine upload --skip-existing dist/*; then + echo "Error: PyPI upload failed." >&2 + echo "The tag and GitHub Release for ${TAG} already exist, so DO NOT re-cut the tag." >&2 + echo "Fix the cause and re-run just the upload:" >&2 + echo " ${PY} -m twine upload --skip-existing dist/*" >&2 + exit 1 +fi + +echo +echo "Done. ${TAG} tagged, released, and published to PyPI." echo "Release formula workflow should start from this tag push." +echo "Verify: pipx install actop && actop --version"