Skip to content

Commit 0582636

Browse files
fix(actions): rework release workflow (Pycord-Development#3034)
* fix(actions): rework release workflow * style(pre-commit): auto fixes from pre-commit.com hooks --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent d026418 commit 0582636

File tree

5 files changed

+513
-60
lines changed

5 files changed

+513
-60
lines changed

.github/workflows/release.yml

Lines changed: 40 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,21 @@ jobs:
146153
shell: bash
147154
env:
148155
VERSION: ${{ inputs.version }}
156+
PREVIOUS_TAG: ${{ needs.pre_config.outputs.previous_tag }}
149157
REPOSITORY: ${{ github.repository }}
150158
GITHUB_TOKEN: ${{ secrets.ADMIN_GITHUB_TOKEN }}
151159
BRANCH: ${{ github.ref_name }}
152160
run: |
153161
git config user.name "NyuwBot"
154162
git config user.email "[email protected]"
155163
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
164+
python scripts/update_changelog.py \
165+
--path CHANGELOG.md \
166+
--version "$VERSION" \
167+
--previous-tag "$PREVIOUS_TAG" \
168+
--branch "$BRANCH" \
169+
--repository "$REPOSITORY" \
170+
--date "$DATE"
160171
git add CHANGELOG.md
161172
git commit -m "chore(release): update CHANGELOG.md for version $VERSION"
162173
- name: "Commit and Push Changelog to ${{ github.ref_name }}"
@@ -235,67 +246,36 @@ jobs:
235246
docs_release:
236247
runs-on: ubuntu-latest
237248
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')) }}
241249
environment: release
242250
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 }}"
251+
- name: "Checkout repository"
252+
uses: actions/checkout@v6
253+
with:
254+
fetch-depth: 0
255+
fetch-tags: true
248256

249-
- name: "Activate and Show Version on Read the Docs"
257+
- name: "Sync and activate version on Read the Docs"
258+
env:
259+
READTHEDOCS_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }}
250260
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-
}'
261+
python3 scripts/manage_rtd_version.py \
262+
--project pycord \
263+
--version "${{ needs.pre_config.outputs.version }}" \
264+
--sync
266265
267266
inform_discord:
268267
runs-on: ubuntu-latest
269268
needs: [docs_release, lib_release, pre_config]
270269
environment: release
271270
steps:
272271
- 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 }}
272+
env:
273+
VERSION: ${{ needs.pre_config.outputs.version }}
274+
PREVIOUS_TAG: ${{ needs.pre_config.outputs.previous_tag }}
275+
PREVIOUS_FINAL_TAG: ${{ needs.pre_config.outputs.previous_final_tag }}
276+
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
277+
REPOSITORY: ${{ github.repository }}
278+
run: python scripts/notify_discord.py
299279

300280
determine_milestone_id:
301281
runs-on: ubuntu-latest

scripts/manage_rtd_version.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import argparse
2+
import json
3+
import os
4+
import re
5+
import sys
6+
import urllib.error
7+
import urllib.request
8+
9+
API_BASE = "https://readthedocs.org/api/v3"
10+
11+
12+
def sync_versions(project: str, token: str) -> None:
13+
url = f"{API_BASE}/projects/{project}/sync-versions/"
14+
req = urllib.request.Request(
15+
url,
16+
data=json.dumps({}).encode("utf-8"),
17+
headers={
18+
"Content-Type": "application/json",
19+
"Authorization": f"Token {token}",
20+
},
21+
method="POST",
22+
)
23+
with urllib.request.urlopen(req) as resp: # noqa: S310
24+
if resp.status >= 300:
25+
raise RuntimeError(
26+
f"Sync versions failed for {project} with status {resp.status}"
27+
)
28+
29+
30+
def activate_version(project: str, docs_version: str, hidden: bool, token: str) -> None:
31+
url = f"{API_BASE}/projects/{project}/versions/{docs_version}/"
32+
payload = {"active": True, "hidden": hidden}
33+
req = urllib.request.Request(
34+
url,
35+
data=json.dumps(payload).encode("utf-8"),
36+
headers={
37+
"Content-Type": "application/json",
38+
"Authorization": f"Token {token}",
39+
},
40+
method="PATCH",
41+
)
42+
with urllib.request.urlopen(req) as resp: # noqa: S310
43+
if resp.status >= 300:
44+
raise RuntimeError(
45+
f"Activating version {docs_version} for {project} failed with status {resp.status}"
46+
)
47+
48+
49+
def determine_docs_version(version: str) -> tuple[str, bool]:
50+
match = re.match(
51+
r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?P<suffix>rc\d+)?$", version
52+
)
53+
if not match:
54+
raise ValueError(f"Version '{version}' is not in the expected format")
55+
major = match.group("major")
56+
minor = match.group("minor")
57+
suffix = match.group("suffix") or ""
58+
hidden = bool(suffix)
59+
if hidden:
60+
docs_version = f"v{major}.{minor}.x"
61+
else:
62+
docs_version = f"v{version}"
63+
return docs_version, hidden
64+
65+
66+
def main() -> None:
67+
parser = argparse.ArgumentParser(
68+
description="Manage Read the Docs version activation."
69+
)
70+
parser.add_argument(
71+
"--project", default="pycord", help="RTD project slug (default: pycord)"
72+
)
73+
parser.add_argument(
74+
"--version", required=True, help="Release version (e.g., 2.6.0 or 2.6.0rc1)"
75+
)
76+
parser.add_argument("--token", help="RTD token (overrides READTHEDOCS_TOKEN env)")
77+
parser.add_argument(
78+
"--sync", action="store_true", help="Sync versions before activating"
79+
)
80+
parser.add_argument(
81+
"--dry-run",
82+
action="store_true",
83+
help="Print planned actions without calling RTD",
84+
)
85+
args = parser.parse_args()
86+
87+
token = args.token or os.environ.get("READTHEDOCS_TOKEN")
88+
if not token:
89+
sys.exit("Missing Read the Docs token.")
90+
91+
try:
92+
docs_version, hidden = determine_docs_version(args.version)
93+
except ValueError as exc:
94+
sys.exit(str(exc))
95+
96+
if args.dry_run:
97+
plan = {
98+
"project": args.project,
99+
"version": args.version,
100+
"docs_version": docs_version,
101+
"hidden": hidden,
102+
"sync": args.sync,
103+
}
104+
print(json.dumps(plan, indent=2))
105+
return
106+
107+
try:
108+
if args.sync:
109+
sync_versions(args.project, token)
110+
activate_version(args.project, docs_version, hidden, token)
111+
except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as exc:
112+
sys.exit(str(exc))
113+
114+
115+
if __name__ == "__main__":
116+
main()

scripts/notify_discord.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import argparse
2+
import json
3+
import os
4+
import sys
5+
import urllib.error
6+
import urllib.request
7+
8+
9+
def build_message(
10+
version: str, previous_tag: str, previous_final_tag: str, repo: str
11+
) -> str:
12+
major_minor = version.split(".")[:2]
13+
major_minor_str = ".".join(major_minor)
14+
docs_url = f"https://docs.pycord.dev/en/v{version}/changelog.html"
15+
base_compare = previous_tag
16+
if "rc" not in version:
17+
base_compare = previous_final_tag or previous_tag
18+
compare_url = f"https://github.com/{repo}/compare/{base_compare}...v{version}"
19+
release_url = f"https://github.com/{repo}/releases/tag/v{version}"
20+
pypi_url = f"https://pypi.org/project/py-cord/{version}/"
21+
22+
if "rc" in version:
23+
heading = f"## <:pycord:1063211537008955495> Pycord v{version} Release Candidate ({major_minor_str}) is available!\n\n"
24+
audience = "@here\n\n"
25+
preface = (
26+
"This is a pre-release (release candidate) for testing and feedback.\n\n"
27+
)
28+
docs_line = f"You can view the changelog here: <{docs_url}>\n\n"
29+
links = f"Check out the [GitHub changelog](<{compare_url}>), [GitHub release page](<{release_url}>), and [PyPI release page](<{pypi_url}>).\n\n"
30+
install = f"You can install this version by running the following command:\n```sh\npip install -U py-cord=={version}\n```\n\n"
31+
close = "Please try it out and let us know your feedback or any issues!"
32+
else:
33+
heading = f"## <:pycord:1063211537008955495> Pycord v{version} is out!\n\n"
34+
audience = "@everyone\n\n"
35+
preface = ""
36+
docs_line = f"You can view the changelog here: <{docs_url}>\n\n"
37+
links = f"Feel free to take a look at the [GitHub changelog](<{compare_url}>), [GitHub release page](<{release_url}>) and the [PyPI release page](<{pypi_url}>).\n\n"
38+
install = f"You can install this version by running the following command:\n```sh\npip install -U py-cord=={version}\n```"
39+
close = ""
40+
41+
return heading + audience + preface + docs_line + links + install + close
42+
43+
44+
def send_webhook(webhook_url: str, content: str) -> None:
45+
payload = {"content": content, "allowed_mentions": {"parse": ["everyone", "roles"]}}
46+
data = json.dumps(payload).encode("utf-8")
47+
req = urllib.request.Request(
48+
webhook_url,
49+
data=data,
50+
headers={
51+
"Content-Type": "application/json",
52+
"User-Agent": "pycord-release-bot/1.0 (+https://github.com/Pycord-Development/pycord)",
53+
"Accept": "*/*",
54+
},
55+
method="POST",
56+
)
57+
with urllib.request.urlopen(req) as resp: # noqa: S310
58+
if resp.status >= 300:
59+
raise RuntimeError(f"Webhook post failed with status {resp.status}")
60+
61+
62+
def main() -> None:
63+
parser = argparse.ArgumentParser(description="Notify Discord about a release.")
64+
parser.add_argument(
65+
"--dry-run", action="store_true", help="Print payload instead of sending"
66+
)
67+
parser.add_argument(
68+
"--webhook-url", help="Webhook URL (overrides DISCORD_WEBHOOK_URL)"
69+
)
70+
args = parser.parse_args()
71+
72+
version = os.environ.get("VERSION")
73+
previous_tag = os.environ.get("PREVIOUS_TAG")
74+
previous_final_tag = os.environ.get("PREVIOUS_FINAL_TAG")
75+
webhook_url = args.webhook_url or os.environ.get("DISCORD_WEBHOOK_URL")
76+
repo = os.environ.get("REPOSITORY")
77+
78+
if not all([version, previous_tag, repo]) or (not args.dry_run and not webhook_url):
79+
sys.exit("Missing required environment variables.")
80+
81+
message = build_message(
82+
version, previous_tag, previous_final_tag or previous_tag, repo
83+
)
84+
85+
if args.dry_run:
86+
payload = {
87+
"content": message,
88+
"allowed_mentions": {"parse": ["everyone", "roles"]},
89+
}
90+
print(json.dumps(payload, indent=2))
91+
return
92+
93+
send_webhook(webhook_url, message)
94+
95+
96+
if __name__ == "__main__":
97+
main()

0 commit comments

Comments
 (0)