Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
*.html
__pycache__/
53 changes: 53 additions & 0 deletions .github/RELEASING.md
Original file line number Diff line number Diff line change
@@ -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<version>` 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.
101 changes: 101 additions & 0 deletions .github/post-release-triage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Post-CRAN triage: 0.3.19

Reviewed on 2026-09-14 against the released features, current source and tests,
and the open GitHub issues. The four items below were closed on that date;
the remaining items retain the follow-up requirements documented here.

## Closed as implemented or superseded

| Item | Evidence | Disposition |
| --- | --- | --- |
| [#94: extract/inline refactoring](https://github.com/REditorSupport/languageserver/issues/94) | [PR #753](https://github.com/REditorSupport/languageserver/pull/753), released in 0.3.19, adds extract-variable, extract-function, and single-use local-variable inlining. [Refactoring tests](../tests/testthat/test-refactor.R) cover free variables, live-out bindings, edit versions, unsafe contexts, and literate-cell boundaries. | Closed as implemented. Applicability remains conservative; arbitrary R transformations are not promised. This reconciles GitHub with the existing NEWS entry. |
| [#307: semantic tokens](https://github.com/REditorSupport/languageserver/issues/307) | Semantic tokens shipped in 0.3.17; 0.3.19 adds cached tokens and deltas. [Provider tests](../tests/testthat/test-semantic-tokens.R) cover full, range, delta reconstruction, UTF-16 positions, and function declarations. | Closed as implemented; specific semantic-token bugs remain in their own issues. |
| [#396: diagnostic code actions](https://github.com/REditorSupport/languageserver/issues/396) | [PR #746](https://github.com/REditorSupport/languageserver/pull/746), released in 0.3.19, provides preferred fixes for common linters and conflict-aware `source.fixAll`. [Code-action tests](../tests/testthat/test-code-action.R) cover assignment, commas, logical constants, layout, suppression, and overlapping fixes. | Closed as implemented. An upstream lintr edit API would be a separate integration enhancement. |
| [Draft PR #397](https://github.com/REditorSupport/languageserver/pull/397) | Its initial diagnostic-fix implementation is superseded by merged PR #746. | Closed as superseded; the original proposal remains available in the PR history. |

## Keep open or close after follow-up

### #621: multi-root workspaces — retain the profile-isolation requirement

[PR #719](https://github.com/REditorSupport/languageserver/pull/719), released
in 0.3.18, implements workspace-folder routing. The
[workspace tests](../tests/testthat/test-workspace.R) verify symbol lookup,
dynamic folder addition, and fallback-workspace cleanup. This satisfies much
of [#621](https://github.com/REditorSupport/languageserver/issues/621).

The issue also asks for each folder's `.Rprofile` and settings to be respected.
[`workspace_startup_packages()`](../R/workspace.R) currently shares one cached
startup-package list across workspaces, and [task workers](../R/task.R) start
with common process/profile options rather than a folder-specific startup
directory. Keep the issue open for this remaining work. Acceptance should use
two folders with different profiles and library paths and verify that package
resolution and diagnostics remain isolated, including after folder removal
and addition. Folder-symbol tests alone do not establish profile isolation.

### #726: diagnostics configuration — distinguish the original fix from new cases

The original [#726 report](https://github.com/REditorSupport/languageserver/issues/726)
used languageserver 0.3.16 with lintr 3.3.0.1. Explicit `parse_settings = TRUE`
shipped in 0.3.17 through
[PR #706](https://github.com/REditorSupport/languageserver/pull/706), as noted
in the existing issue reply. The stabilization changes additionally preserve
the path for nested configuration and exclusions in saved and new `.R`,
`.Rmd`, and `.qmd` buffers. The
[configuration regression tests](../tests/testthat/test-diagnostics-configuration.R)
exercise these cases and restore lintr settings after failures.

Close with the original fix and the stabilization release version once these
additional changes land. If the reporter still reproduces the saved `.R`
case, request their `.lintr`, exact paths, and installed package versions;
do not assume every configuration report has the same cause.

### #735: Quarto semantic tokens — await editor confirmation

[PR #752](https://github.com/REditorSupport/languageserver/pull/752) scopes
providers to literate R cells. Parsing the exact document from
[#735](https://github.com/REditorSupport/languageserver/issues/735) now produces
semantic tokens only on line 19 (`print("hello")`, using one-based lines);
YAML, prose, math, and cell metadata produce none. The
[literate-document tests](../tests/testthat/test-literate.R) also exercise
region isolation and Quarto requests through the server.

Keep open pending confirmation in Cursor/VS Code with the Quarto extension
and languageserver 0.3.19 or later. A maintainer already requested confirmation
of #752 on 2026-08-24; avoid posting a duplicate request. If it persists, obtain
the extension versions and actual semantic-token response to distinguish
server output from the client's embedded-document mapping.

### #731 and #687: startup and completion performance — retain reproducible workloads

[#731](https://github.com/REditorSupport/languageserver/issues/731) reports
a worker startup timeout with Windows, R 4.6.0, and an `renv` profile. Workers
now start asynchronously and task failures are contained, but these changes
do not prove that slow `renv` activation completes successfully. Retain the
issue until a minimal project is exercised on that platform, recording cold
startup time and whether worker readiness and diagnostics eventually succeed.

[#687](https://github.com/REditorSupport/languageserver/issues/687) reports
repeated 20-second completion stalls on a remote cluster. 0.3.19 improves
package-resolution reuse, caching, and scheduling, but local warm-provider
timings cannot establish a fix for remote filesystem and package startup
costs. Use the [typing benchmark](../inst/benchmarks/README.md) with cold-start
and diagnostics-enabled measurements. Ask for a minimal package list, remote
filesystem details, and timings before and after idle periods if the problem
persists; close only after reproducing the workload or receiving confirmation.

### PR #665: formatting-style documentation — review independently

[PR #665](https://github.com/REditorSupport/languageserver/pull/665) adds a
`styler.equals::equals_style()` example for
[#664](https://github.com/REditorSupport/languageserver/issues/664). It does
not repair the Neovim setup. Keep it separate from the current README fixes
and verify the suggested package API and intended style before merging it.
The existing README already demonstrates disabling the assignment-operator
rewrite directly; avoid presenting the new dependency as required.

## Applying this review

Track the remaining items by their concrete next action: profile isolation,
regression verification, or an environment-specific reproduction. Include the shipped version and
linked tests when resolving a feature request. Do not close a reproducible
bug merely because a related feature or performance improvement shipped.
192 changes: 192 additions & 0 deletions .github/scripts/cran_release.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading