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
85 changes: 58 additions & 27 deletions .github/scripts/filter-app-news.R
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Drop entries for app-only commits from NEWS.md.
# Move entries for app-only commits in NEWS.md into a "Shiny app" section.
#
# 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.
# package released in step with singlehit (see sync-app-release.R), so its
# changes would otherwise read as singlehit features. Each version's app
# entries are gathered under "#### Shiny app" at the end of that version. An
# entry counts as the app's when its commit has the conventional scope `app`
# (for example `feat(app): ...`) or changed only files under app/. Entries whose
# commit cannot be inspected stay where they are.

news_entry_sha <- function(entry) {
match <- regmatches(entry, regexpr("/tree/[0-9a-f]{40}", entry))
Expand All @@ -32,26 +34,9 @@ is_app_commit <- function(commit) {
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.
# Remove each "#### Section" heading left with no entries, with the blank line
# that follows it.
drop_empty_sections <- function(lines) {
is_heading <- grepl("^#{2,4} ", lines)
drop <- logical(length(lines))
for (k in which(startsWith(lines, "#### "))) {
Expand All @@ -69,10 +54,56 @@ filter_app_news <- function(lines, inspect = inspect_commit) {
lines[!drop]
}

# `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) {
app <- logical(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))) {
app[i:end] <- TRUE
}
i <- end + 1L
} else {
i <- i + 1L
}
}
if (!any(app)) {
return(lines)
}

# Each "## " version heading starts a block; the app section goes after the
# block's last entry, before autonewsmd's "Full set of changes" link.
starts <- unique(c(1L, which(startsWith(lines, "## "))))
ends <- c(starts[-1L] - 1L, length(lines))
blocks <- Map(
function(from, to) {
rows <- seq.int(from, to)
moved <- lines[rows][app[rows]]
block <- drop_empty_sections(lines[rows][!app[rows]])
if (length(moved) == 0L) {
return(block)
}
full <- match(TRUE, startsWith(block, "Full set of changes"), nomatch = length(block) + 1L)
filled <- which(nzchar(trimws(block[seq_len(full - 1L)])))
after <- if (length(filled) > 0L) max(filled) else 0L
append(block, c("", "#### Shiny app", "", moved), after = after)
},
starts,
ends
)
unlist(blocks, use.names = FALSE)
}

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))
message(sprintf("Moved app entries into %d Shiny app sections of NEWS.md.", sum(filtered == "#### Shiny app")))
}
58 changes: 58 additions & 0 deletions .github/scripts/sync-app-release.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Release the Shiny app in app/ in step with singlehit.
#
# The release workflow runs this after bumping DESCRIPTION and before the
# release commit, so these edits land in that commit and the release tag holds
# both packages at the same version. The app is pinned to singlehit's release
# tag, so `pak::pak("seanthimons/singlehit/app@vX")` installs the matching
# singlehit. When publishing nothing is committed, so the script only checks
# that the merged files already match.

# `check_only` stops if anything is out of step instead of editing it.
sync_app_release <- function(version, app = "app", check_only = FALSE) {
d <- desc::desc(file.path(app, "DESCRIPTION"))
remotes <- d$get_remotes()
i <- grep("/singlehit(@.*)?$", remotes)
if (length(i) != 1L) {
stop("app/DESCRIPTION needs exactly one singlehit entry in Remotes.")
}
remotes[[i]] <- paste0(sub("@.*$", "", remotes[[i]]), "@v", version)
requirement <- paste(">=", version)

config_path <- file.path(app, "inst", "golem-config.yml")
old_config <- readLines(config_path, warn = FALSE)
config <- sub("^(\\s*golem_version:).*$", paste("\\1", version), old_config)

deps <- d$get_deps()
synced <- identical(as.character(d$get_version()), version) &&
identical(deps$version[deps$package == "singlehit" & deps$type == "Imports"], requirement) &&
identical(d$get_remotes(), remotes) &&
identical(old_config, config)
if (check_only) {
if (!synced) {
stop(sprintf(
"app/ is not released in step with singlehit %s: set its Version, singlehit (%s), Remotes %s, and golem_version.",
version,
requirement,
remotes[[i]]
))
}
return(invisible(FALSE))
}

d$set_version(version)
d$set_dep("singlehit", "Imports", requirement)
d$set_remotes(remotes)
d$write()
# desc leaves a trailing space after "Remotes:".
path <- file.path(app, "DESCRIPTION")
writeLines(sub("[ \t]+$", "", readLines(path, warn = FALSE)), path)
writeLines(config, config_path)
invisible(!synced)
}

if (sys.nframe() == 0L) {
version <- read.dcf("DESCRIPTION", fields = "Version")[[1L]]
publishing <- identical(Sys.getenv("RELEASE_MODE"), "publish")
sync_app_release(version, check_only = publishing)
message(sprintf("app/ %s singlehit %s.", if (publishing) "matches" else "set to", version))
}
26 changes: 20 additions & 6 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,28 @@ 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` or `.github/scripts/filter-app-news.R` automatically validate
preparation with dry-run enabled.
change `release.yaml`, `.github/scripts/filter-app-news.R`, or
`.github/scripts/sync-app-release.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.
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.

Each release also releases the Shiny app in `app/` at the same version. After
the version bump, `.github/scripts/sync-app-release.R` sets the app's
`Version`, its `singlehit (>= VERSION)` requirement, its
`Remotes: seanthimons/singlehit@vVERSION` pin, and `golem_version` in
`app/inst/golem-config.yml`, so the release commit and tag hold both packages.
`pak::pak("seanthimons/singlehit/app@vVERSION")` then installs the app with the
matching singlehit. In publish mode the script only checks that the merged app
files match and fails the release otherwise. Between releases, the app on
`main` stays pinned to the last singlehit tag. Only the root package's tarball
is built and checked; nothing in CI checks the app.

After autonewsmd writes NEWS.md, `.github/scripts/filter-app-news.R` moves
entries for commits scoped `app` or touching only `app/` into a `#### Shiny app`
section at the end of their version, so they do not read as singlehit
changes. NEWS.md is regenerated from history each release, so earlier
versions' sections get the same treatment.

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 @@ -6,6 +6,7 @@ on:
paths:
- .github/workflows/release.yaml
- .github/scripts/filter-app-news.R
- .github/scripts/sync-app-release.R
workflow_dispatch:
inputs:
release-mode:
Expand Down Expand Up @@ -39,6 +40,7 @@ jobs:
version-type: ${{ inputs.version-type || 'patch' }}
notes-mode: autonewsmd
news-postprocess-script: .github/scripts/filter-app-news.R
pre-release-check-script: .github/scripts/sync-app-release.R
dry-run: ${{ github.event_name == 'pull_request' || inputs.dry-run }}
secrets:
RELEASE_PAT: ${{ secrets.RELEASE_PAT }}
36 changes: 32 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,38 @@ The package provides:

## Shiny app

A work-in-progress Shiny interface lives in the
[`app/`](https://github.com/seanthimons/singlehit/tree/main/app) directory. It is
a separate package that depends on `singlehit`; installing `singlehit` does not
install or require it. The app is licensed under GPL-3, while `singlehit`
If you would rather not write R code, the `singlehitapp` Shiny app runs the same
analysis in your web browser. It is a separate package stored in the
[`app/`](https://github.com/seanthimons/singlehit/tree/main/app) directory of
this repository, and each singlehit release includes the app at the same
version. Installing `singlehit` does not install the app, but installing the
app also installs the singlehit release it was built with:

```r
install.packages("pak") # only needed once
pak::pak("seanthimons/singlehit/app@*release") # the latest app and its singlehit
singlehitapp::run_app() # opens the app in your browser
```

To install a specific release, replace `*release` with its tag, for example
`app@v0.4.0`.

The tabs, from left to right:

- **Data:** load a CSV/TSV/TXT file or the Ward rotavirus example data. A QMRA
wiki `.xlsx` workbook, or the bundled sample, goes to the Library tab instead.
- **Library:** filter and plot QMRA wiki experiments, send one to Fit, or choose
several to test for pooling.
- **Pooling:** test whether the chosen experiments can share one curve, and send
the pooled data to Fit.
- **Fit:** the dose-trend test, parameter estimates, and goodness of fit.
- **Compare:** the recommended model and the full assessment.
- **Bootstrap:** confidence intervals, computed in the background.
- **Plots:** the fitted curves, with PNG download.
- **Explore:** build your own plots by dragging variables.

The [app guide](https://seanthimons.github.io/singlehit/articles/shiny-app.html)
walks through each tab. The app is licensed under GPL-3, while `singlehit`
remains MIT.

## Prerequisites
Expand Down
20 changes: 14 additions & 6 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,20 @@ export are planned.
The app is licensed under GPL-3 (see `LICENSE.md`) because it imports esquisse,
which is GPL-3. The `singlehit` package itself stays MIT.

## Requires singlehit 0.3.0 or later
## Releases

The Bootstrap and Plots tabs use `singlehit` functions (`add_bootstrap()`,
`bootstrap_progress()`) that were added in 0.3.0, so install `singlehit` 0.3.0
or later (for example from GitHub with `pak::pak("seanthimons/singlehit")`).
The app is released with `singlehit`, at the same version and under the same
tag. The release workflow sets the app's version, raises its `singlehit`
requirement to that version, and pins `Remotes` to the release tag, so

```r
pak::pak("seanthimons/singlehit/app@*release") # or @v0.4.0 for a given release
```

installs the app with the `singlehit` release it was built with. Between
releases, the app on `main` stays pinned to the last `singlehit` tag. If app
code on `main` needs unreleased `singlehit` functions, use the development
setup below.

## Running during development

Expand All @@ -154,8 +163,7 @@ This loads the in-repo `singlehit` first, so the tests do not need it installed.
## Running against an installed singlehit

```r
pak::pak("seanthimons/singlehit")
pak::pak("seanthimons/singlehit/app")
pak::pak("seanthimons/singlehit/app@*release")
singlehitapp::run_app()
```

Expand Down
48 changes: 48 additions & 0 deletions tests/testthat/test-app-release-sync.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# The app version sync 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", "sync-app-release.R")
skip_if_not(file.exists(script), "release script is not in the built package")
skip_if_not_installed("desc")
sync_env <- new.env()
sys.source(script, envir = sync_env)

app_copy <- function() {
app <- tempfile("app")
dir.create(file.path(app, "inst"), recursive = TRUE)
writeLines(
c(
"Package: singlehitapp",
"Version: 0.0.0.9000",
"Imports:",
" shiny,",
" singlehit (>= 0.3.0)",
"Remotes:",
" seanthimons/singlehit"
),
file.path(app, "DESCRIPTION")
)
writeLines(
c("default:", " golem_name: singlehitapp", " golem_version: 0.0.0.9000"),
file.path(app, "inst", "golem-config.yml")
)
app
}

test_that("syncing sets the app's version, singlehit floor, remote tag, and golem version", {
app <- app_copy()
expect_true(sync_env$sync_app_release("0.4.0", app))
d <- read.dcf(file.path(app, "DESCRIPTION"))
expect_equal(d[, "Version"], c(Version = "0.4.0"))
expect_match(d[, "Imports"], "singlehit (>= 0.4.0)", fixed = TRUE)
expect_equal(trimws(d[, "Remotes"]), c(Remotes = "seanthimons/singlehit@v0.4.0"))
expect_true(" golem_version: 0.4.0" %in% readLines(file.path(app, "inst", "golem-config.yml")))
expect_false(any(grepl("[ \t]$", readLines(file.path(app, "DESCRIPTION")))))
expect_false(sync_env$sync_app_release("0.4.0", app))
})

test_that("check-only mode passes once synced and fails otherwise", {
app <- app_copy()
expect_error(sync_env$sync_app_release("0.4.0", app, check_only = TRUE), "not released in step")
sync_env$sync_app_release("0.4.0", app)
expect_silent(sync_env$sync_app_release("0.4.0", app, check_only = TRUE))
})
Loading
Loading