Skip to content

Commit 4d2b8fe

Browse files
ci(release): publish a release from its annotated tag
A release is now an annotated tag vX.Y.Z on master whose message is the release notes. Pushing the tag runs the new Release workflow: it checks the tag, runs the lint and test workflows as the gate, builds the package at the tag, publishes it to PyPI, creates the GitHub release with the tag's message as its notes and the wheel and sdist attached, and posts to Slack. The check (`.github/scripts/check_release_tag.sh`) fetches the tag object itself, since checkout can store an annotated tag as a lightweight ref. It fails on a lightweight tag, naming the `git tag -a` command, and on a tag whose commit is not on master. It stops without publishing on a tag whose message has a `Retroactive:` line, which marks a past release re-tagged with its notes. Running the workflow by hand from a tag publishes it to TestPyPI, or, with testpypi unchecked, repeats a failed release. This replaces release-drafter: the Manual Draft Release workflow, its configuration, and the workflow that published a release once its draft was published, including the archived upload-release-asset action. The lint and test workflows gain a workflow_call trigger so the release can run them. RELEASE_MEMO.md describes how a release is cut, and CONTRIBUTING.md points to it. Pull request titles still state the user-visible change, since the notes are written from them; labels no longer drive the notes.
1 parent 58412c8 commit 4d2b8fe

9 files changed

Lines changed: 246 additions & 244 deletions

File tree

‎.github/release_drafter.yaml‎

Lines changed: 0 additions & 35 deletions
This file was deleted.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
# Decide whether a release tag can be published.
3+
#
4+
# check_release_tag.sh <tag> [<branch>]
5+
#
6+
# Fails on a tag that is not vX.Y.Z, on a lightweight tag, and on a tag whose
7+
# commit is not on <branch> (default: master). Succeeds without publishing on a
8+
# tag whose message has a "Retroactive:" line: such tags record past releases.
9+
# Writes publish=true or publish=false to $GITHUB_OUTPUT when it is set.
10+
set -euo pipefail
11+
12+
tag="${1:?usage: check_release_tag.sh <tag> [<branch>]}"
13+
branch="${2:-master}"
14+
output="${GITHUB_OUTPUT:-/dev/null}"
15+
16+
fail() {
17+
echo "::error::$1" >&2
18+
shift
19+
for line in "$@"; do echo "$line" >&2; done
20+
exit 1
21+
}
22+
23+
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "$tag is not a release tag (vX.Y.Z)."
24+
25+
# actions/checkout can store an annotated tag as a lightweight ref, so fetch the
26+
# tag object itself, and the branch the tag must be on.
27+
git fetch --quiet --force origin "refs/tags/$tag:refs/tags/$tag"
28+
git fetch --quiet --no-tags origin "+refs/heads/$branch:refs/remotes/origin/$branch"
29+
30+
if [[ "$(git cat-file -t "refs/tags/$tag")" != "tag" ]]; then
31+
fail "$tag is a lightweight tag. A release tag is annotated, with the release notes as its message:" \
32+
" git tag -d $tag" \
33+
" git tag -a $tag <commit> --cleanup=verbatim -F notes.md" \
34+
" git push --force origin refs/tags/$tag"
35+
fi
36+
37+
commit="$(git rev-parse "refs/tags/$tag^{commit}")"
38+
if ! git merge-base --is-ancestor "$commit" "refs/remotes/origin/$branch"; then
39+
fail "$tag points to $commit, which is not on $branch."
40+
fi
41+
42+
if git for-each-ref "refs/tags/$tag" --format='%(contents)' | grep -q '^Retroactive:'; then
43+
echo "::notice::$tag records a past release (its message has a Retroactive: line); nothing to publish."
44+
echo "publish=false" >> "$output"
45+
exit 0
46+
fi
47+
48+
echo "$tag is an annotated tag on $branch; publishing."
49+
echo "publish=true" >> "$output"

‎.github/workflows/draft_release.yaml‎

Lines changed: 0 additions & 31 deletions
This file was deleted.

‎.github/workflows/lint.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ on:
1010
- "**" # every branch
1111
- "!gh-pages" # exclude gh-pages branch
1212
- "!stage*" # exclude branches beginning with stage
13+
# The release workflow runs this as its gate.
14+
workflow_call:
1315
jobs:
1416
lint:
1517
runs-on: ubuntu-latest

‎.github/workflows/post_draft_release_published.yaml‎

Lines changed: 0 additions & 108 deletions
This file was deleted.

‎.github/workflows/release.yaml‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
name: Release
2+
3+
# A release is an annotated tag vX.Y.Z on master whose message is the release
4+
# notes. Pushing it runs the test suite, builds the package at the tag (the
5+
# version comes from the tag through hatch-vcs), publishes it to PyPI, and
6+
# creates the GitHub release with the tag's message as its notes.
7+
#
8+
# To publish an existing tag to TestPyPI instead, run this workflow by hand from
9+
# that tag ("Use workflow from" -> Tags) with testpypi checked.
10+
on:
11+
push:
12+
tags:
13+
- "v[0-9]+.[0-9]+.[0-9]+"
14+
workflow_dispatch:
15+
inputs:
16+
testpypi:
17+
description: "Publish to TestPyPI, without a GitHub release or notification"
18+
type: boolean
19+
default: true
20+
21+
permissions:
22+
contents: read
23+
24+
concurrency:
25+
group: release-${{ github.ref_name }}
26+
cancel-in-progress: false
27+
28+
jobs:
29+
check:
30+
runs-on: ubuntu-latest
31+
outputs:
32+
publish: ${{ steps.check.outputs.publish }}
33+
steps:
34+
- name: Require a tag
35+
if: github.ref_type != 'tag'
36+
run: |
37+
echo "::error::Run this workflow from a release tag (vX.Y.Z), not from a branch."
38+
exit 1
39+
- uses: actions/checkout@v4
40+
with:
41+
fetch-depth: 0
42+
- name: Check the tag
43+
id: check
44+
run: .github/scripts/check_release_tag.sh "$GITHUB_REF_NAME" master
45+
46+
lint:
47+
needs: check
48+
if: needs.check.outputs.publish == 'true'
49+
uses: ./.github/workflows/lint.yaml
50+
51+
test:
52+
needs: check
53+
if: needs.check.outputs.publish == 'true'
54+
uses: ./.github/workflows/test.yaml
55+
56+
publish:
57+
needs: [check, lint, test]
58+
if: needs.check.outputs.publish == 'true'
59+
runs-on: ubuntu-latest
60+
permissions:
61+
# write permission is required to create the GitHub release
62+
contents: write
63+
env:
64+
TAG: ${{ github.ref_name }}
65+
TEST_PYPI: ${{ github.event_name == 'workflow_dispatch' && inputs.testpypi }}
66+
TWINE_USERNAME: ${{ secrets.twine_username }}
67+
TWINE_PASSWORD: ${{ secrets.twine_password }}
68+
TWINE_TEST_USERNAME: ${{ secrets.twine_test_username }}
69+
TWINE_TEST_PASSWORD: ${{ secrets.twine_test_password }}
70+
steps:
71+
- uses: actions/checkout@v4
72+
with:
73+
# Full history and tags, so hatch-vcs derives the version from the tag.
74+
fetch-depth: 0
75+
- name: Fetch the tag object
76+
# actions/checkout can store an annotated tag as a lightweight ref.
77+
run: git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
78+
- name: Set up Python
79+
uses: actions/setup-python@v5
80+
with:
81+
# The oldest supported Python; the build is pure Python either way.
82+
python-version: "3.10"
83+
- name: Build
84+
run: |
85+
python -m pip install build
86+
python -m build .
87+
- name: Check the built version
88+
run: |
89+
version="${TAG#v}"
90+
for file in dist/*; do
91+
case "$(basename "$file")" in
92+
"datajoint-$version-"*.whl | "datajoint-$version.tar.gz") ;;
93+
*) echo "::error::$file does not carry version $version"; exit 1 ;;
94+
esac
95+
done
96+
- name: Publish to PyPI
97+
run: |
98+
python -m pip install --upgrade twine
99+
if [[ "$TEST_PYPI" == "true" ]]; then
100+
export TWINE_REPOSITORY="testpypi"
101+
export TWINE_USERNAME="$TWINE_TEST_USERNAME"
102+
export TWINE_PASSWORD="$TWINE_TEST_PASSWORD"
103+
else
104+
export TWINE_REPOSITORY="pypi"
105+
fi
106+
# --skip-existing keeps re-runs idempotent.
107+
python -m twine upload --skip-existing dist/*
108+
- name: Create the GitHub release
109+
if: env.TEST_PYPI != 'true'
110+
env:
111+
GH_TOKEN: ${{ github.token }}
112+
run: |
113+
git for-each-ref "refs/tags/$TAG" --format='%(contents)' > "$RUNNER_TEMP/notes.md"
114+
gh release create "$TAG" --verify-tag --title "DataJoint Library ${TAG#v}" \
115+
--notes-file "$RUNNER_TEMP/notes.md" dist/*
116+
- name: Post release notification to Slack
117+
if: env.TEST_PYPI != 'true'
118+
uses: slackapi/slack-github-action@v2.0.0
119+
with:
120+
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
121+
webhook-type: incoming-webhook
122+
payload: |
123+
{
124+
"text": "*New Release Published!* :tada: \n*Repository:* ${{ github.repository }}\n*Version:* ${{ github.ref_name }}\n*URL:* ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }}",
125+
"blocks": [
126+
{
127+
"type": "section",
128+
"text": {
129+
"type": "mrkdwn",
130+
"text": "*New Release Published!* :tada:\n*Repository:* ${{ github.repository }}\n*Version:* ${{ github.ref_name }}\n*URL:* <${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }}|View Release>"
131+
}
132+
}
133+
]
134+
}

‎.github/workflows/test.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ on:
2424
- "pixi.lock"
2525
- ".github/workflows/test.yaml"
2626

27+
# The release workflow runs this as its gate.
28+
workflow_call:
29+
2730
jobs:
2831
test:
2932
runs-on: ubuntu-latest

‎CONTRIBUTING.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,10 @@ def insert(self, rows, *, replace=False):
186186
- **Don't:** Repeat function signature in description
187187

188188
See [NumPy Docstring Guide](https://numpydoc.readthedocs.io/en/latest/format.html) for full reference.
189+
190+
## Releases
191+
192+
Releases are annotated tags whose messages are the release notes; there is no changelog file to
193+
update in a pull request. A pull request's title states its user-visible change, since the
194+
notes are written from the titles. See [RELEASE_MEMO.md](RELEASE_MEMO.md) for how a release is
195+
cut.

0 commit comments

Comments
 (0)