diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index b32cafa..6b82622 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -193,24 +193,36 @@ jobs: API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} TITLE: "Incompatible with upcoming TrueNAS: ${{ steps.report.outputs.refs }}" run: | - body="$(jq -Rs . < /tmp/issue.md)" - title="$(printf '%s' "$TITLE" | jq -Rs .)" + # python3, not jq: jq is not guaranteed on a self-hosted runner, and a bug + # report that dies on a missing tool is a warning system that does not warn. + python3 - <<'PY' + import json, os, urllib.error, urllib.request - # Same title => same issue. Comment on it instead of filing a new one. - number="$(curl -sf -H "Authorization: token $TOKEN" \ - "$API/issues?state=all&type=issues" \ - | jq -r --arg t "$TITLE" '.[] | select(.title == $t) | .number' | head -1)" + api, token, title = os.environ["API"], os.environ["TOKEN"], os.environ["TITLE"] + with open("/tmp/issue.md", encoding="utf-8") as fh: + body = fh.read() + headers = {"Authorization": f"token {token}", + "Content-Type": "application/json"} - if [ -n "$number" ]; then - curl -sS -X POST "$API/issues/$number/comments" \ - -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d "$(printf '{"body":%s}' "$body")" -o /dev/null -w 'comment -> %{http_code}\n' - curl -sS -X PATCH "$API/issues/$number" \ - -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d '{"state":"open"}' -o /dev/null -w 'reopen -> %{http_code}\n' - else - curl -sS -X POST "$API/issues" \ - -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d "$(printf '{"title":%s,"body":%s}' "$title" "$body")" \ - -o /dev/null -w 'create -> %{http_code}\n' - fi + def call(url, method, data=None): + req = urllib.request.Request( + url, method=method, headers=headers, + data=json.dumps(data).encode() if data else None) + with urllib.request.urlopen(req) as r: # noqa: S310 + return json.load(r) if r.length != 0 else {} + + # Same title => same issue. Comment on it rather than filing a new one every + # morning: a bot that duplicates itself daily gets muted, and then it is not + # a warning system any more. + issues = call(f"{api}/issues?state=all&type=issues", "GET") + match = next((i for i in issues if i["title"] == title), None) + + if match: + n = match["number"] + call(f"{api}/issues/{n}/comments", "POST", {"body": body}) + call(f"{api}/issues/{n}", "PATCH", {"state": "open"}) + print(f"commented on and reopened issue #{n}") + else: + made = call(f"{api}/issues", "POST", {"title": title, "body": body}) + print(f"filed issue #{made['number']}") + PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 612389b..e9cb0d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,27 +164,45 @@ jobs: *-rc*|*-beta*|*-alpha*) prerelease=true ;; esac - # jq -Rs so the notes are JSON-encoded properly: the changelog is full of - # quotes, backticks and newlines, and hand-built JSON would mangle them. - body="$(jq -Rs . < /tmp/notes.md)" - payload="$(printf '{"tag_name":%s,"name":%s,"body":%s,"prerelease":%s}' \ - "$(printf '%s' "$TAG" | jq -Rs .)" \ - "$(printf '%s' "$TAG" | jq -Rs .)" \ - "$body" "$prerelease")" + # python3, not jq. The changelog is full of quotes, backticks and newlines, + # so the body must be properly JSON-encoded -- but `jq` is not guaranteed on + # a self-hosted Gitea runner, and a publish step that dies on a missing tool + # leaves a tag with no release behind it. python3 is guaranteed: setup-python + # ran above. + python3 - "$TAG" "$API" "$TOKEN" "$prerelease" <<'PY' + import json, sys, urllib.error, urllib.request - existing="$(curl -sf -H "Authorization: token $TOKEN" \ - "$API/releases/tags/$TAG" 2>/dev/null || true)" + tag, api, token, prerelease = sys.argv[1:5] + with open("/tmp/notes.md", encoding="utf-8") as fh: + body = fh.read() - if [ -n "$existing" ]; then - id="$(printf '%s' "$existing" | jq -r .id)" - echo "Release $TAG exists (id=$id) — updating notes." - curl -sS -X PATCH "$API/releases/$id" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$payload" -o /dev/null -w 'PATCH -> %{http_code}\n' - else - curl -sS -X POST "$API/releases" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$payload" -o /dev/null -w 'POST -> %{http_code}\n' - fi + payload = { + "tag_name": tag, "name": tag, "body": body, + "prerelease": prerelease == "true", + } + headers = { + "Authorization": f"token {token}", + "Content-Type": "application/json", + } + + def call(url, method, data=None): + req = urllib.request.Request( + url, method=method, headers=headers, + data=json.dumps(data).encode() if data else None) + with urllib.request.urlopen(req) as r: # noqa: S310 + return r.status, json.load(r) if r.length != 0 else {} + + try: + _, existing = call(f"{api}/releases/tags/{tag}", "GET") + except urllib.error.HTTPError as e: + if e.code != 404: + raise + existing = None + + if existing: + status, _ = call(f"{api}/releases/{existing['id']}", "PATCH", payload) + print(f"updated release {tag} -> {status}") + else: + status, _ = call(f"{api}/releases", "POST", payload) + print(f"created release {tag} (prerelease={payload['prerelease']}) -> {status}") + PY diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index a818c5d..b4b33b2 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -205,3 +205,31 @@ class TestSignificance: assert version_tuple("0.4.2") > version_tuple("0.4.1") # Pre-release suffixes are dropped, not ranked above the release. assert version_tuple("v0.5.0-rc1") == version_tuple("v0.5.0") + + +class TestCandidateNotesResolveToTheBaseVersion: + """`notes v0.6.0-rc1` must return v0.6.0's section. + + A candidate ships the same code as the release it is a candidate for, and the + CHANGELOG only ever has the one section. Without this, the release workflow cut + v0.6.0-rc1, passed every gate, and then died extracting the body -- so the tag + existed but nothing was ever published. Caught in an rc, which is the entire + point of having them. + """ + + CHANGELOG = "# C\n\n## v0.6.0 — 2026-07-13\n\n### Added\n- the thing\n\n## v0.5.1 — 2026-07-13\n\n- older\n" + + def test_an_rc_resolves_to_its_base_version(self): + body = extract_notes(self.CHANGELOG, "v0.6.0-rc1") + assert "the thing" in body + assert "older" not in body + + def test_rc10_too(self): + assert "the thing" in extract_notes(self.CHANGELOG, "v0.6.0-rc10") + + def test_the_plain_version_still_works(self): + assert "the thing" in extract_notes(self.CHANGELOG, "v0.6.0") + + def test_a_genuinely_missing_section_still_raises(self): + with pytest.raises(KeyError): + extract_notes(self.CHANGELOG, "v9.9.9-rc1") diff --git a/tools/release_notes.py b/tools/release_notes.py index f76391b..c797081 100644 --- a/tools/release_notes.py +++ b/tools/release_notes.py @@ -107,10 +107,15 @@ def changelog_versions(text: str) -> list[str]: def extract_notes(text: str, version: str) -> str: """The body of one version's section, without its heading. + A release candidate resolves to its BASE version: v0.6.0-rc2 ships the same code + as v0.6.0 and therefore the same notes, and the CHANGELOG only ever has the one + section. Without this, the release workflow cut the tag, passed every gate, and + then died extracting the body -- so the candidate existed but was never published. + Raises KeyError if the version has no section -- a release with an empty or wrong body is worse than a failed release. """ - want = normalise(version) + want = base_version(version) lines = text.splitlines() start = None