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
115 changes: 92 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
40 changes: 0 additions & 40 deletions CHANGELOG.md

This file was deleted.

13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
1 change: 0 additions & 1 deletion VERSION

This file was deleted.

1 change: 0 additions & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ _answers_file: .copier-py-answers.yml

# the project owns these once created
_skip_if_exists:
- VERSION
- renovate.json

#===============================================================================
Expand Down
2 changes: 1 addition & 1 deletion template/.github/.copier-ci-answers.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading