Skip to content
Merged
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
78 changes: 78 additions & 0 deletions .github/scripts/filter-app-news.R
Original file line number Diff line number Diff line change
@@ -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))
}
8 changes: 6 additions & 2 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- .github/workflows/release.yaml
- .github/scripts/filter-app-news.R
workflow_dispatch:
inputs:
release-mode:
Expand Down Expand Up @@ -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 }}
62 changes: 62 additions & 0 deletions tests/testthat/test-news-filter.R
Original file line number Diff line number Diff line change
@@ -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)
})
Loading