From 42bdd0e208adef3b8ce3191aeff317dab22c20d8 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:46:55 -0600 Subject: [PATCH 1/2] Use hatch-vcs for release versions Amp-Thread-ID: https://ampcode.com/threads/T-019e86ce-3bea-705f-9b77-1a0f14ebe2d5 Co-authored-by: Amp --- .github/workflows/release.yml | 78 ++++++++++++++++------- .github/workflows/validate.yml | 2 + .gitignore | 3 +- AGENTS.md | 112 +++++---------------------------- dev/release-automation.md | 91 +++++++++++++++++++++++++++ pyproject.toml | 7 ++- uv.lock | 1 - 7 files changed, 170 insertions(+), 124 deletions(-) create mode 100644 dev/release-automation.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a18bac3..ce22abe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,8 @@ on: - "v*" workflow_dispatch: inputs: - tag: - description: "Existing release tag to publish, for example v0.1.0" + version: + description: "Package version to publish, for example 0.1.0 or v0.1.0" required: true type: string @@ -16,7 +16,7 @@ permissions: pull-requests: read concurrency: - group: release-${{ github.event.inputs.tag || github.ref_name }} + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} cancel-in-progress: false defaults: @@ -24,15 +24,41 @@ defaults: shell: bash jobs: + release_ref: + name: Resolve release tag + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + + steps: + - name: Resolve release tag + id: release + env: + RELEASE_INPUT: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} + run: | + release_input="${RELEASE_INPUT}" + if [[ ! "${release_input}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error title=Invalid release version::Use MAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH, got '${release_input}'." + exit 1 + fi + + release_version="${release_input#v}" + release_tag="v${release_version}" + echo "tag=${release_tag}" >> "${GITHUB_OUTPUT}" + echo "version=${release_version}" >> "${GITHUB_OUTPUT}" + validate: name: Validate + needs: release_ref uses: ./.github/workflows/validate.yml with: - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ needs.release_ref.outputs.tag }} build-package: false wheel: name: Build wheel + needs: release_ref runs-on: ubuntu-24.04 env: IMPORT_NAME: src_py_lib @@ -45,7 +71,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ needs.release_ref.outputs.tag }} - name: Set up Python uses: actions/setup-python@v6 @@ -66,7 +92,7 @@ jobs: - name: Validate release inputs id: release env: - RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + RELEASE_TAG: ${{ needs.release_ref.outputs.tag }} run: | release_tag="${RELEASE_TAG}" if [[ ! "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then @@ -80,29 +106,19 @@ jobs: tag_revision="$(git rev-list -n 1 "${release_tag}")" git fetch --no-tags origin main main_revision="$(git rev-parse origin/main)" - if ! git merge-base --is-ancestor "${tag_revision}" "${main_revision}"; then - echo "::error title=Tag is not on main::Tag '${release_tag}' points at ${tag_revision}, which is not reachable from origin/main." - echo "::error::Merge the release PR first, then tag the main commit." - exit 1 - fi - - project_version=$(uv run --frozen python - <<'PY' - import tomllib - - with open("pyproject.toml", "rb") as pyproject_file: - print(tomllib.load(pyproject_file)["project"]["version"]) - PY - ) - if [[ "v${project_version}" != "${release_tag}" ]]; then - echo "::error title=Version mismatch::pyproject.toml version '${project_version}' does not match tag '${release_tag}'." + if [[ "${tag_revision}" != "${main_revision}" ]]; then + echo "::error title=Tag is not origin/main::Tag '${release_tag}' points at ${tag_revision}, but origin/main is ${main_revision}." + echo "::error::Tag the remote head of main, then rerun the release." exit 1 fi echo "tag=${release_tag}" >> "${GITHUB_OUTPUT}" + echo "version=${release_tag#v}" >> "${GITHUB_OUTPUT}" - name: Build distributions id: build run: | + release_version="${{ steps.release.outputs.version }}" dist_dir="build/release/dist" rm -rf build/release mkdir -p "${dist_dir}" @@ -123,6 +139,22 @@ jobs: wheel_name="$(basename "${wheel_path}")" source_distribution_path="${source_distributions[0]}" source_distribution_name="$(basename "${source_distribution_path}")" + case "${wheel_name}" in + src_py_lib-"${release_version}"-*.whl) + ;; + *) + echo "::error title=Wheel version mismatch::Expected wheel version ${release_version}, got '${wheel_name}'." + exit 1 + ;; + esac + case "${source_distribution_name}" in + src_py_lib-"${release_version}".tar.gz) + ;; + *) + echo "::error title=Source distribution version mismatch::Expected source distribution version ${release_version}, got '${source_distribution_name}'." + exit 1 + ;; + esac wheel_checksum_path="${wheel_path}.sha256" source_distribution_checksum_path="${source_distribution_path}.sha256" @@ -214,7 +246,7 @@ jobs: github-release: name: Publish GitHub release assets - needs: [validate, wheel] + needs: [release_ref, validate, wheel] runs-on: ubuntu-24.04 permissions: contents: write @@ -230,7 +262,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + RELEASE_TAG: ${{ needs.release_ref.outputs.tag }} run: | release_tag="${RELEASE_TAG}" notes_path="$(find release-assets -name release-notes.md -print -quit)" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 38c7363..7714adb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -158,6 +158,7 @@ jobs: - name: Check out code uses: actions/checkout@v6 with: + fetch-depth: 0 persist-credentials: false ref: ${{ inputs.ref || github.ref }} @@ -217,6 +218,7 @@ jobs: - name: Check out code uses: actions/checkout@v6 with: + fetch-depth: 0 persist-credentials: false ref: ${{ inputs.ref || github.ref }} diff --git a/.gitignore b/.gitignore index 8d17076..37c9c71 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ __pycache__ *.gql *.py[cod] *.py[oc] +*.swp *.yaml build/ dist/ @@ -17,4 +18,4 @@ wheels/ # Allow !.env.example -!.markdownlint-cli2.yaml +!.markdownlint-cli2.yaml \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d35c732..75838cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,111 +51,29 @@ uv run python -m unittest discover -s tests ## Release process -- The tagged source commit must already contain the package version it - releases. Do not make the release workflow edit `pyproject.toml`. -- The tag must be `vMAJOR.MINOR.PATCH`, and `.github/workflows/release.yml` - verifies that it matches `project.version` before building GitHub release - assets and publishing to PyPI. -- Prepare releases on a branch from current `main`. Set `VERSION`, then run: -- As part of every release bump, find old release-version literals in - `AGENTS.md`, `README.md`, and release snippets, and replace them with the - new version where they are meant to stay current. +- Package versions are derived from Git tags through `hatch-vcs`. +- `pyproject.toml` must use `dynamic = ["version"]`; do not add a hard-coded + `project.version` for releases. +- The release tag must be `vMAJOR.MINOR.PATCH` and point at a commit reachable + from `origin/main`. +- The release workflow builds from the tag and checks that wheel and source + distribution filenames match the tag version before publishing. +- Do not make the release workflow edit `pyproject.toml` or `uv.lock`. +- Prefer the release script described in `dev/release-automation.md` once it + exists. Until then, tag the remote head of `main` directly: ```sh set -euo pipefail -VERSION=0.1.6 -BRANCH="release-v${VERSION}" - -git fetch origin --tags --prune -git switch main -git pull --ff-only -git switch -c "${BRANCH}" - -uv run python - "${VERSION}" <<'PY' -from pathlib import Path -import re -import sys - -version = sys.argv[1] -path = Path("pyproject.toml") -text = path.read_text() -new_text = re.sub( - r'(?m)^version = "[^"]+"$', - f'version = "{version}"', - text, - count=1, -) -if new_text == text: - raise SystemExit("pyproject.toml version was not updated") -path.write_text(new_text) -PY - -uv lock -``` - -- Validate before opening the PR: - -```sh -set -euo pipefail - -uv lock --check -actionlint -npx --yes markdownlint-cli2@0.22.1 -uv run ruff check . -uv run ruff format --check . -uv run pyright -uv run python -m unittest discover -s tests -uv build --wheel --sdist --out-dir /tmp/src-py-lib-release-check --no-create-gitignore -rm -rf /tmp/src-py-lib-release-check -``` - -- Commit, push, open the PR, wait for checks, then merge it. If review is - required, stop after `gh pr checks` and ask for review before merging. - -```sh -set -euo pipefail - -VERSION=0.1.6 -BRANCH="release-v${VERSION}" +VERSION_INPUT= +VERSION="${VERSION_INPUT#v}" GH_REPO="sourcegraph/src-py-lib" -git add pyproject.toml uv.lock -git commit -m "Release v${VERSION}" -git push -u origin "${BRANCH}" - -gh pr create \ - --repo "${GH_REPO}" \ - --base main \ - --head "${BRANCH}" \ - --title "Release v${VERSION}" \ - --body "Bump src-py-lib package metadata to ${VERSION}." - -gh pr checks "${BRANCH}" --repo "${GH_REPO}" --watch --fail-fast -gh pr merge "${BRANCH}" --repo "${GH_REPO}" --squash --delete-branch -``` - -- Tag the merged `main` commit. Do not tag a branch commit. - -```sh -set -euo pipefail - -VERSION=0.1.6 - +[[ "${VERSION_INPUT}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]] git fetch origin --tags --prune -git switch main -git pull --ff-only -git tag "v${VERSION}" +MAIN_COMMIT="$(git rev-parse origin/main)" +git tag -a "v${VERSION}" "${MAIN_COMMIT}" -m "Release v${VERSION}" git push origin "v${VERSION}" -``` - -- Watch the release workflow and confirm the GitHub release and PyPI project. - -```sh -set -euo pipefail - -VERSION=0.1.6 -GH_REPO="sourcegraph/src-py-lib" RUN_ID="$( gh run list \ diff --git a/dev/release-automation.md b/dev/release-automation.md new file mode 100644 index 0000000..59ca1ea --- /dev/null +++ b/dev/release-automation.md @@ -0,0 +1,91 @@ +# Release automation plan + +## Goal + +- Make publishing a release one command, which releases the current remote +head of `main` + +```sh +./dev/release.sh +``` + +- The script takes either `version` or `v` + - Verifies it's ahead of the latest release + - Derives and pushes the `v` release tag + - Verifies the release, including the GHA workflow and PyPI + +## Source of truth + +`origin/main` plus the release tag is the release source of truth. + +The release script should: + +1. fetch `origin/main` and tags +2. resolve the exact `origin/main` commit to release +3. validate `` as `1.2.3` or `v1.2.3` +4. strip a leading `v` if present, then derive `release_tag="v${version}"` +5. tag the resolved `origin/main` commit + +Package builds use `hatch-vcs`, so the release tag supplies the package version. +The release script should not edit files, create release branches, or commit to +`main`. + +## One-script flow + +`./dev/release.sh ` should: + +1. refuse dirty local release-script changes that would affect its own behavior +2. fetch `origin main --tags --prune` +3. reject versions that do not match `^v?[0-9]+\.[0-9]+\.[0-9]+$` +4. fail if an unprefixed tag like `` exists +5. fail if `v` already exists locally, on GitHub, or as a release +6. create annotated tag `v` pointing at the `origin/main` commit +7. push only that tag +8. watch the release workflow for that tag +9. verify the GitHub Release has wheel, source distribution, and checksum assets +10. verify PyPI lists the new version + +The only normal command that creates or pushes release tags should be this +script. + +## Version policy + +Keep release versions out of docs and copyable examples. `pyproject.toml` should +use `dynamic = ["version"]`; do not add a hard-coded `project.version` for a +release. + +Good docs: + +```sh +./dev/release.sh +``` + +Avoid docs like: + +```sh +VERSION=0.1.6 +git tag "v${VERSION}" +``` + +## Workflow dispatch fallback + +If the GitHub release workflow keeps a manual dispatch button, its input should +also be `version`, not `tag`. + +```yaml +workflow_dispatch: + inputs: + version: + description: "Package version, for example 1.2.3 or v1.2.3" + required: true + type: string +``` + +The workflow should derive `v` internally and reject anything that is +not `MAJOR.MINOR.PATCH` or `vMAJOR.MINOR.PATCH`. + +## Dependency policy + +Keep this low-dependency. A stdlib Python script plus existing `uv`, `git`, and +`gh` commands should be enough. Do not add a release framework unless this script +becomes hard to maintain. diff --git a/pyproject.toml b/pyproject.toml index de5e138..5b21d3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [dependency-groups] @@ -10,7 +10,7 @@ dev = [ [project] name = "src-py-lib" -version = "0.1.7" +dynamic = ["version"] description = "Reusable libraries for Sourcegraph projects" readme = "README.md" requires-python = ">=3.11" @@ -44,6 +44,9 @@ Issues = "https://github.com/sourcegraph/src-py-lib/issues" [tool.hatch.build.targets.wheel] packages = ["src/src_py_lib"] +[tool.hatch.version] +source = "vcs" + [tool.pyright] include = ["src/src_py_lib", "tests"] typeCheckingMode = "strict" diff --git a/uv.lock b/uv.lock index 5e5ccf9..f13ce62 100644 --- a/uv.lock +++ b/uv.lock @@ -254,7 +254,6 @@ wheels = [ [[package]] name = "src-py-lib" -version = "0.1.7" source = { editable = "." } dependencies = [ { name = "httpx" }, From 333b9fb53c5211d62e84c8c92cc4d6c443d601b2 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:48:09 -0600 Subject: [PATCH 2/2] Remove release automation plan Amp-Thread-ID: https://ampcode.com/threads/T-019e86ce-3bea-705f-9b77-1a0f14ebe2d5 Co-authored-by: Amp --- AGENTS.md | 3 +- dev/release-automation.md | 91 --------------------------------------- 2 files changed, 1 insertion(+), 93 deletions(-) delete mode 100644 dev/release-automation.md diff --git a/AGENTS.md b/AGENTS.md index 75838cb..b609a79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,8 +59,7 @@ uv run python -m unittest discover -s tests - The release workflow builds from the tag and checks that wheel and source distribution filenames match the tag version before publishing. - Do not make the release workflow edit `pyproject.toml` or `uv.lock`. -- Prefer the release script described in `dev/release-automation.md` once it - exists. Until then, tag the remote head of `main` directly: +- Tag the remote head of `main` directly: ```sh set -euo pipefail diff --git a/dev/release-automation.md b/dev/release-automation.md deleted file mode 100644 index 59ca1ea..0000000 --- a/dev/release-automation.md +++ /dev/null @@ -1,91 +0,0 @@ -# Release automation plan - -## Goal - -- Make publishing a release one command, which releases the current remote -head of `main` - -```sh -./dev/release.sh -``` - -- The script takes either `version` or `v` - - Verifies it's ahead of the latest release - - Derives and pushes the `v` release tag - - Verifies the release, including the GHA workflow and PyPI - -## Source of truth - -`origin/main` plus the release tag is the release source of truth. - -The release script should: - -1. fetch `origin/main` and tags -2. resolve the exact `origin/main` commit to release -3. validate `` as `1.2.3` or `v1.2.3` -4. strip a leading `v` if present, then derive `release_tag="v${version}"` -5. tag the resolved `origin/main` commit - -Package builds use `hatch-vcs`, so the release tag supplies the package version. -The release script should not edit files, create release branches, or commit to -`main`. - -## One-script flow - -`./dev/release.sh ` should: - -1. refuse dirty local release-script changes that would affect its own behavior -2. fetch `origin main --tags --prune` -3. reject versions that do not match `^v?[0-9]+\.[0-9]+\.[0-9]+$` -4. fail if an unprefixed tag like `` exists -5. fail if `v` already exists locally, on GitHub, or as a release -6. create annotated tag `v` pointing at the `origin/main` commit -7. push only that tag -8. watch the release workflow for that tag -9. verify the GitHub Release has wheel, source distribution, and checksum assets -10. verify PyPI lists the new version - -The only normal command that creates or pushes release tags should be this -script. - -## Version policy - -Keep release versions out of docs and copyable examples. `pyproject.toml` should -use `dynamic = ["version"]`; do not add a hard-coded `project.version` for a -release. - -Good docs: - -```sh -./dev/release.sh -``` - -Avoid docs like: - -```sh -VERSION=0.1.6 -git tag "v${VERSION}" -``` - -## Workflow dispatch fallback - -If the GitHub release workflow keeps a manual dispatch button, its input should -also be `version`, not `tag`. - -```yaml -workflow_dispatch: - inputs: - version: - description: "Package version, for example 1.2.3 or v1.2.3" - required: true - type: string -``` - -The workflow should derive `v` internally and reject anything that is -not `MAJOR.MINOR.PATCH` or `vMAJOR.MINOR.PATCH`. - -## Dependency policy - -Keep this low-dependency. A stdlib Python script plus existing `uv`, `git`, and -`gh` commands should be enough. Do not add a release framework unless this script -becomes hard to maintain.