diff --git a/.github/scripts/filter-app-news.R b/.github/scripts/filter-app-news.R new file mode 100644 index 0000000..8968adb --- /dev/null +++ b/.github/scripts/filter-app-news.R @@ -0,0 +1,78 @@ +# Drop entries for app-only commits from NEWS.md. +# +# The release workflow runs this after autonewsmd writes NEWS.md. autonewsmd +# lists every conventional commit, but the Shiny app in app/ is a separate +# package with its own audience, so its changes do not belong in the singlehit +# release notes. An entry is dropped when its commit has the conventional scope +# `app` (for example `feat(app): ...`) or changed only files under app/. Entries +# whose commit cannot be inspected are kept. + +news_entry_sha <- function(entry) { + match <- regmatches(entry, regexpr("/tree/[0-9a-f]{40}", entry)) + if (length(match) == 0L) NA_character_ else sub("^/tree/", "", match[[1L]]) +} + +# The subject and changed paths of a commit, or NULL when git cannot show it. +inspect_commit <- function(sha) { + git <- function(...) { + out <- suppressWarnings(system2("git", c(...), stdout = TRUE, stderr = FALSE)) + if (is.null(attr(out, "status"))) out else NULL + } + subject <- git("show", "-s", "--format=%s", sha) + files <- git("show", "--no-commit-id", "--name-only", "--format=", sha) + if (is.null(subject) || is.null(files)) NULL else list(subject = subject[[1L]], files = files[nzchar(files)]) +} + +is_app_commit <- function(commit) { + if (is.null(commit)) { + return(FALSE) + } + scoped <- grepl("^[a-z]+\\(app\\)!?:", commit$subject) + only_app <- length(commit$files) > 0L && all(startsWith(commit$files, "app/")) + scoped || only_app +} + +# `inspect` maps a commit sha to its subject and files; a parameter so the +# filter can be tested without a repository. +filter_app_news <- function(lines, inspect = inspect_commit) { + keep <- rep(TRUE, length(lines)) + i <- 1L + while (i <= length(lines)) { + if (startsWith(lines[[i]], "- ")) { + end <- i + while (end < length(lines) && startsWith(lines[[end + 1L]], " ")) end <- end + 1L + sha <- news_entry_sha(paste(lines[i:end], collapse = "\n")) + if (!is.na(sha) && is_app_commit(inspect(sha))) keep[i:end] <- FALSE + i <- end + 1L + } else { + i <- i + 1L + } + } + lines <- lines[keep] + + # Remove a "#### Section" heading left with no entries, with the blank line + # that follows it. + is_heading <- grepl("^#{2,4} ", lines) + drop <- logical(length(lines)) + for (k in which(startsWith(lines, "#### "))) { + later <- which(is_heading & seq_along(lines) > k) + last <- if (length(later) > 0L) later[[1L]] - 1L else length(lines) + if (!any(startsWith(lines[seq.int(k + 1L, length.out = max(0L, last - k))], "- "))) { + drop[[k]] <- TRUE + after <- k + 1L + while (after <= last && !nzchar(trimws(lines[[after]]))) { + drop[[after]] <- TRUE + after <- after + 1L + } + } + } + lines[!drop] +} + +if (sys.nframe() == 0L) { + news <- readLines("NEWS.md", warn = FALSE, encoding = "UTF-8") + filtered <- filter_app_news(news) + removed <- sum(startsWith(news, "- ")) - sum(startsWith(filtered, "- ")) + writeLines(filtered, "NEWS.md", useBytes = TRUE) + message(sprintf("Removed %d app-only entries from NEWS.md.", removed)) +} diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 96a7fce..5917f95 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -43,10 +43,14 @@ The release caller respects that policy: publishes the release. It does not bump the version or regenerate NEWS. Keep `dry-run: true` for validation; it remains the manual default. PRs that -change `release.yaml` automatically validate preparation with dry-run enabled. +change `release.yaml` or `.github/scripts/filter-app-news.R` automatically validate +preparation with dry-run enabled. Fork PRs skip this job because they cannot receive `RELEASE_PAT`. The old `version_type` input is replaced by `version-type`; `none` is unsupported. -No missing NEWS postprocessor is referenced. +After autonewsmd writes NEWS.md, `.github/scripts/filter-app-news.R` removes entries for +commits scoped `app` or touching only `app/`, since the Shiny app is not part of +the package. NEWS.md is regenerated from history each release, so this also +removes those entries from earlier versions' sections. If branch push succeeds but PR creation fails, open a PR from the existing `release/vVERSION` branch. If publication fails after the tag push, rerun diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bab0ebe..f61f8db 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,6 +5,7 @@ on: branches: [main] paths: - .github/workflows/release.yaml + - .github/scripts/filter-app-news.R workflow_dispatch: inputs: release-mode: @@ -37,6 +38,7 @@ jobs: release-mode: ${{ inputs.release-mode || 'prepare-pr' }} version-type: ${{ inputs.version-type || 'patch' }} notes-mode: autonewsmd + news-postprocess-script: .github/scripts/filter-app-news.R dry-run: ${{ github.event_name == 'pull_request' || inputs.dry-run }} secrets: RELEASE_PAT: ${{ secrets.RELEASE_PAT }} diff --git a/tests/testthat/test-news-filter.R b/tests/testthat/test-news-filter.R new file mode 100644 index 0000000..d992490 --- /dev/null +++ b/tests/testthat/test-news-filter.R @@ -0,0 +1,62 @@ +# The release-notes filter lives with the CI scripts, which are not part of the +# built package, so these tests only run from a source checkout. +script <- test_path("..", "..", ".github", "scripts", "filter-app-news.R") +skip_if_not(file.exists(script), "release script is not in the built package") +filter_env <- new.env() +sys.source(script, envir = filter_env) + +entry <- function(text, sha) c(paste("-", text), sprintf(" ([%s](https://github.com/o/r/tree/%s))", substr(sha, 1, 7), sha)) +sha <- function(letter) strrep(letter, 40) + +commits <- list( + a = list(subject = "feat(app): add a tab", files = c("app/R/a.R", ".Rbuildignore")), + b = list(subject = "fix: change a thing", files = c("app/R/b.R", "app/DESCRIPTION")), + c = list(subject = "feat(bootstrap): add progress", files = c("R/bootstrap.R", "app/R/c.R")), + d = list(subject = "fix(bootstrap): handle limit", files = "R/bootstrap.R"), + e = list(subject = "docs: readme", files = "README.md") +) +inspect <- function(sha) commits[[substr(sha, 1, 1)]] +run <- function(lines) filter_env$filter_app_news(lines, inspect) + +test_that("entries are dropped for the app scope or app-only changes, and kept otherwise", { + news <- c( + "## singlehit v1.0.0 (2026-01-01)", "", "#### New features", "", + entry("add a tab", sha("a")), + entry("change a thing", sha("b")), + entry("add progress", sha("c")), + entry("handle limit", sha("d")), + "", "#### Docs", "", entry("readme", sha("e")) + ) + out <- run(news) + expect_false(any(grepl(sha("a"), out))) + expect_false(any(grepl(sha("b"), out))) + expect_true(any(grepl(sha("c"), out))) + expect_true(any(grepl(sha("d"), out))) + expect_true(any(grepl(sha("e"), out))) +}) + +test_that("a section left empty is removed along with its blank line", { + news <- c( + "## singlehit v1.0.0 (2026-01-01)", "", + "#### New features", "", entry("add a tab", sha("a")), "", + "#### Docs", "", entry("readme", sha("e")) + ) + out <- run(news) + expect_false("#### New features" %in% out) + expect_equal(out, c("## singlehit v1.0.0 (2026-01-01)", "", "#### Docs", "", entry("readme", sha("e")))) +}) + +test_that("a section that keeps any entry keeps its heading", { + news <- c("#### New features", "", entry("add a tab", sha("a")), entry("add progress", sha("c"))) + expect_equal(run(news), c("#### New features", "", entry("add progress", sha("c")))) +}) + +test_that("entries whose commit cannot be inspected are kept", { + news <- c("#### Fixes", "", entry("mystery", sha("z")), entry("no link", sha("a"))[1]) + expect_equal(run(news), news) +}) + +test_that("text without app entries comes back unchanged", { + news <- c("", "", "## singlehit v1.0.0 (2026-01-01)", "", "#### Docs", "", entry("readme", sha("e")), "") + expect_equal(run(news), news) +})