Skip to content

Commit 7bb2a2d

Browse files
committed
fix(actions): rework release workflow (#3034)
1 parent d026418 commit 7bb2a2d

File tree

7 files changed

+679
-60
lines changed

7 files changed

+679
-60
lines changed

.github/workflows/release.yml

Lines changed: 42 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ jobs:
2828
is_rc: ${{ steps.determine_vars.outputs.is_rc }}
2929
version: ${{ steps.determine_vars.outputs.version }}
3030
previous_tag: ${{ steps.determine_vars.outputs.previous_tag }}
31+
previous_final_tag: ${{ steps.determine_vars.outputs.previous_final_tag }}
3132
runs-on: ubuntu-latest
3233
steps:
3334
- name: "Checkout Repository"
@@ -41,18 +42,24 @@ jobs:
4142
env:
4243
VERSION: ${{ github.event.inputs.version }}
4344
run: |
44-
VALID_VERSION_REGEX='^([0-9]+\.[0-9]+\.[0-9]+((a|b|rc|\.dev|\.post)[0-9]+)?)$'
45+
set -euo pipefail
46+
VALID_VERSION_REGEX='^[0-9]+\.[0-9]+\.[0-9]+(rc[0-9]+)?$'
4547
if ! [[ $VERSION =~ $VALID_VERSION_REGEX ]]; then
46-
echo "::error::Invalid version string '$VERSION'. Must match PEP 440 (e.g. 1.2.0, 1.2.0rc1, 1.2.0.dev1, 1.2.0a1, 1.2.0b1, 1.2.0.post1)"
48+
echo "::error::Invalid version string '$VERSION'. Only releases like 1.2.3 and release candidates like 1.2.3rc1 are supported."
4749
exit 1
4850
fi
49-
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]]; then
50-
echo "::error::Unsupported version string '$VERSION'. Only normal releases (e.g. 1.2.3) and rc (e.g. 1.2.3rc1) are supported at this time."
51+
echo "version=$VERSION" >> $GITHUB_OUTPUT
52+
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || git describe --tags --abbrev=0 2>/dev/null || true)
53+
if [[ -z "$PREVIOUS_TAG" ]]; then
54+
echo "::error::Could not determine previous tag. Ensure at least one tag exists."
5155
exit 1
5256
fi
53-
echo "version=$VERSION" >> $GITHUB_OUTPUT
54-
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^)
5557
echo "previous_tag=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT
58+
PREVIOUS_FINAL_TAG=$(git tag --sort=-v:refname | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)
59+
if [[ -z "$PREVIOUS_FINAL_TAG" ]]; then
60+
PREVIOUS_FINAL_TAG=$PREVIOUS_TAG
61+
fi
62+
echo "previous_final_tag=${PREVIOUS_FINAL_TAG}" >> $GITHUB_OUTPUT
5663
MAJOR_MINOR_VERSION=$(echo $VERSION | grep -oE '^[0-9]+\.[0-9]+')
5764
echo "branch_name=v${MAJOR_MINOR_VERSION}.x" >> $GITHUB_OUTPUT
5865
if [[ $VERSION == *rc* ]]; then
@@ -146,17 +153,23 @@ jobs:
146153
shell: bash
147154
env:
148155
VERSION: ${{ inputs.version }}
156+
PREVIOUS_TAG: ${{ needs.pre_config.outputs.previous_tag }}
157+
PREVIOUS_FINAL_TAG: ${{ needs.pre_config.outputs.previous_final_tag }}
149158
REPOSITORY: ${{ github.repository }}
150159
GITHUB_TOKEN: ${{ secrets.ADMIN_GITHUB_TOKEN }}
151160
BRANCH: ${{ github.ref_name }}
152161
run: |
153162
git config user.name "NyuwBot"
154163
git config user.email "[email protected]"
155164
DATE=$(date +'%Y-%m-%d')
156-
sed -i "/These changes are available on the \`.*\` branch, but have not yet been released\./{N;d;}" CHANGELOG.md
157-
sed -i "s/## \[Unreleased\]/## [$VERSION] - $DATE/" CHANGELOG.md
158-
sed -i "0,/## \[$VERSION\]/ s|## \[$VERSION\]|## [Unreleased]\n\nThese changes are available on the \`$BRANCH\` branch, but have not yet been released.\n\n### Added\n\n### Changed\n\n### Fixed\n\n### Deprecated\n\n### Removed\n\n&|" CHANGELOG.md
159-
sed -i "s|\[unreleased\]:.*|[unreleased]: https://github.com/$REPOSITORY/compare/v$VERSION...HEAD\n[$VERSION]: https://github.com/$REPOSITORY/compare/$(git describe --tags --abbrev=0 @^)...v$VERSION|" CHANGELOG.md
165+
python scripts/update_changelog.py \
166+
--path CHANGELOG.md \
167+
--version "$VERSION" \
168+
--previous-tag "$PREVIOUS_TAG" \
169+
--previous-final-tag "$PREVIOUS_FINAL_TAG" \
170+
--branch "$BRANCH" \
171+
--repository "$REPOSITORY" \
172+
--date "$DATE"
160173
git add CHANGELOG.md
161174
git commit -m "chore(release): update CHANGELOG.md for version $VERSION"
162175
- name: "Commit and Push Changelog to ${{ github.ref_name }}"
@@ -235,67 +248,36 @@ jobs:
235248
docs_release:
236249
runs-on: ubuntu-latest
237250
needs: [lib_release, pre_config]
238-
if:
239-
${{ needs.pre_config.outputs.is_rc == 'false' || (needs.pre_config.outputs.is_rc
240-
== 'true' && endsWith(needs.pre_config.outputs.version, '0rc1')) }}
241251
environment: release
242252
steps:
243-
- name: "Sync Versions on Read the Docs"
244-
run: |
245-
curl --location --request POST 'https://readthedocs.org/api/v3/projects/pycord/sync-versions/' \
246-
--header 'Content-Type: application/json' \
247-
--header "Authorization: Token ${{ secrets.READTHEDOCS_TOKEN }}"
253+
- name: "Checkout repository"
254+
uses: actions/checkout@v6
255+
with:
256+
fetch-depth: 0
257+
fetch-tags: true
248258

249-
- name: "Activate and Show Version on Read the Docs"
259+
- name: "Sync and activate version on Read the Docs"
260+
env:
261+
READTHEDOCS_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }}
250262
run: |
251-
VERSION=${{ needs.pre_config.outputs.version }}
252-
MAJOR_MINOR_VERSION=$(echo $VERSION | grep -oE '^[0-9]+\.[0-9]+')
253-
HIDDEN=$([[ $VERSION == *rc* ]] && echo true || echo false)
254-
if [[ $VERSION == *rc* ]]; then
255-
DOCS_VERSION="v${MAJOR_MINOR_VERSION}.x"
256-
else
257-
DOCS_VERSION="v$VERSION"
258-
fi
259-
curl --location --request PATCH "https://readthedocs.org/api/v3/projects/pycord/versions/$DOCS_VERSION/" \
260-
--header 'Content-Type: application/json' \
261-
--header "Authorization: Token ${{ secrets.READTHEDOCS_TOKEN }}" \
262-
--data '{
263-
"active": true,
264-
"hidden": $HIDDEN
265-
}'
263+
python3 scripts/manage_rtd_version.py \
264+
--project pycord \
265+
--version "${{ needs.pre_config.outputs.version }}" \
266+
--sync
266267
267268
inform_discord:
268269
runs-on: ubuntu-latest
269270
needs: [docs_release, lib_release, pre_config]
270271
environment: release
271272
steps:
272273
- name: "Notify Discord"
273-
run: |
274-
VERSION=${{ needs.pre_config.outputs.version }}
275-
MAJOR_MINOR_VERSION=$(echo $VERSION | grep -oE '^[0-9]+\.[0-9]+')
276-
DOCS_URL="<https://docs.pycord.dev/en/v$VERSION/changelog.html>"
277-
GITHUB_COMPARE_URL="<https://github.com/Pycord-Development/pycord/compare/${{ needs.pre_config.outputs.previous_tag }}...v$VERSION>"
278-
GITHUB_RELEASE_URL="<https://github.com/Pycord-Development/pycord/releases/tag/v$VERSION>"
279-
PYPI_RELEASE_URL="<https://pypi.org/project/py-cord/$VERSION/>"
280-
if [[ $VERSION == *rc* ]]; then
281-
ANNOUNCEMENT="## <:pycord:1063211537008955495> Pycord v$VERSION Release Candidate ($MAJOR_MINOR_VERSION) is available!\n\n"
282-
ANNOUNCEMENT="${ANNOUNCEMENT}@here\n\n"
283-
ANNOUNCEMENT="${ANNOUNCEMENT}This is a pre-release (release candidate) for testing and feedback.\n\n"
284-
ANNOUNCEMENT="${ANNOUNCEMENT}You can view the changelog here: <$DOCS_URL>\n\n"
285-
ANNOUNCEMENT="${ANNOUNCEMENT}Check out the [GitHub changelog]($GITHUB_COMPARE_URL), [GitHub release page]($GITHUB_RELEASE_URL), and [PyPI release page]($PYPI_RELEASE_URL).\n\n"
286-
ANNOUNCEMENT="${ANNOUNCEMENT}You can install this version by running the following command:\n\`\`\`sh\npip install -U py-cord==$VERSION\n\`\`\`\n\n"
287-
ANNOUNCEMENT="${ANNOUNCEMENT}Please try it out and let us know your feedback or any issues!"
288-
else
289-
ANNOUNCEMENT="## <:pycord:1063211537008955495> Pycord v${VERSION} is out!\n\n"
290-
ANNOUNCEMENT="${ANNOUNCEMENT}@everyone\n\n"
291-
ANNOUNCEMENT="${ANNOUNCEMENT}You can view the changelog here: <$DOCS_URL>\n\n"
292-
ANNOUNCEMENT="${ANNOUNCEMENT}Feel free to take a look at the [GitHub changelog]($GITHUB_COMPARE_URL), [GitHub release page]($GITHUB_RELEASE_URL) and the [PyPI release page]($PYPI_RELEASE_URL).\n\n"
293-
ANNOUNCEMENT="${ANNOUNCEMENT}You can install this version by running the following command:\n\`\`\`sh\npip install -U py-cord==$VERSION\n\`\`\`"
294-
fi
295-
curl -H "Content-Type: application/json" \
296-
-X POST \
297-
-d "{\"content\":\"$ANNOUNCEMENT\",\"allowed_mentions\":{\"parse\":[\"everyone\",\"roles\"]}}" \
298-
${{ secrets.DISCORD_WEBHOOK_URL }}
274+
env:
275+
VERSION: ${{ needs.pre_config.outputs.version }}
276+
PREVIOUS_TAG: ${{ needs.pre_config.outputs.previous_tag }}
277+
PREVIOUS_FINAL_TAG: ${{ needs.pre_config.outputs.previous_final_tag }}
278+
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
279+
REPOSITORY: ${{ github.repository }}
280+
run: python scripts/notify_discord.py
299281

300282
determine_milestone_id:
301283
runs-on: ubuntu-latest

scripts/count_sourcelines.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
"""
2+
The MIT License (MIT)
3+
4+
Copyright (c) 2025 Lala Sabathil <[email protected]> & Pycord Development
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a
7+
copy of this software and associated documentation files (the "Software"),
8+
to deal in the Software without restriction, including without limitation
9+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
10+
and/or sell copies of the Software, and to permit persons to whom the
11+
Software is furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
22+
DEALINGS IN THE SOFTWARE.
23+
"""
24+
125
import os
226

327
cur_path = os.getcwd()

scripts/docs_json_exporter.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
"""
2+
The MIT License (MIT)
3+
4+
Copyright (c) 2025 Lala Sabathil <[email protected]> & Pycord Development
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a
7+
copy of this software and associated documentation files (the "Software"),
8+
to deal in the Software without restriction, including without limitation
9+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
10+
and/or sell copies of the Software, and to permit persons to whom the
11+
Software is furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
22+
DEALINGS IN THE SOFTWARE.
23+
"""
24+
125
import json
226
import os
327

scripts/manage_rtd_version.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
The MIT License (MIT)
3+
4+
Copyright (c) 2025 Lala Sabathil <[email protected]> & Pycord Development
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a
7+
copy of this software and associated documentation files (the "Software"),
8+
to deal in the Software without restriction, including without limitation
9+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
10+
and/or sell copies of the Software, and to permit persons to whom the
11+
Software is furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
22+
DEALINGS IN THE SOFTWARE.
23+
"""
24+
25+
import argparse
26+
import json
27+
import os
28+
import re
29+
import sys
30+
import urllib.error
31+
import urllib.request
32+
33+
API_BASE = "https://readthedocs.org/api/v3"
34+
35+
36+
def sync_versions(project: str, token: str) -> None:
37+
url = f"{API_BASE}/projects/{project}/sync-versions/"
38+
req = urllib.request.Request(
39+
url,
40+
data=json.dumps({}).encode("utf-8"),
41+
headers={
42+
"Content-Type": "application/json",
43+
"Authorization": f"Token {token}",
44+
},
45+
method="POST",
46+
)
47+
with urllib.request.urlopen(req) as resp: # noqa: S310
48+
if resp.status >= 300:
49+
raise RuntimeError(
50+
f"Sync versions failed for {project} with status {resp.status}"
51+
)
52+
53+
54+
def activate_version(project: str, docs_version: str, hidden: bool, token: str) -> None:
55+
url = f"{API_BASE}/projects/{project}/versions/{docs_version}/"
56+
payload = {"active": True, "hidden": hidden}
57+
req = urllib.request.Request(
58+
url,
59+
data=json.dumps(payload).encode("utf-8"),
60+
headers={
61+
"Content-Type": "application/json",
62+
"Authorization": f"Token {token}",
63+
},
64+
method="PATCH",
65+
)
66+
with urllib.request.urlopen(req) as resp: # noqa: S310
67+
if resp.status >= 300:
68+
raise RuntimeError(
69+
f"Activating version {docs_version} for {project} failed with status {resp.status}"
70+
)
71+
72+
73+
def determine_docs_version(version: str) -> tuple[str, bool]:
74+
match = re.match(
75+
r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?P<suffix>rc\d+)?$", version
76+
)
77+
if not match:
78+
raise ValueError(f"Version '{version}' is not in the expected format")
79+
major = match.group("major")
80+
minor = match.group("minor")
81+
suffix = match.group("suffix") or ""
82+
hidden = bool(suffix)
83+
if hidden:
84+
docs_version = f"v{major}.{minor}.x"
85+
else:
86+
docs_version = f"v{version}"
87+
return docs_version, hidden
88+
89+
90+
def main() -> None:
91+
parser = argparse.ArgumentParser(
92+
description="Manage Read the Docs version activation."
93+
)
94+
parser.add_argument(
95+
"--project", default="pycord", help="RTD project slug (default: pycord)"
96+
)
97+
parser.add_argument(
98+
"--version", required=True, help="Release version (e.g., 2.6.0 or 2.6.0rc1)"
99+
)
100+
parser.add_argument("--token", help="RTD token (overrides READTHEDOCS_TOKEN env)")
101+
parser.add_argument(
102+
"--sync", action="store_true", help="Sync versions before activating"
103+
)
104+
parser.add_argument(
105+
"--dry-run",
106+
action="store_true",
107+
help="Print planned actions without calling RTD",
108+
)
109+
args = parser.parse_args()
110+
111+
token = args.token or os.environ.get("READTHEDOCS_TOKEN")
112+
if not token:
113+
sys.exit("Missing Read the Docs token.")
114+
115+
try:
116+
docs_version, hidden = determine_docs_version(args.version)
117+
except ValueError as exc:
118+
sys.exit(str(exc))
119+
120+
if args.dry_run:
121+
plan = {
122+
"project": args.project,
123+
"version": args.version,
124+
"docs_version": docs_version,
125+
"hidden": hidden,
126+
"sync": args.sync,
127+
}
128+
print(json.dumps(plan, indent=2))
129+
return
130+
131+
try:
132+
if args.sync:
133+
sync_versions(args.project, token)
134+
activate_version(args.project, docs_version, hidden, token)
135+
except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as exc:
136+
sys.exit(str(exc))
137+
138+
139+
if __name__ == "__main__":
140+
main()

0 commit comments

Comments
 (0)