Skip to content

Commit 1df6f96

Browse files
committed
1 parent dcaf6e8 commit 1df6f96

3 files changed

Lines changed: 129 additions & 52 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: Generate release schedule artifacts
2+
on:
3+
schedule:
4+
# At 00:00 on day-of-month 2 in every 3rd month. (i.e. every quarter)
5+
# 2nd is chosen to avoid fencepost errors
6+
- cron: "0 0 2 */3 *"
7+
# On demand
8+
workflow_dispatch:
9+
10+
jobs:
11+
create-artifacts:
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: write
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v5
18+
with:
19+
# We're going to make a tag that we can we release so we'll need the full history for that
20+
fetch-depth: 0
21+
22+
- uses: prefix-dev/setup-pixi@v0.9.2
23+
with:
24+
pixi-version: "v0.49.0"
25+
26+
- name: Generate artifacts
27+
run: |
28+
pixi run generate-schedule --locked
29+
30+
- name: Test json artifact
31+
run: |
32+
cat schedule.json | jq
33+
34+
- name: setup git
35+
run: |
36+
# git will complain if we don't do this first
37+
git config user.name "github-actions[bot]"
38+
git config user.email "github-actions[bot]@users.noreply.github.com"
39+
40+
- name: determine tag name
41+
id: tag_name
42+
run: |
43+
echo "TAG_NAME=$(date '+%Y-Q%q')" >> "$GITHUB_OUTPUT"
44+
- name: Commit artifacts and create tag
45+
env:
46+
GH_TOKEN: ${{ secrets.PAT }}
47+
run: |
48+
git add schedule.md chart.md schedule.json
49+
git commit -m "generate schedule for ${{ steps.tag_name.outputs.TAG_NAME }} release" || echo "No changes to commit"
50+
git tag ${{ steps.tag_name.outputs.TAG_NAME }}
51+
git push origin main
52+
git push origin tag ${{ steps.tag_name.outputs.TAG_NAME }}
53+
54+
- name: Publish github release
55+
uses: softprops/action-gh-release@v2
56+
env:
57+
GH_TOKEN: ${{ secrets.PAT }}
58+
with:
59+
generate_release_notes: true
60+
tag_name: ${{ steps.tag_name.outputs.TAG_NAME }}
61+
make_latest: true
62+
files: |
63+
schedule.md
64+
chart.md
65+
schedule.json
66+
67+
permissions:
68+
contents: write

‎action.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ runs:
5858
echo "Regenerating schedule.json..."
5959
pixi run generate-schedule --locked
6060
if diff -q schedule.json "${{ github.workspace }}/$SCHEDULE_FILE" >/dev/null; then
61-
echo "Source and destination have identical contents – nothing to move."
61+
echo "Source and destination have identical contents - nothing to move."
6262
else
6363
mv schedule.json "${{ github.workspace }}/$SCHEDULE_FILE"
6464
fi

‎spec0_versions.py‎

Lines changed: 60 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,121 @@
11
import requests
2-
import json
32
import collections
43
from datetime import datetime, timedelta
54

65
import pandas as pd
7-
from packaging.version import Version
6+
from packaging.version import Version, InvalidVersion
87

98

10-
PY_RELEASES = {
9+
py_releases = {
1110
"3.8": "Oct 14, 2019",
1211
"3.9": "Oct 5, 2020",
1312
"3.10": "Oct 4, 2021",
1413
"3.11": "Oct 24, 2022",
1514
"3.12": "Oct 2, 2023",
1615
"3.13": "Oct 7, 2024",
16+
"3.14": "Oct 7, 2025",
1717
}
18-
CORE_PACKAGES = [
19-
"ipython",
20-
"matplotlib",
21-
"networkx",
18+
core_packages = [
19+
# Path(x).stem for x in glob("../core-projects/*.md") if "_index" not in x
2220
"numpy",
21+
"scipy",
22+
"matplotlib",
2323
"pandas",
2424
"scikit-image",
25+
"networkx",
2526
"scikit-learn",
26-
"scipy",
2727
"xarray",
28+
"ipython",
2829
"zarr",
2930
]
30-
PLUS_36_MONTHS = timedelta(days=int(365 * 3))
31-
PLUS_24_MONTHS = timedelta(days=int(365 * 2))
31+
plus36 = timedelta(days=int(365 * 3))
32+
plus24 = timedelta(days=int(365 * 2))
3233

3334
# Release data
34-
# We put the cutoff at 3 quarters ago - we do not use "just" -9 months
35-
# to avoid the content of the quarter to change depending on when we
36-
# generate this file during the current quarter.
37-
CURRENT_DATE = pd.Timestamp.now()
38-
CURRENT_QUARTER_START = pd.Timestamp(
39-
CURRENT_DATE.year, (CURRENT_DATE.quarter - 1) * 3 + 1, 1
35+
36+
# put cutoff 3 quarters ago – we do not use "just" -9 month,
37+
# to avoid the content of the quarter to change depending on when we generate this
38+
# file during the current quarter.
39+
40+
current_date = pd.Timestamp.now()
41+
current_quarter_start = pd.Timestamp(
42+
current_date.year, (current_date.quarter - 1) * 3 + 1, 1
4043
)
41-
CUTOFF = CURRENT_QUARTER_START - pd.DateOffset(months=9)
44+
cutoff = current_quarter_start - pd.DateOffset(months=9)
4245

4346

44-
def get_release_dates(package, support_time=PLUS_24_MONTHS):
47+
def get_release_dates(package, support_time=plus24):
4548
releases = {}
49+
4650
print(f"Querying pypi.org for {package} versions...", end="", flush=True)
4751
response = requests.get(
4852
f"https://pypi.org/simple/{package}",
4953
headers={"Accept": "application/vnd.pypi.simple.v1+json"},
5054
).json()
5155
print("OK")
56+
5257
file_date = collections.defaultdict(list)
5358
for f in response["files"]:
54-
if f["filename"].endswith(".tar.gz") or f["filename"].endswith(".zip"):
55-
continue
5659
ver = f["filename"].split("-")[1]
5760
try:
5861
version = Version(ver)
59-
except Exception:
62+
except InvalidVersion as e:
63+
print(f"Error: '{ver}' is an invalid version for '{package}'. Reason: {e}")
6064
continue
65+
6166
if version.is_prerelease or version.micro != 0:
6267
continue
63-
release_date = pd.Timestamp(f["upload-time"]).tz_localize(None)
68+
69+
release_date = None
70+
for format in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]:
71+
try:
72+
release_date = datetime.strptime(f["upload-time"], format)
73+
except ValueError as e:
74+
print(f"Error parsing invalid date: {e}")
75+
6476
if not release_date:
6577
continue
78+
6679
file_date[version].append(release_date)
80+
6781
release_date = {v: min(file_date[v]) for v in file_date}
82+
6883
for ver, release_date in sorted(release_date.items()):
6984
drop_date = release_date + support_time
70-
if drop_date >= CUTOFF:
85+
if drop_date >= cutoff:
7186
releases[ver] = {
7287
"release_date": release_date,
7388
"drop_date": drop_date,
7489
}
90+
7591
return releases
7692

7793

7894
package_releases = {
7995
"python": {
8096
version: {
8197
"release_date": datetime.strptime(release_date, "%b %d, %Y"),
82-
"drop_date": datetime.strptime(release_date, "%b %d, %Y") + PLUS_36_MONTHS,
98+
"drop_date": datetime.strptime(release_date, "%b %d, %Y") + plus36,
8399
}
84-
for version, release_date in PY_RELEASES.items()
100+
for version, release_date in py_releases.items()
85101
}
86102
}
87-
package_releases |= {package: get_release_dates(package) for package in CORE_PACKAGES}
88-
# Filter all items whose drop_date are in the past
103+
104+
package_releases |= {package: get_release_dates(package) for package in core_packages}
105+
106+
# filter all items whose drop_date are in the past
89107
package_releases = {
90108
package: {
91109
version: dates
92110
for version, dates in releases.items()
93-
if dates["drop_date"] > CUTOFF
111+
if dates["drop_date"] > cutoff
94112
}
95113
for package, releases in package_releases.items()
96114
}
97115

116+
98117
# Save Gantt chart
118+
99119
print("Saving Mermaid chart to chart.md")
100120
with open("chart.md", "w") as fh:
101121
fh.write(
@@ -104,6 +124,7 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS):
104124
axisFormat %m / %Y
105125
title Support Window"""
106126
)
127+
107128
for name, releases in package_releases.items():
108129
fh.write(f"\n\nsection {name}")
109130
for version, dates in releases.items():
@@ -113,6 +134,7 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS):
113134
fh.write("\n")
114135

115136
# Print drop schedule
137+
116138
data = []
117139
for k, versions in package_releases.items():
118140
for v, dates in versions.items():
@@ -124,30 +146,15 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS):
124146
pd.to_datetime(dates["drop_date"]),
125147
)
126148
)
149+
127150
df = pd.DataFrame(data, columns=["package", "version", "release", "drop"])
151+
128152
df["quarter"] = df["drop"].dt.to_period("Q")
129-
df["new_min_version"] = (
130-
df[["package", "version", "quarter"]].groupby("package").shift(-1)["version"]
131-
)
132-
dq = df.set_index(["quarter", "package"]).sort_index().dropna()
133-
new_min_versions = (
134-
dq.groupby(["quarter", "package"]).agg({"new_min_version": "max"}).reset_index()
135-
)
136153

137-
# We want to build a dict with the structure [{start_date: timestamp, packages: {package: lower_bound}}]
138-
new_min_versions_list = []
139-
for q, packages in new_min_versions.groupby("quarter"):
140-
package_lower_bounds = {
141-
p: str(v) for p, v in packages.drop("quarter", axis=1).itertuples(index=False)
142-
}
143-
# jq is really insistent the Z should be there
144-
quarter_start_time_str = str(q.start_time.isoformat()) + "Z"
145-
new_min_versions_list.append(
146-
{"start_date": quarter_start_time_str, "packages": package_lower_bounds}
147-
)
148-
print("Saving drop schedule to schedule.json")
149-
with open("schedule.json", "w") as f:
150-
f.write(json.dumps(new_min_versions_list, sort_keys=True))
154+
dq = df.set_index(["quarter", "package"]).sort_index()
155+
156+
157+
print("Saving drop schedule to schedule.md")
151158

152159

153160
def pad_table(table):
@@ -165,6 +172,7 @@ def pad_table(table):
165172
line += f"| {str.ljust(entry, width)} "
166173
line += "|"
167174
padded_table.append(line)
175+
168176
return padded_table
169177

170178

@@ -184,6 +192,7 @@ def make_table(sub):
184192
else f"{rel_min.strftime('%b %Y')} and {rel_max.strftime('%b %Y')}"
185193
)
186194
table.append(f"|{package:<15}|{version_range:<19}|released {rel_range}|")
195+
187196
return pad_table(table)
188197

189198

@@ -195,13 +204,13 @@ def make_quarter(quarter, dq):
195204
return "\n".join(table)
196205

197206

198-
print("Saving drop schedule to schedule.md")
199207
with open("schedule.md", "w") as fh:
200-
# We collect packages 6 month in the past, and drop the first quarter
208+
# we collect package 6 month in the past, and drop the first quarter
201209
# as we might have filtered some of the packages out depending on
202210
# when we ran the script.
203211
tb = []
204212
for quarter in list(sorted(set(dq.index.get_level_values(0))))[1:]:
205213
tb.append(make_quarter(quarter, dq))
214+
206215
fh.write("\n\n".join(tb))
207216
fh.write("\n")

0 commit comments

Comments
 (0)