From 239d330a865d72bb8f2506144af239094be53a66 Mon Sep 17 00:00:00 2001 From: FenTouXunGui <43262531+fentouxungui@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:42:23 +0800 Subject: [PATCH] ci: add ShinyApps prebuilt desktop bundle workflow --- .github/workflows/build-app-bundle.yml | 329 +++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 .github/workflows/build-app-bundle.yml diff --git a/.github/workflows/build-app-bundle.yml b/.github/workflows/build-app-bundle.yml new file mode 100644 index 0000000..8d9de95 --- /dev/null +++ b/.github/workflows/build-app-bundle.yml @@ -0,0 +1,329 @@ +# ===================================================================== +# ShinyApps app-layer bundle builder (Route A) -- HARDENED. +# Builds prebuilt desktop bundles (win-x64, mac-arm64) for an R/Shiny app +# and attaches them to a GitHub Release. No shell rebuild. R 4.6.1. +# +# Reliability design (learned from real onboardings): +# * dependency graph from the SOURCE index (binary index is incomplete on +# a brand-new R and silently drops edges) +# * per-package binary->source install (Bioc annotations/TxDb have no +# binaries for a new R) +# * fix-up loop over installed DESCRIPTIONs (catches dropped transitive deps) +# * load validation: every required seed package must load from rlib +# * base-priority packages are excluded from the required check +# * macOS: optional source rebuild + otool portability audit (CRAN macOS +# binaries can hard-link XQuartz/Homebrew) +# * size guard against the 2 GiB GitHub Release asset limit +# +# Modes (auto-detected when APP_MODE=auto): +# * flat - the repo IS the app (app.R, or ui.R+server.R, or legacy +# shinyUI/shinyServer). Sources are copied verbatim; the +# runtime runs the directory. No entry is synthesized. +# * package-fn - the app is an R package whose exported function returns +# a shinyApp object; ENTRY_FN is that call. +# * package-deploy - the app is a dir inside an R package that reads package +# internals; the app files are copied + library() lines +# prepended + data supplied via an RData (mirrors the +# package's own deploy_*() helper). +# ===================================================================== +name: Build app bundle + +on: + push: + tags: ['v*'] + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Existing tag to attach bundles to (e.g. v0.1.0)" + required: false + type: string + +permissions: + contents: write + +env: + APP_ID: "paramgui" + APP_NAME: "paramGUI" + APP_AUTHOR: "Joris Snellenburg" + APP_DESC: "Shiny GUI for parameter estimation examples" + APP_HOMEPAGE: "https://github.com/glotaran/paramGUI" + APP_LICENSE: "GPL (>= 2)" + APP_MODE: "flat" + APP_SUBDIR: "inst/shinyApps/paramGUI" + PKG_SUBDIR: "." + ENTRY_FN: "" + UI_LIBS: "" + SERVER_LIBS: "" + DEMO_RDA: "" + DEMO_OBJECT: "" + DEMO_SAVE_AS: "" + DEMO_RDATA: "" + EXTRA_PKGS: "paramGUI" + EXCLUDE: "rsconnect,.github" + ARCHIVE_URLS: "" + EXTRA_REPOS: "" + SOFT_PKGS: "" + SOURCE_REBUILD: "gdtools" + PREFETCH_SCRIPT: "" + APP_NOTES: "" + MAX_REQUEST_MB: "1024" + SMOKE: "on" + SMOKE_TIMEOUT_SEC: "300" + R_VERSION: "4.6.1" + +jobs: + bundle: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: windows-latest, platform: "win-x64" } + - { os: macos-14, platform: "mac-arm64" } + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ env.R_VERSION }} + use-public-rspm: true + + - name: Install build helpers + shell: bash + run: Rscript -e 'install.packages(c("jsonlite","zip","digest","BiocManager","callr","curl"), repos="https://cloud.r-project.org")' + + - name: Resolve store version + shell: bash + run: | + V="${{ github.event.release.tag_name }}${{ github.ref_name }}${{ inputs.tag }}" + V="${V#v}" + if [ -z "$V" ]; then V="0.1.0"; fi + echo "VERSION=$V" >> "$GITHUB_ENV" + + - name: Assemble bundle (app/ + rlib/ + manifest.json) + shell: Rscript {0} + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + app_id <- Sys.getenv("APP_ID") + mode <- Sys.getenv("APP_MODE"); if (!nzchar(mode)) mode <- "auto" + sub_ <- Sys.getenv("APP_SUBDIR"); if (!nzchar(sub_)) sub_ <- "." + pkg_sub <- Sys.getenv("PKG_SUBDIR"); if (!nzchar(pkg_sub)) pkg_sub <- "." + root <- getwd() + bundle <- file.path(root, "bundle"); unlink(bundle, recursive = TRUE); dir.create(bundle, recursive = TRUE) + appdir <- file.path(bundle, "app"); dir.create(appdir, recursive = TRUE) + lib <- file.path(bundle, "rlib"); dir.create(lib, recursive = TRUE) + .libPaths(c(lib, .libPaths())) + base_pkgs <- rownames(installed.packages(priority = "base")) + split_pkgs <- function(x) { p <- strsplit(trimws(x), "[ ,]+")[[1]]; p[nzchar(p)] } + dcf_deps <- function(dcf, fields = c("Depends","Imports","LinkingTo")) { + f <- intersect(fields, colnames(dcf)); if (!length(f)) return(character()) + v <- na.omit(unlist(dcf[1, f])) + v <- trimws(sub("[[:space:]]*\\(.*\\)$", "", trimws(unlist(strsplit(paste(v, collapse = ","), ","))))) + v[nzchar(v) & v != "R"] + } + opt <- function() sprintf("options(shiny.launch.browser = FALSE, shiny.maxRequestSize = %s * 1024^2)", Sys.getenv("MAX_REQUEST_MB")) + pkgroot <- normalizePath(file.path(root, pkg_sub), winslash = "/", mustWork = FALSE) + has_pkg <- file.exists(file.path(pkgroot, "DESCRIPTION")) + if (mode == "auto") mode <- if (!has_pkg) "flat" else if (nzchar(Sys.getenv("ENTRY_FN"))) "package-fn" else "package-deploy" + + seed <- character(); pkg_name <- "" + if (mode == "flat") { + src <- normalizePath(file.path(root, sub_), winslash = "/", mustWork = TRUE) + for (p in list.files(src, all.files = TRUE, no.. = TRUE, full.names = TRUE)) + if (!basename(p) %in% c(".git", ".github")) file.copy(p, appdir, recursive = TRUE) + for (e in split_pkgs(Sys.getenv("EXCLUDE"))) { t <- file.path(appdir, e); if (file.exists(t)) unlink(t, recursive = TRUE) } + if (file.exists(file.path(appdir, "app.R"))) { + if (any(grepl("runApp[[:space:]]*\\(", readLines(file.path(appdir, "app.R"), warn = FALSE)))) { + file.rename(file.path(appdir, "app.R"), file.path(appdir, paste0(app_id, "-app.R"))) + writeLines(c(opt(), sprintf("app <- source(\"%s-app.R\")$value", app_id), + "if (!inherits(app, \"shiny.appobj\")) stop(\"app.R did not return a shiny app object\")", "app"), + file.path(appdir, "app.R")) + } + } else if (!file.exists(file.path(appdir, "ui.R"))) stop("flat mode: no app.R or ui.R in ", src) + sf <- list.files(appdir, pattern = "[.](R|r|Rmd|rmd)$", recursive = TRUE, full.names = TRUE) + txt <- paste(unlist(lapply(sf, function(f) tryCatch(readLines(f, warn = FALSE), error = function(e) character()))), collapse = "\n") + grab <- function(pat) { x <- regmatches(txt, gregexpr(pat, txt))[[1]]; if (length(x)) gsub("[()\"']", "", sub(".*?(library|require)", "", x)) else character() } + seed <- unique(c(grab("library\\([A-Za-z0-9._\"']+"), grab("require\\([A-Za-z0-9._\"']+"), + sub("::.*$", "", regmatches(txt, gregexpr("([A-Za-z][A-Za-z0-9._]+)::", txt))[[1]]), + split_pkgs(Sys.getenv("EXTRA_PKGS")), "shiny")) + } else if (mode == "package-fn") { + dcf <- read.dcf(file.path(pkgroot, "DESCRIPTION")); pkg_name <- unname(dcf[1, "Package"]) + writeLines(c(opt(), Sys.getenv("ENTRY_FN")), file.path(appdir, "app.R")) + seed <- c(dcf_deps(dcf), split_pkgs(Sys.getenv("EXTRA_PKGS")), pkg_name) + } else { + dcf <- read.dcf(file.path(pkgroot, "DESCRIPTION")); pkg_name <- unname(dcf[1, "Package"]) + psrc <- normalizePath(file.path(pkgroot, "inst", sub_), winslash = "/", mustWork = TRUE) + for (p in list.files(psrc, all.files = TRUE, no.. = TRUE, full.names = TRUE)) file.copy(p, appdir, recursive = TRUE) + prepend <- function(f, lines) if (file.exists(f) && length(lines)) { txt <- readLines(f, warn = FALSE); writeLines(c(lines, txt), f) } + rda <- Sys.getenv("DEMO_RDA") + if (nzchar(rda)) { + e <- new.env(); load(file.path(root, rda), envir = e) + save_as <- Sys.getenv("DEMO_SAVE_AS"); if (!nzchar(save_as)) save_as <- ".SHINYSTAN_OBJECT" + rdata <- Sys.getenv("DEMO_RDATA"); if (!nzchar(rdata)) rdata <- "sso.RData" + assign(save_as, get(Sys.getenv("DEMO_OBJECT"), envir = e)); save(list = save_as, file = file.path(appdir, rdata)) + prepend(file.path(appdir, "global.R"), sprintf("load(\"%s\")", rdata)) + } + ui_libs <- split_pkgs(Sys.getenv("UI_LIBS")); srv_libs <- split_pkgs(Sys.getenv("SERVER_LIBS")) + prepend(file.path(appdir, "ui.R"), paste0("library(", ui_libs, ");")) + prepend(file.path(appdir, "server.R"), paste0("library(", srv_libs, ");")) + seed <- unique(c(ui_libs, srv_libs, dcf_deps(dcf), split_pkgs(Sys.getenv("EXTRA_PKGS")))) + } + seed <- setdiff(seed, base_pkgs) + cat("mode:", mode, "| seed:", paste(sort(seed), collapse = ", "), "\n") + + er <- split_pkgs(Sys.getenv("EXTRA_REPOS")) + repos <- unique(c(er, BiocManager::repositories())); options(repos = repos) + db <- available.packages(repos = repos, type = "source") + dropped <- setdiff(seed, rownames(db)); if (length(dropped)) cat("WARN dropped (not in CRAN/Bioc):", paste(dropped, collapse = ", "), "\n") + seed <- intersect(seed, rownames(db)) + closure <- unique(c(seed, unlist(tools::package_dependencies(seed, db = db, recursive = TRUE)))) + closure <- setdiff(closure, base_pkgs); closure <- intersect(closure, rownames(db)) + cat("closure size:", length(closure), "\n") + + options(pkgType = "both") + soft <- split_pkgs(Sys.getenv("SOFT_PKGS")) + have <- function() basename(list.dirs(lib, recursive = FALSE)) + ins <- function(p, type = "both", deps = NA) if (length(p)) install.packages(p, lib = lib, repos = repos, type = type, dependencies = deps, Ncpus = 2) + ins(closure) + nb <- setdiff(closure, have()); if (length(nb)) { cat("source retry:", length(nb), "\n"); ins(nb, type = "source", deps = FALSE) } + if (identical(unname(Sys.info()[["sysname"]]), "Darwin")) { + sr <- split_pkgs(Sys.getenv("SOURCE_REBUILD")) + if (length(sr)) { + oldpath <- Sys.getenv("PATH"); pth <- strsplit(oldpath, .Platform$path.sep)[[1]] + pth <- pth[!grepl("homebrew|linuxbrew", pth, ignore.case = TRUE)] + Sys.setenv(PATH = paste(pth, collapse = .Platform$path.sep), PKG_CONFIG_PATH = "", PKG_CONFIG_LIBDIR = "") + cat("macOS source rebuild (Homebrew hidden):", paste(sr, collapse = ", "), "\n") + ins(sr, type = "source", deps = FALSE) + Sys.setenv(PATH = oldpath) + } + } + if (mode == "package-fn") { install.packages(pkgroot, lib = lib, repos = NULL, type = "source", dependencies = FALSE); if (!pkg_name %in% have()) stop("app package failed to install: ", pkg_name) } + for (u in split_pkgs(Sys.getenv("ARCHIVE_URLS"))) { f <- tempfile(fileext = ".tar.gz"); download.file(u, f, mode = "wb", quiet = TRUE); try(install.packages(f, lib = lib, repos = NULL, type = "source", dependencies = FALSE), silent = TRUE) } + + for (i in 1:5) { + installed <- have(); need <- character() + for (p in installed) { d <- tryCatch(read.dcf(file.path(lib, p, "DESCRIPTION")), error = function(e) NULL); if (!is.null(d)) need <- c(need, dcf_deps(d)) } + need <- setdiff(unique(intersect(need, rownames(db))), c(installed, base_pkgs)) + if (!length(need)) break + cat("fix-up round", i, ":", paste(need, collapse = ", "), "\n"); ins(need) + } + + if (identical(unname(Sys.info()[["sysname"]]), "Darwin")) { + bins <- list.files(lib, pattern = "[.](so|dylib)$", recursive = TRUE, full.names = TRUE); bad <- character() + for (b in bins) { o <- suppressWarnings(system2("otool", c("-L", shQuote(b)), stdout = TRUE, stderr = FALSE)); if (any(grepl("(/opt/X11|/opt/homebrew|/usr/local/opt)", o))) bad <- c(bad, b) } + if (length(bad)) stop("non-portable dylib references: ", paste(unique(basename(bad)), collapse = ", ")) + } + + required <- setdiff(seed, c(soft, base_pkgs)); miss <- setdiff(required, have()) + if (length(miss)) stop("missing required packages: ", paste(miss, collapse = ", ")) + for (p in required) { ok <- tryCatch({ loadNamespace(p, lib.loc = lib); TRUE }, error = function(e) { cat("LOAD FAILED:", p, "->", conditionMessage(e), "\n"); FALSE }); if (!ok) stop("seed package cannot be loaded from rlib: ", p) } + soft_missing <- setdiff(intersect(soft, seed), c(have(), base_pkgs)) + cat("rlib packages:", length(have()), "| soft missing:", paste(soft_missing, collapse = ","), "\n") + + entry <- if (file.exists(file.path(appdir, "app.R"))) "app/app.R" else "app/ui.R" + pkgs <- as.data.frame(installed.packages(lib.loc = lib), stringsAsFactors = FALSE) + notes <- Sys.getenv("APP_NOTES"); if (length(soft_missing)) notes <- paste0(notes, " Not bundled: ", paste(soft_missing, collapse = ", "), ".") + man <- list(schema = 1L, id = app_id, name = Sys.getenv("APP_NAME"), version = Sys.getenv("VERSION"), + author = Sys.getenv("APP_AUTHOR"), description = Sys.getenv("APP_DESC"), homepage = Sys.getenv("APP_HOMEPAGE"), + license = Sys.getenv("APP_LICENSE"), runtime = "r-shiny", entry = entry, platform = "${{ matrix.platform }}", + r_version = Sys.getenv("R_VERSION"), min_shell_version = "0.1.0", notes = notes, + packages = lapply(seq_len(nrow(pkgs)), function(i) list(name = pkgs$Package[i], version = pkgs$Version[i], path = file.path("rlib", pkgs$Package[i]), source = "cran"))) + jsonlite::write_json(man, file.path(bundle, "manifest.json"), pretty = TRUE, auto_unbox = TRUE) + + - name: Prefetch first-launch data (optional) + if: ${{ env.PREFETCH_SCRIPT != '' }} + timeout-minutes: 45 + shell: Rscript {0} + run: | + # Run a repo-provided R script from inside the bundled app directory, with the + # bundle's rlib on .libPaths(), so an app that downloads a database at first launch + # generates it here and ships it (the app then finds it and never downloads). The + # script must write into the current directory (e.g. data/) and be idempotent. + root <- getwd() + script <- file.path(root, Sys.getenv("PREFETCH_SCRIPT")) + if (!file.exists(script)) stop("PREFETCH_SCRIPT not found: ", script) + appdir <- file.path(root, "bundle", "app") + .libPaths(c(file.path(root, "bundle", "rlib"), .libPaths())) + options(timeout = 3600) + old <- setwd(appdir); on.exit(setwd(old), add = TRUE) + source(script) + cat("prefetch done; files under the app dir:\n") + print(list.files(".", recursive = TRUE, pattern = "[.](txt|tsv|rds|rda|sqlite)$")) + - name: Zip + sha256 + size guard + shell: Rscript {0} + run: | + out <- sprintf("%s-%s-%s.zip", Sys.getenv("APP_ID"), Sys.getenv("VERSION"), "${{ matrix.platform }}") + bundle <- file.path(getwd(), "bundle"); old <- setwd(bundle); on.exit(setwd(old)) + zip::zipr(out, files = list.files(".", all.files = TRUE, no.. = TRUE)) + entries <- utils::unzip(out, list = TRUE)$Name + if (!any(grepl("^rlib/", entries))) stop("zip is missing rlib/ -- aborting") + if (!any(grepl("^app/(app[.]R|ui[.]R)$", entries))) stop("zip is missing app/app.R or app/ui.R -- aborting") + setwd(old); file.rename(file.path(bundle, out), out) + sha <- digest::digest(out, algo = "sha256", file = TRUE); sz <- file.size(out) + cat("bundle zip size (bytes):", sz, "\n") + if (sz > 2 * 1024^3) stop("bundle exceeds the 2 GiB GitHub Release asset limit; trim modules or use a smaller closure.") + writeLines(sprintf('{"platform":"%s","file":"%s","sha256":"%s","size":%d}', "${{ matrix.platform }}", out, sha, sz), + sprintf("meta-%s.json", "${{ matrix.platform }}")) + + - name: Smoke test (launch the bundle like the ShinyApps shell) + if: ${{ env.SMOKE != 'off' }} + timeout-minutes: 12 + shell: Rscript {0} + run: | + # Reproduce the ShinyApps shell's startup: put the bundle's rlib on .libPaths and + # load the app from its own directory. Runs in a child process with a hard timeout so + # it can never hang, and surfaces the exact R error if the app fails to start. + app_id <- Sys.getenv("APP_ID"); ver <- Sys.getenv("VERSION"); plat <- "${{ matrix.platform }}" + zip <- sprintf("%s-%s-%s.zip", app_id, ver, plat) + if (!file.exists(zip)) stop("smoke: bundle not found: ", zip) + tmp <- tempfile("smoke"); dir.create(tmp) + utils::unzip(zip, exdir = tmp) + appdir <- file.path(tmp, "app"); lib <- file.path(tmp, "rlib") + if (!dir.exists(appdir) || !dir.exists(lib)) stop("smoke: unexpected bundle layout") + timeout <- suppressWarnings(as.integer(Sys.getenv("SMOKE_TIMEOUT_SEC", "300"))); if (is.na(timeout) || timeout < 30) timeout <- 300 + cat(sprintf("smoke: loading app '%s' (rlib on .libPaths)\n", app_id)) + load_app <- function(appdir, lib) { + .libPaths(c(lib, .libPaths())) + old <- setwd(appdir); on.exit(setwd(old), add = TRUE) + # shinyAppDir reproduces the shell: it handles app.R and legacy + # ui.R+server.R (shinyUI/shinyServer) directory apps alike. + obj <- tryCatch(shiny::shinyAppDir("."), error = function(e) e) + if (inherits(obj, "error")) stop("shinyAppDir failed: ", conditionMessage(obj)) + if (!inherits(obj, "shiny.appobj")) stop("app did not produce a shiny app object") + invisible(TRUE) + } + res <- tryCatch(callr::r(load_app, args = list(appdir = appdir, lib = lib), timeout = timeout, show = TRUE), + error = function(e) e) + if (inherits(res, "error")) { + cat("\n---- smoke error ----\n"); cat(conditionMessage(res), "\n") + stop("smoke test FAILED: the app did not start: ", conditionMessage(res)) + } + cat("SMOKE OK\n") + + - uses: actions/upload-artifact@v4 + with: + name: bundle-${{ matrix.platform }} + path: | + *.zip + meta-*.json + + release: + needs: bundle + if: github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v') || inputs.tag != '' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.ref_name }} + name: ${{ inputs.tag || github.ref_name }} + files: artifacts/*.zip