diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c22566..5c93745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,48 +10,117 @@ permissions: contents: read concurrency: - # pull requests share a group per branch so newer pushes replace older runs; each commit on main - # gets its own group, since a pending run in a shared group is dropped and its tag would be skipped - group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + # pull requests share a group per branch so newer pushes replace older runs; pushes to main share one + # group so releases go out in order, and a run dropped while pending is covered by the next, which + # releases every commit since the last tag + group: ci-${{ github.event_name == 'pull_request' && github.ref || 'main' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: version: runs-on: ubuntu-26.04 + outputs: + version: ${{ steps.next.outputs.version }} + released: ${{ steps.next.outputs.released }} + commits: ${{ steps.next.outputs.commits }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: VERSION holds a single X.Y.Z version + # full history and tags, to find the last release and every commit since + fetch-depth: 0 + - id: next + name: Work out the version from the commits since the last release run: | - grep -Eqx '[0-9]+\.[0-9]+\.[0-9]+' VERSION && [ "$(grep -c "" VERSION)" -eq 1 ] || { - echo "::error file=VERSION::VERSION must be one line of the form X.Y.Z, without a leading v" - exit 1 - } + pattern='v[0-9]+\.[0-9]+\.[0-9]+' + latest=$(git tag --list --sort=-v:refname | { grep -Ex "$pattern" || true; } | sed -n 1p) + current=$(git tag --points-at HEAD | { grep -Ex "$pattern" || true; } | sort -V | tail -n 1) - tag: - if: github.event_name == 'push' + released=false + if [ -n "$current" ]; then + # a re-run on a commit that is already released + released=true + version=${current#v} + bump="already released" + elif [ -z "$latest" ]; then + version=0.1.0 + bump="first release" + else + IFS=. read -r major minor patch <<< "${latest#v}" + subjects=$(git log --no-merges --format=%s "$latest..HEAD") + bodies=$(git log --no-merges --format=%b "$latest..HEAD") + if grep -Eq '^[a-z]+(\([^)]*\))?!:' <<< "$subjects" || grep -Eq '^BREAKING[ -]CHANGE:' <<< "$bodies"; then + version="$((major + 1)).0.0" + bump="X, for a breaking change" + elif grep -Eq '^feat(\([^)]*\))?:' <<< "$subjects"; then + version="$major.$((minor + 1)).0" + bump="Y, for a feat commit" + else + version="$major.$minor.$((patch + 1))" + bump="Z" + fi + fi + + # each first-parent commit since the last release is one merge to main, so one set of notes + if [ -n "$latest" ]; then + commits=$(git rev-list --first-parent "$latest..HEAD" | tr '\n' ' ') + else + commits=$(git rev-parse HEAD) + fi + + { + echo "version=$version" + echo "released=$released" + echo "commits=$commits" + } >> "$GITHUB_OUTPUT" + echo "::notice title=Version::v$version ($bump)" + + release: + if: github.event_name == 'push' && needs.version.outputs.released != 'true' needs: version runs-on: ubuntu-26.04 permissions: - # creates the tag named in VERSION on the merged commit + # publishes the release, which also creates its tag contents: write + # reads the merged pull requests' descriptions for the release notes + pull-requests: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - id: version - run: echo "tag=v$(tr -d "[:space:]" < VERSION)" >> "$GITHUB_OUTPUT" - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - TAG: ${{ steps.version.outputs.tag }} + TAG: v${{ needs.version.outputs.version }} + COMMITS: ${{ needs.version.outputs.commits }} with: script: | const tag = process.env.TAG; - try { - await github.rest.git.createRef({ ...context.repo, ref: `refs/tags/${tag}`, sha: context.sha }); - core.notice(`Tagged ${context.sha} as ${tag}`); - } catch (error) { - if (error.status !== 422 || !/already exists/i.test(error.message)) throw error; - core.notice(`${tag} already exists; VERSION was not bumped, so nothing was tagged`); + + // newest first; a pull request merged by rebase owns several commits but is listed once + const sections = []; + const listed = new Set(); + for (const sha of process.env.COMMITS.split(' ').filter(Boolean)) { + const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + ...context.repo, + commit_sha: sha, + }); + const merged = pulls.find(pull => pull.merge_commit_sha === sha) ?? pulls.find(pull => pull.merged_at); + if (merged) { + if (listed.has(merged.number)) continue; + listed.add(merged.number); + sections.push({ title: `${merged.title} (#${merged.number})`, body: merged.body || '' }); + } else { + const { data: commit } = await github.rest.git.getCommit({ ...context.repo, commit_sha: sha }); + const [title, ...rest] = commit.message.split('\n'); + sections.push({ title, body: rest.join('\n') }); + } } + const notes = sections.length === 1 + ? sections[0].body.trim() || sections[0].title + : sections.map(section => `### ${section.title}\n\n${section.body.trim()}`.trim()).join('\n\n'); + + await github.rest.repos.createRelease({ + ...context.repo, + tag_name: tag, + target_commitish: context.sha, + name: tag, + body: notes, + }); + core.notice(`Released ${tag}`); diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 1c69911..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# Changelog - -## Added - -* 3.0.3 - * the changelog lists changes under the version they were released in, newest first -* 3.0.2 - * a shorter README with the right copy command, gh:karshosphere/template-python - * the first commit of a new project links to this template -* 3.0.1 - * generated code passes make format-dry-run when no author is set in the Copier settings -* 3.0.0 - * VERSION holds a plain X.Y.Z here and in generated projects, with CI adding the v for tags - * existing projects remove the v from VERSION once -* 2.2.0 - * generated projects take their package version from VERSION - * existing projects run `uv lock` once after updating -* 2.1.2 - * shell history in the dev container persists between sessions -* 2.1.1 - * the dev container, and CI with it, starts on hosts that have no /etc/timezone -* 2.1.0 - * generated projects keep their Copier answers in `.copier-py-answers.yml` at the root rather than in `.copier-generic-project/` - * existing projects move the file once before their next update -* 2.0.0 - * generated projects ship a Makefile with build, format, format-dry-run, test and release targets in place of run.sh - * make in the dev container, and the shared CI calls make -* 1.2.0 - * generated projects ship the shared GitHub CI, a VERSION file and the shared Renovate settings, which copier update leaves alone -* 1.1.0 - * generated projects ship a run.sh for building, formatting with ruff, testing with pytest and building a wheel and sdist - * starter tests and ruff-clean code -* 1.0.2 - * generated projects install, start and build - * a uv.lock, the right colorlog package, a working FastAPI lifespan and a pinned uv in the dev container -* 1.0.1 - * the Copier badge in generated READMEs links to karshosphere/template-python -* 1.0.0 - * `VERSION` file starting at v1.0.0, checked on every pull request - * each merge to main tagged with that version diff --git a/README.md b/README.md index 9bca1ed..a2a79cb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ $ copier update --defaults --answers-file .copier-py-answers.yml Older projects need a one-off step: * **Before 2.1.0:** move the answers file to the root first, with `git mv .copier-generic-project/.copier-py-answers.yml .copier-py-answers.yml`. * **Before 2.2.0:** run `uv lock` after the update and commit `uv.lock`, as the lock still holds the old fixed version. -* **Before 3.0.0:** drop the `v` from `VERSION` with `sed -i 's/^v//' VERSION`. + +Updating to 4.0.0 removes `VERSION`; the project's existing tags carry on as before. ## What a Project Gets @@ -55,14 +56,14 @@ Older projects need a one-off step: * `make format` formats the sources with ruff; `make format-dry-run` fails if any need it. * `make test` runs the tests with pytest. * `make release` builds a wheel and an sdist into `dist/`. -* A package version read from `VERSION`, so that file is the only one to bump. +* A package version taken from the release tags by setuptools-scm, so there is nothing to bump. * A dev container with Python, uv and make. -* The shared CI from [template-github-ci](https://github.com/karshosphere/template-github-ci): it runs the four make targets in the dev container, requires a `VERSION` bump on every pull request, and publishes a GitHub release on each merge to main. -* `VERSION`, starting at `0.1.0`, and a `renovate.json` using the shared Renovate settings. The project owns both; updates never overwrite them. +* The shared CI from [template-github-ci](https://github.com/karshosphere/template-github-ci): it runs the four make targets in the dev container, works out each release's version from the commit messages, and publishes a GitHub release on each merge to main. +* A `renovate.json` using the shared Renovate settings. The project owns it; updates never overwrite it. ## Maintaining the Template -* Bump `VERSION`, a plain `X.Y.Z`, on every branch: `Z` for fixes, `Y` for features and chores, `X` for changes that break `copier update` in existing projects. CI checks it and tags each merge to main `vX.Y.Z`. -* Renovate keeps the pinned actions and images current, grouping minor updates into one weekly pull request. It never touches `VERSION`. +* There is no `VERSION` file or changelog. CI works out each release's version from the commit messages: `X` for a change that breaks `copier update` in existing projects, marked with `!` after the type as in `feat!:`, `Y` for `feat:`, and `Z` for anything else. Each merge to main is published as a GitHub release tagged `vX.Y.Z`, with the pull request's description as its notes. +* Renovate keeps the pinned actions and images current in one weekly pull request, which merges as a `Z` release with no other change. * Bring in a newer release of the shared CI from the repository root with `copier update -a .github/.copier-ci-answers.yml template`. diff --git a/VERSION b/VERSION deleted file mode 100644 index 75a22a2..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -3.0.3 diff --git a/copier.yml b/copier.yml index 76ffa1e..59138ef 100644 --- a/copier.yml +++ b/copier.yml @@ -5,7 +5,6 @@ _answers_file: .copier-py-answers.yml # the project owns these once created _skip_if_exists: - - VERSION - renovate.json #=============================================================================== diff --git a/template/.github/.copier-ci-answers.yml b/template/.github/.copier-ci-answers.yml index f77eff6..c54e0d8 100644 --- a/template/.github/.copier-ci-answers.yml +++ b/template/.github/.copier-ci-answers.yml @@ -1,4 +1,4 @@ # .github/.copier-ci-answers.yml.jinja # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY -_commit: v3.0.0 +_commit: v4.0.0 _src_path: gh:karshosphere/template-github-ci diff --git a/template/.github/workflows/ci.yml b/template/.github/workflows/ci.yml index f6f9dbb..9fd6034 100644 --- a/template/.github/workflows/ci.yml +++ b/template/.github/workflows/ci.yml @@ -10,9 +10,10 @@ permissions: contents: read concurrency: - # pull requests share a group per branch so newer pushes replace older runs; each commit on main - # gets its own group, since a pending run in a shared group is dropped and its tag would be skipped - group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + # pull requests share a group per branch so newer pushes replace older runs; pushes to main share one + # group so releases go out in order, and a run dropped while pending is covered by the next, which + # releases every commit since the last tag + group: ci-${{ github.event_name == 'pull_request' && github.ref || 'main' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: @@ -24,33 +25,67 @@ env: jobs: version: runs-on: ubuntu-26.04 + outputs: + version: ${{ steps.next.outputs.version }} + released: ${{ steps.next.outputs.released }} + commits: ${{ steps.next.outputs.commits }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # full history, so every existing release tag is available to compare against + # full history and tags, to find the last release and every commit since fetch-depth: 0 - - name: VERSION holds a single X.Y.Z version + - id: next + name: Work out the version from the commits since the last release run: | - if ! grep -Eqx '[0-9]+\.[0-9]+\.[0-9]+' VERSION || [ "$(grep -c "" VERSION)" -ne 1 ]; then - echo "::error file=VERSION::VERSION must be one line of the form X.Y.Z, without a leading v" - exit 1 + pattern='v[0-9]+\.[0-9]+\.[0-9]+' + latest=$(git tag --list --sort=-v:refname | { grep -Ex "$pattern" || true; } | sed -n 1p) + current=$(git tag --points-at HEAD | { grep -Ex "$pattern" || true; } | sort -V | tail -n 1) + + released=false + if [ -n "$current" ]; then + # a re-run on a commit that is already released + released=true + version=${current#v} + bump="already released" + elif [ -z "$latest" ]; then + version=0.1.0 + bump="first release" + else + IFS=. read -r major minor patch <<< "${latest#v}" + subjects=$(git log --no-merges --format=%s "$latest..HEAD") + bodies=$(git log --no-merges --format=%b "$latest..HEAD") + if grep -Eq '^[a-z]+(\([^)]*\))?!:' <<< "$subjects" || grep -Eq '^BREAKING[ -]CHANGE:' <<< "$bodies"; then + version="$((major + 1)).0.0" + bump="X, for a breaking change" + elif grep -Eq '^feat(\([^)]*\))?:' <<< "$subjects"; then + version="$major.$((minor + 1)).0" + bump="Y, for a feat commit" + else + version="$major.$minor.$((patch + 1))" + bump="Z" + fi fi - - name: VERSION is higher than every release tag - run: | - # release tags are the version with a leading v - version=v$(tr -d '[:space:]' < VERSION) - latest=$(git tag --list --sort=-v:refname | { grep -Ex 'v[0-9]+\.[0-9]+\.[0-9]+' || true; } | sed -n 1p) + + # each first-parent commit since the last release is one merge to main, so one set of notes if [ -n "$latest" ]; then - highest=$(printf '%s\n' "$latest" "$version" | sort -V | tail -n 1) - if [ "$version" = "$latest" ] || [ "$highest" != "$version" ]; then - echo "::error file=VERSION::VERSION is ${version#v} but $latest is already released; bump it above ${latest#v}" - exit 1 - fi + commits=$(git rev-list --first-parent "$latest..HEAD" | tr '\n' ' ') + else + commits=$(git rev-parse HEAD) fi + { + echo "version=$version" + echo "released=$released" + echo "commits=$commits" + } >> "$GITHUB_OUTPUT" + echo "::notice title=Version::v$version ($bump)" + checks: + needs: version runs-on: ubuntu-26.04 + env: + VERSION: ${{ needs.version.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -68,13 +103,13 @@ jobs: - name: Start the dev container run: devcontainer up --workspace-folder . - name: Format - run: devcontainer exec --workspace-folder . make format-dry-run + run: devcontainer exec --workspace-folder . make format-dry-run VERSION="$VERSION" - name: Build - run: devcontainer exec --workspace-folder . make build + run: devcontainer exec --workspace-folder . make build VERSION="$VERSION" - name: Test - run: devcontainer exec --workspace-folder . make test + run: devcontainer exec --workspace-folder . make test VERSION="$VERSION" - name: Release build - run: devcontainer exec --workspace-folder . make release + run: devcontainer exec --workspace-folder . make release VERSION="$VERSION" - if: github.event_name == 'push' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -84,20 +119,15 @@ jobs: if-no-files-found: ignore release: - if: github.event_name == 'push' + if: github.event_name == 'push' && needs.version.outputs.released != 'true' needs: [version, checks] runs-on: ubuntu-26.04 permissions: # publishes the release, which also creates its tag contents: write - # reads the merged pull request's description for the release notes + # reads the merged pull requests' descriptions for the release notes pull-requests: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - id: version - run: echo "tag=v$(tr -d "[:space:]" < VERSION)" >> "$GITHUB_OUTPUT" - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: # a pattern rather than a name, so a missing artifact means no files instead of a failure @@ -106,19 +136,36 @@ jobs: path: dist - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - TAG: ${{ steps.version.outputs.tag }} + TAG: v${{ needs.version.outputs.version }} + COMMITS: ${{ needs.version.outputs.commits }} with: script: | const fs = require('fs'); const path = require('path'); const tag = process.env.TAG; - const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - ...context.repo, - commit_sha: context.sha, - }); - const merged = pulls.find(pull => pull.merge_commit_sha === context.sha) ?? pulls.find(pull => pull.merged_at); - const notes = merged?.body || context.payload.head_commit?.message || ''; + // newest first; a pull request merged by rebase owns several commits but is listed once + const sections = []; + const listed = new Set(); + for (const sha of process.env.COMMITS.split(' ').filter(Boolean)) { + const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + ...context.repo, + commit_sha: sha, + }); + const merged = pulls.find(pull => pull.merge_commit_sha === sha) ?? pulls.find(pull => pull.merged_at); + if (merged) { + if (listed.has(merged.number)) continue; + listed.add(merged.number); + sections.push({ title: `${merged.title} (#${merged.number})`, body: merged.body || '' }); + } else { + const { data: commit } = await github.rest.git.getCommit({ ...context.repo, commit_sha: sha }); + const [title, ...rest] = commit.message.split('\n'); + sections.push({ title, body: rest.join('\n') }); + } + } + const notes = sections.length === 1 + ? sections[0].body.trim() || sections[0].title + : sections.map(section => `### ${section.title}\n\n${section.body.trim()}`.trim()).join('\n\n'); const files = fs.existsSync('dist') ? fs.readdirSync('dist', { withFileTypes: true }).filter(entry => entry.isFile()).map(entry => entry.name) diff --git a/template/Makefile.jinja b/template/Makefile.jinja index 8735163..1538d79 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -2,6 +2,11 @@ PACKAGE := {{ module_slug }} +# CI passes the version it releases; without it, setuptools-scm reads the tags +ifdef VERSION +export SETUPTOOLS_SCM_PRETEND_VERSION := $(VERSION) +endif + .PHONY: build format format-dry-run test release build: diff --git a/template/README.md.jinja b/template/README.md.jinja index 760c7fb..9292ad4 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -54,7 +54,7 @@ $ make format-dry-run # list files that need formatting `make release` builds a wheel and an sdist into `dist/`. They install on any platform with Python 3.13. -The package version is read from `VERSION`, the same version CI releases, so bump only that file. +The package version comes from the release tags through setuptools-scm, so there is nothing to bump. In CI it is the version being released; locally it is worked out from `git describe`, or `0.0.0` with no tags or no git. diff --git a/template/VERSION b/template/VERSION deleted file mode 100644 index 6e8bf73..0000000 --- a/template/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 9f698d1..16a2eb4 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=80"] +requires = ["setuptools>=80", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] @@ -27,17 +27,21 @@ dependencies = [ [project.scripts] main = "{{ module_slug }}.main:main" -# setuptools reads VERSION, the version CI releases -[tool.setuptools.dynamic] -version = { file = "VERSION" } +# setuptools-scm reads the release tags, or the version CI passes in through the Makefile +[tool.setuptools_scm] +fallback_version = "0.0.0" [tool.setuptools.packages.find] include = ["{{ module_slug }}*"] [tool.uv] package = true -# rebuild the installed project when VERSION changes, so its metadata stays current -cache-keys = [{ file = "pyproject.toml" }, { file = "VERSION" }] +# rebuild the installed project when the version changes, so its metadata stays current +cache-keys = [ + { file = "pyproject.toml" }, + { git = { commit = true, tags = true } }, + { env = "SETUPTOOLS_SCM_PRETEND_VERSION" }, +] [dependency-groups] dev = [