From c9b604c1624c4b0837afcdbe626802d460d5df9f Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Tue, 15 Sep 2026 07:08:54 +0800 Subject: [PATCH] ci: publish CRAN releases with verified tags and modern authentication --- .github/.gitignore | 1 + .github/RELEASING.md | 53 +++++++ .github/scripts/cran_release.py | 192 +++++++++++++++++++++++ .github/scripts/test_cran_release.py | 225 +++++++++++++++++++++++++++ .github/workflows/tagbot.yml | 49 ++++-- DESCRIPTION | 3 +- NEWS.md | 5 + 7 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 .github/RELEASING.md create mode 100644 .github/scripts/cran_release.py create mode 100644 .github/scripts/test_cran_release.py diff --git a/.github/.gitignore b/.github/.gitignore index 2d19fc76..e32f10ff 100644 --- a/.github/.gitignore +++ b/.github/.gitignore @@ -1 +1,2 @@ *.html +__pycache__/ diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 00000000..5335d1de --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,53 @@ +# CRAN and GitHub releases + +Finalize the release notes in `NEWS.md` and set the release `Version` in +`DESCRIPTION` when the release is ready. Merge that release to the default branch +before submitting to CRAN. After release, bump `Version` to a development version +and remove `Date`; start a new development section in `NEWS.md`. + +The **CRAN release** workflow checks CRAN's source package index every six hours. +It uses the first commit on the default branch whose committed `DESCRIPTION` +matches CRAN's package and version. This is normally the release merge commit. +It takes release notes from that commit's matching `NEWS.md` section, creates +`v` at that exact commit, and publishes a GitHub release. Later changes +to `DESCRIPTION`, `NEWS.md`, or other files cannot change the selected source. + +The version bump must identify the finalized release. If additional release +fixes were made after that version first entered the default branch, review the +source commit manually before publication. The workflow deliberately fails when +an existing tag points elsewhere; it never moves tags or rewrites releases. +Existing drafts and prereleases also require manual review. A failed run that +created the correct tag can safely be rerun to finish publishing its release. + +Use **Actions → CRAN release → Run workflow** to preview the selected commit and +release notes. `dry_run` is enabled by default. Uncheck it to publish immediately +after reviewing the preview. The workflow always checks out the default branch; +the script also reads GitHub's current default-branch commit before selecting +release history, including when invoked from a local feature branch. + +The script requires Python 3 and Git, without third-party Python or R packages. +It uses the built-in `GITHUB_TOKEN` through `GH_TOKEN`; no personal token is +needed. Only the publication job has `contents: write`. Publications run one at +a time. Pull requests affecting the workflow or script run offline safety tests +with read-only permissions. + +For local review, first fetch the default branch's full history. An authenticated +shell with `GH_TOKEN` or `GITHUB_TOKEN` can then preview the live CRAN/GitHub state: + +```sh +python3 .github/scripts/cran_release.py --repository REditorSupport/languageserver +``` + +Adding `--publish` creates the tag and release. It requires a token, the live +CRAN index, and access to GitHub. No token contents are printed. A complete +offline preview instead uses the local `HEAD` and an uncompressed CRAN-style +`PACKAGES` file containing `Package:` and `Version:` fields: + +```sh +python3 .github/scripts/cran_release.py --cran-index /tmp/PACKAGES --plan-only +python3 -m unittest discover -s .github/scripts -p 'test_cran_release.py' -v +``` + +Offline inputs cannot be combined with `--publish`. If the current remote default +branch commit is missing locally, fetch it and retry; the script will not fall +back to a potentially stale local branch. diff --git a/.github/scripts/cran_release.py b/.github/scripts/cran_release.py new file mode 100644 index 00000000..f359b03b --- /dev/null +++ b/.github/scripts/cran_release.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Publish the current CRAN release from its original default-branch commit. + +Uses only Python's standard library and git. The default is a read-only preview; +--publish explicitly enables GitHub writes. GH_TOKEN accepts the workflow token +without imposing personal-access-token format restrictions. +""" + +import argparse +import gzip +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +CRAN_INDEX = "https://cran.r-project.org/src/contrib/PACKAGES.gz" + + +def dcf_records(text): + """Read the Package/Version fields from CRAN's DCF index or DESCRIPTION.""" + for paragraph in re.split(r"\n\s*\n", text.strip()): + fields = {} + for line in paragraph.splitlines(): + if line and not line[0].isspace() and ":" in line: + key, value = line.split(":", 1) + fields[key] = value.strip() + if fields: + yield fields + + +def cran_version(package, index=None): + if index is None: + with urlopen(CRAN_INDEX, timeout=30) as response: + index = gzip.decompress(response.read()).decode("utf-8") + versions = [record.get("Version", "") for record in dcf_records(index) + if record.get("Package") == package] + if len(versions) != 1 or not re.fullmatch(r"[0-9]+(?:[.-][0-9]+)+", versions[0]): + raise RuntimeError(f"CRAN must list exactly one valid version of {package}") + return versions[0] + + +def git(*args, cwd=None): + return subprocess.check_output(["git", *args], cwd=cwd, text=True).strip() + + +def release_commit(package, version, cwd=None, ref="HEAD"): + """Find when the release version first entered the default branch. + + Walking only DESCRIPTION changes, oldest first, avoids tagging subsequent + development commits even if maintainers have not bumped Version yet. The + workflow checks out the default branch with its full history, and live runs + resolve ref from GitHub's current default branch rather than the local HEAD. + """ + commits = git("log", "--first-parent", "--reverse", "--format=%H", + ref, "--", "DESCRIPTION", cwd=cwd).splitlines() + for commit in commits: + description = git("show", f"{commit}:DESCRIPTION", cwd=cwd) + fields = next(dcf_records(description)) + if fields.get("Package") == package and fields.get("Version") == version: + return commit + raise RuntimeError(f"No committed DESCRIPTION matches CRAN's {package} {version}") + + +def release_notes(package, version, commit, cwd=None): + news = git("show", f"{commit}:NEWS.md", cwd=cwd) + heading = re.compile(r"^# " + re.escape(package) + r" " + re.escape(version) + r"\s*$") + lines = news.splitlines() + for start, line in enumerate(lines): + if heading.fullmatch(line): + end = next((i for i in range(start + 1, len(lines)) + if lines[i].startswith("# ")), len(lines)) + notes = "\n".join(lines[start + 1:end]).strip() + if notes: + return notes + "\n" + raise RuntimeError(f"Missing nonempty NEWS.md section for {package} {version} at {commit}") + + +class GitHub: + def __init__(self, repository, token=None): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise RuntimeError("Specify a GitHub repository as owner/name") + self.base = f"https://api.github.com/repos/{repository}" + self.token = token + + def request(self, method, path, data=None, allow_missing=False): + headers = {"Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "languageserver-cran-release"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + payload = None if data is None else json.dumps(data).encode("utf-8") + if payload is not None: + headers["Content-Type"] = "application/json" + request = Request(self.base + path, data=payload, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + return json.load(response) + except HTTPError as error: + if allow_missing and error.code == 404: + return None + raise RuntimeError(f"GitHub {method} {path} returned HTTP {error.code}") from error + + +def default_branch_head(api): + branch = api.request("GET", "")["default_branch"] + obj = api.request("GET", f"/git/ref/heads/{quote(branch, safe='')}")["object"] + if obj["type"] != "commit" or not re.fullmatch(r"[0-9a-f]{40}", obj["sha"]): + raise RuntimeError("GitHub's default branch must resolve to a commit") + return obj["sha"] + + +def tag_commit(api, tag): + ref = api.request("GET", f"/git/ref/tags/{tag}", allow_missing=True) + if ref is None: + return None + obj = ref["object"] + # Annotated tags may in turn reference another annotated tag. + for _ in range(10): + if obj["type"] == "commit": + return obj["sha"] + if obj["type"] != "tag": + break + obj = api.request("GET", f"/git/tags/{obj['sha']}")["object"] + raise RuntimeError(f"Tag {tag} does not resolve to a commit") + + +def publish_release(api, version, commit, notes, publish=False): + tag = f"v{version}" + target = tag_commit(api, tag) + if target is not None and target != commit: + raise RuntimeError(f"Refusing to move {tag}: points to {target}, expected {commit}") + release = api.request("GET", f"/releases/tags/{tag}", allow_missing=True) + if release is not None: + if target is None or release["draft"] or release["prerelease"]: + raise RuntimeError(f"Existing release {tag} needs manual review") + print(f"Already published: {release['html_url']} ({commit})") + return release + print(f"{'Publish' if publish else 'Preview'}: {tag} at {commit}") + print(notes) + if not publish: + return None + if target is None: + api.request("POST", "/git/refs", {"ref": f"refs/tags/{tag}", "sha": commit}) + # Check again before publishing: never silently release a conflicting tag. + if tag_commit(api, tag) != commit: + raise RuntimeError(f"Tag {tag} changed before release publication") + release = api.request("POST", "/releases", { + "tag_name": tag, "target_commitish": commit, "name": tag, + "body": notes, "draft": False, "prerelease": False, + }) + print(f"Published: {release['html_url']}") + return release + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY")) + parser.add_argument("--cran-index", type=Path, help="Read a local PACKAGES file for offline review") + parser.add_argument("--plan-only", action="store_true", help="Skip GitHub reads for offline review") + parser.add_argument("--publish", action="store_true", help="Create the GitHub tag and release") + args = parser.parse_args() + if args.publish and (args.plan_only or args.cran_index): + parser.error("--publish requires the live CRAN index and GitHub checks") + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if args.publish and not token: + parser.error("--publish requires GH_TOKEN or GITHUB_TOKEN") + if not args.plan_only and not args.repository: + parser.error("--repository or GITHUB_REPOSITORY is required") + api = None if args.plan_only else GitHub(args.repository, token) + ref = "HEAD" if args.plan_only else default_branch_head(api) + package = next(dcf_records(git("show", f"{ref}:DESCRIPTION")))["Package"] + index = args.cran_index.read_text() if args.cran_index else None + version = cran_version(package, index) + commit = release_commit(package, version, ref=ref) + notes = release_notes(package, version, commit) + if args.plan_only: + print(f"Preview: v{version} at {commit}\n\n{notes}") + return + publish_release(api, version, commit, notes, args.publish) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError, subprocess.CalledProcessError) as error: + sys.exit(str(error)) diff --git a/.github/scripts/test_cran_release.py b/.github/scripts/test_cran_release.py new file mode 100644 index 00000000..7a877667 --- /dev/null +++ b/.github/scripts/test_cran_release.py @@ -0,0 +1,225 @@ +"""Offline tests of release selection, permissions, and recovery after failure.""" + +import contextlib +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, call, patch +from urllib.error import HTTPError + +import cran_release as release + + +class HistoryTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.repo = Path(self.directory.name) + release.git("init", "-q", "-b", "main", cwd=self.repo) + release.git("config", "user.name", "Release test", cwd=self.repo) + release.git("config", "user.email", "release-test@example.invalid", cwd=self.repo) + release.git("config", "commit.gpgsign", "false", cwd=self.repo) + release.git("config", "core.hooksPath", "/dev/null", cwd=self.repo) + self.commit("0.3.18", "Old release") + + def commit(self, version, message): + (self.repo / "DESCRIPTION").write_text( + f"Package: languageserver\nVersion: {version}\nTitle: {message}\n") + (self.repo / "NEWS.md").write_text( + "# languageserver 0.3.19\n\n- Released fix.\n\n" + "# languageserver 0.3.18\n\n- Older fix.\n") + release.git("add", ".", cwd=self.repo) + release.git("commit", "-qm", message, cwd=self.repo) + return release.git("rev-parse", "HEAD", cwd=self.repo) + + def test_selects_release_before_unbumped_and_bumped_development(self): + expected = self.commit("0.3.19", "Release") + self.commit("0.3.19", "Development without version bump") + self.commit("0.3.19.9000", "Development version") + (self.repo / "DESCRIPTION").write_text("Package: languageserver\nVersion: 9.9.9\n") + self.assertEqual(release.release_commit("languageserver", "0.3.19", self.repo), expected) + self.assertEqual(release.release_notes("languageserver", "0.3.19", expected, self.repo), + "- Released fix.\n") + + def test_selects_merge_where_version_enters_default_branch(self): + release.git("checkout", "-qb", "release", cwd=self.repo) + self.commit("0.3.19", "Release branch") + release.git("checkout", "-q", "main", cwd=self.repo) + release.git("merge", "--no-ff", "-qm", "Merge release", "release", cwd=self.repo) + expected = release.git("rev-parse", "HEAD", cwd=self.repo) + self.commit("0.3.19.9000", "Development version") + self.assertEqual(release.release_commit("languageserver", "0.3.19", self.repo), expected) + + def test_unmerged_and_prefix_versions_do_not_match(self): + release.git("checkout", "-qb", "release", cwd=self.repo) + self.commit("0.3.19", "Unmerged release") + release.git("checkout", "-q", "main", cwd=self.repo) + self.commit("0.3.19.9000", "Development version") + with self.assertRaisesRegex(RuntimeError, "No committed DESCRIPTION"): + release.release_commit("languageserver", "0.3.19", self.repo) + + def test_explicit_default_branch_ref_excludes_local_feature_commits(self): + default_head = release.git("rev-parse", "HEAD", cwd=self.repo) + self.commit("0.3.19", "Unmerged feature version") + with self.assertRaisesRegex(RuntimeError, "No committed DESCRIPTION"): + release.release_commit("languageserver", "0.3.19", self.repo, ref=default_head) + + def test_missing_release_notes_abort(self): + expected = self.commit("0.3.20", "Release with missing notes") + with self.assertRaisesRegex(RuntimeError, "Missing nonempty NEWS"): + release.release_notes("languageserver", "0.3.20", expected, self.repo) + + +class FakeGitHub: + def __init__(self, target=None, published=False, annotated=False): + self.target = target + self.annotated = annotated + self.release = ({"html_url": "https://example.invalid/v0.3.19", + "draft": False, "prerelease": False} if published else None) + self.writes = [] + self.fail_release = False + + def request(self, method, path, data=None, allow_missing=False): + if method == "GET" and path == "/git/ref/tags/v0.3.19": + if self.target is None: + return None + return {"object": {"type": "tag" if self.annotated else "commit", "sha": self.target}} + if method == "GET" and path == f"/git/tags/{self.target}": + return {"object": {"type": "commit", "sha": self.target}} + if method == "GET" and path == "/releases/tags/v0.3.19": + return self.release + if method == "POST": + self.writes.append((path, data)) + if path == "/git/refs": + self.target = data["sha"] + return {} + if path == "/releases": + if self.fail_release: + raise RuntimeError("GitHub temporary error") + self.release = {"html_url": "https://example.invalid/v0.3.19", + "draft": data["draft"], "prerelease": data["prerelease"]} + return self.release + raise AssertionError(f"Unexpected API call: {method} {path}") + + +class PublishTests(unittest.TestCase): + def setUp(self): + self.output = contextlib.redirect_stdout(io.StringIO()) + self.output.__enter__() + self.addCleanup(self.output.__exit__, None, None, None) + + def publish(self, api, publish=True): + return release.publish_release(api, "0.3.19", "release-commit", "Release notes\n", publish) + + def test_preview_makes_no_writes(self): + api = FakeGitHub() + self.publish(api, publish=False) + self.assertEqual(api.writes, []) + + def test_publish_uses_exact_commit_and_notes_and_is_idempotent(self): + api = FakeGitHub() + self.publish(api) + self.publish(api) + self.assertEqual(api.writes, [ + ("/git/refs", {"ref": "refs/tags/v0.3.19", "sha": "release-commit"}), + ("/releases", {"tag_name": "v0.3.19", "target_commitish": "release-commit", + "name": "v0.3.19", "body": "Release notes\n", + "draft": False, "prerelease": False}), + ]) + + def test_existing_lightweight_and_annotated_tags_are_reused(self): + for annotated in (False, True): + with self.subTest(annotated=annotated): + api = FakeGitHub(target="release-commit", annotated=annotated) + self.publish(api) + self.assertEqual([path for path, _ in api.writes], ["/releases"]) + + def test_conflicting_tag_is_never_modified_even_with_existing_release(self): + for published in (False, True): + with self.subTest(published=published): + api = FakeGitHub(target="wrong-commit", published=published) + with self.assertRaisesRegex(RuntimeError, "Refusing to move"): + self.publish(api) + self.assertEqual(api.writes, []) + + def test_existing_draft_prerelease_or_missing_tag_requires_review(self): + for state in ("draft", "prerelease", "missing-tag"): + with self.subTest(state=state): + api = FakeGitHub(target="release-commit", published=True) + if state == "missing-tag": + api.target = None + else: + api.release[state] = True + with self.assertRaisesRegex(RuntimeError, "manual review"): + self.publish(api) + self.assertEqual(api.writes, []) + + def test_recovers_from_release_failure_after_tag_creation(self): + api = FakeGitHub() + api.fail_release = True + with self.assertRaisesRegex(RuntimeError, "temporary error"): + self.publish(api) + api.fail_release = False + self.publish(api) + self.assertEqual([path for path, _ in api.writes].count("/git/refs"), 1) + self.assertIsNotNone(api.release) + + def test_changed_tag_aborts_publication(self): + api = FakeGitHub() + with patch.object(release, "tag_commit", side_effect=[None, "another-commit"]): + with self.assertRaisesRegex(RuntimeError, "changed before release"): + self.publish(api) + self.assertEqual([path for path, _ in api.writes], ["/git/refs"]) + + +class InputAndAPITests(unittest.TestCase): + def test_cran_index_requires_unique_exact_package(self): + index = "Package: other\nVersion: 9.0\n\nPackage: languageserver\nVersion: 0.3.19\n" + self.assertEqual(release.cran_version("languageserver", index), "0.3.19") + for invalid in ("", index + "\n" + index, "Package: languageserver\nVersion: bad", + "Package: languageserver"): + with self.subTest(index=invalid): + with self.assertRaisesRegex(RuntimeError, "exactly one valid version"): + release.cran_version("languageserver", invalid) + + def test_live_runs_resolve_the_remote_default_branch_commit(self): + api = Mock() + api.request.side_effect = [{"default_branch": "main"}, + {"object": {"type": "commit", "sha": "a" * 40}}] + self.assertEqual(release.default_branch_head(api), "a" * 40) + self.assertEqual(api.request.call_args_list, + [call("GET", ""), call("GET", "/git/ref/heads/main")]) + + def test_workflow_token_is_sent_as_bearer_without_pat_validation(self): + api = release.GitHub("owner/repo", "ghs_workflow-token") + with patch.object(release, "urlopen", return_value=io.BytesIO(b'{}')) as open_url: + api.request("GET", "/releases/tags/v0.3.19") + request = open_url.call_args.args[0] + self.assertEqual(request.get_header("Authorization"), "Bearer ghs_workflow-token") + self.assertEqual(request.get_header("X-github-api-version"), "2022-11-28") + + def test_only_404_is_treated_as_absent(self): + api = release.GitHub("owner/repo") + for status in (401, 403, 404, 429, 500): + with self.subTest(status=status): + error = HTTPError("https://example.invalid", status, "error", {}, None) + with patch.object(release, "urlopen", side_effect=error): + if status == 404: + self.assertIsNone(api.request("GET", "/releases/tags/v0.3.19", allow_missing=True)) + else: + with self.assertRaisesRegex(RuntimeError, f"HTTP {status}"): + api.request("GET", "/releases/tags/v0.3.19", allow_missing=True) + + def test_publish_cannot_use_offline_inputs(self): + for option in ("--plan-only", "--cran-index=PACKAGES"): + with self.subTest(option=option): + with patch("sys.argv", ["cran_release.py", "--publish", option]): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as error: + release.main() + self.assertEqual(error.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/tagbot.yml b/.github/workflows/tagbot.yml index 7a4407af..20faf2e1 100644 --- a/.github/workflows/tagbot.yml +++ b/.github/workflows/tagbot.yml @@ -1,21 +1,52 @@ -name: TagBot +name: CRAN release on: schedule: - # every six hour - - cron: 0 */6 * * * + - cron: '0 */6 * * *' + workflow_dispatch: + inputs: + dry_run: + description: Preview the release without creating a tag or release + type: boolean + default: true + pull_request: + paths: + - '.github/workflows/tagbot.yml' + - '.github/scripts/cran_release.py' + - '.github/scripts/test_cran_release.py' + +permissions: + contents: read jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - run: python3 -m unittest discover -s .github/scripts -p 'test_cran_release.py' -v + publish-github-release: + needs: test + if: github.event_name != 'pull_request' && github.repository == 'REditorSupport/languageserver' runs-on: ubuntu-latest - container: rtagbot/tagbot:latest + permissions: + contents: write + concurrency: + group: cran-release-publish + cancel-in-progress: false steps: - uses: actions/checkout@v7 with: + ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 - - name: check and publish release - run: | - tagbot::publish_release() - shell: Rscript {0} + persist-credentials: false + - name: Check CRAN and publish the matching release env: - GITHUB_PAT: ${{secrets.GITHUB_TOKEN}} + GH_TOKEN: ${{ github.token }} + PUBLISH: ${{ github.event_name == 'schedule' || !inputs.dry_run }} + run: | + args=() + if [[ "$PUBLISH" == true ]]; then args+=(--publish); fi + python3 .github/scripts/cran_release.py "${args[@]}" diff --git a/DESCRIPTION b/DESCRIPTION index f3a3bad3..e4b8428a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,7 @@ Type: Package Package: languageserver Title: Language Server Protocol -Version: 0.3.19 -Date: 2026-09-11 +Version: 0.3.19.9000 Authors@R: c(person(given = "Randy", family = "Lai", diff --git a/NEWS.md b/NEWS.md index 75aee377..330517e7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,8 @@ +# languageserver (development version) + +- Repair CRAN-to-GitHub release automation with authenticated, idempotent + publication from the finalized release commit. + # languageserver 0.3.19 - Add a shared Quarto/R Markdown region model with `.qmd` and Quarto language