diff --git a/DESCRIPTION b/DESCRIPTION index ada62e63..be9edd2e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -72,4 +72,4 @@ Config/testthat/edition: 3 Encoding: UTF-8 Language: en-US Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 +RoxygenNote: 7.3.3 diff --git a/NAMESPACE b/NAMESPACE index 37bd5092..43acfffc 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -10,6 +10,8 @@ export(add_dockerfile_with_renv_heroku) export(add_dockerfile_with_renv_shinyproxy) export(add_empty_file) export(add_fct) +export(add_github_action) +export(add_gitlab_ci) export(add_html_template) export(add_js_file) export(add_js_handler) diff --git a/NEWS.md b/NEWS.md index aabd894d..7cef828c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,7 @@ ## New features / user-visible changes +- New `add_github_action()` and `add_gitlab_ci()` helpers generate minimal deployment CI for fresh `{golem}` apps. - The `add_dockerfile_with_renv_*` function now generates a multi-stage Dockerfile by default (use `single_file = FALSE` to retain the previous behavior). - The `add_dockerfile_with_renv_*` function now creates a Dockerfile that sets `golem.app.prod = TRUE` by default (use `set_golem.app.prod = FALSE` to retain the previous behavior). - Print functions have be reworked standardized using the `{cli}` package (@ilyaZar, #89) diff --git a/R/add_ci_files.R b/R/add_ci_files.R new file mode 100644 index 00000000..a578c6f9 --- /dev/null +++ b/R/add_ci_files.R @@ -0,0 +1,268 @@ +#' Add deployment CI for GitHub Actions +#' +#' Creates a minimal GitHub Actions workflow for deploying a `{golem}` app via +#' `{rsconnect}`. If needed, this function also creates a root `app.R` and +#' `.rscignore` by calling [add_positconnect_file()]. +#' +#' @inheritParams add_module +#' +#' @export +#' +#' @return The path to the created workflow, invisibly. +add_github_action <- function( + golem_wd = get_golem_wd(), + open = TRUE, + pkg +) { + signal_arg_is_deprecated( + pkg, + fun = as.character( + sys.call()[[1]] + ), + "pkg" + ) + + add_deploy_ci_( + template = "github-action-template.yml", + output = fs_path( + golem_wd, + ".github", + "workflows", + "shiny-deploy.yaml" + ), + golem_wd = golem_wd, + open = open + ) +} + +#' Add deployment CI for GitLab +#' +#' Creates a minimal GitLab CI file for deploying a `{golem}` app via +#' `{rsconnect}`. If needed, this function also creates a root `app.R` and +#' `.rscignore` by calling [add_positconnect_file()]. +#' +#' @inheritParams add_module +#' +#' @export +#' +#' @return The path to the created workflow, invisibly. +add_gitlab_ci <- function( + golem_wd = get_golem_wd(), + open = TRUE, + pkg +) { + signal_arg_is_deprecated( + pkg, + fun = as.character( + sys.call()[[1]] + ), + "pkg" + ) + + add_deploy_ci_( + template = "gitlab-ci-template.yml", + output = fs_path( + golem_wd, + ".gitlab-ci.yml" + ), + golem_wd = golem_wd, + open = open + ) +} + +add_deploy_ci_ <- function( + template, + output, + golem_wd = get_golem_wd(), + open = TRUE +) { + golem_wd <- fs_path_abs( + golem_wd + ) + + ensure_deploy_entrypoint_( + golem_wd = golem_wd + ) + + if ( + fs_file_exists( + output + ) + ) { + cli_alert_info( + sprintf( + "The '%s'-file already exists.", + basename( + output + ) + ) + ) + return( + open_or_go_to( + output, + open + ) + ) + } + + fs_dir_create( + path_dir( + output + ), + recurse = TRUE + ) + + writeLines( + render_ci_template_( + template = template, + golem_wd = golem_wd + ), + con = output + ) + + if ( + basename( + output + ) == + "shiny-deploy.yaml" + ) { + ensure_github_gitignore_( + golem_wd = golem_wd + ) + usethis_use_build_ignore( + ".github" + ) + } else { + usethis_use_build_ignore( + ".gitlab-ci.yml" + ) + } + + cat_created( + output + ) + open_or_go_to( + output, + open + ) +} + +render_ci_template_ <- function( + template, + golem_wd = get_golem_wd() +) { + app_name <- get_golem_name( + golem_wd = golem_wd + ) + + template_lines <- readLines( + golem_sys( + "utils", + template + ), + warn = FALSE + ) + + gsub( + "__APPNAME__", + app_name, + template_lines, + fixed = TRUE + ) +} + +ensure_deploy_entrypoint_ <- function( + golem_wd = get_golem_wd() +) { + app_file <- fs_path( + golem_wd, + "app.R" + ) + rscignore_file <- fs_path( + golem_wd, + ".rscignore" + ) + + if ( + !fs_file_exists( + app_file + ) + ) { + add_positconnect_file( + golem_wd = golem_wd, + open = FALSE + ) + return( + invisible( + golem_wd + ) + ) + } + + if ( + !fs_file_exists( + rscignore_file + ) + ) { + add_rscignore_file( + golem_wd = golem_wd, + open = FALSE + ) + } + + invisible( + golem_wd + ) +} + +ensure_github_gitignore_ <- function( + golem_wd = get_golem_wd() +) { + where <- fs_path( + golem_wd, + ".github", + ".gitignore" + ) + + if ( + !fs_file_exists( + where + ) + ) { + writeLines( + "*.html", + con = where + ) + return( + invisible( + where + ) + ) + } + + content <- readLines( + where, + warn = FALSE + ) + + if ( + !"*.html" %in% + content + ) { + writeLines( + c( + content, + "*.html" + ), + con = where + ) + } + + invisible( + where + ) +} + +path_dir <- function(path) { + dirname(path) +} diff --git a/inst/shinyexample/dev/02_dev.R b/inst/shinyexample/dev/02_dev.R index 9058022d..74780e82 100644 --- a/inst/shinyexample/dev/02_dev.R +++ b/inst/shinyexample/dev/02_dev.R @@ -65,8 +65,10 @@ covrpage::covrpage() usethis::use_github() # GitHub Actions -usethis::use_github_action() -# Chose one of the three +# Add actions workflow for golem based deployment of Shiny apps +golem::add_github_action() +# Or use the generic usethis GitHub Actions helpers +# Choose one of the three # See https://usethis.r-lib.org/reference/use_github_action.html usethis::use_github_action_check_release() usethis::use_github_action_check_standard() @@ -82,7 +84,7 @@ usethis::use_circleci_badge() usethis::use_jenkins() # GitLab CI -usethis::use_gitlab_ci() +golem::add_gitlab_ci() # You're now set! ---- # go to dev/03_deploy.R diff --git a/inst/utils/github-action-template.yml b/inst/utils/github-action-template.yml new file mode 100644 index 00000000..b8493124 --- /dev/null +++ b/inst/utils/github-action-template.yml @@ -0,0 +1,72 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + +name: shiny-deploy.yaml + +permissions: read-all + +jobs: + shiny-deploy: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rsconnect, local::. + needs: shiny + + - name: Validate deployment settings + env: + APPNAME: __APPNAME__ + ACCOUNT: your-account-name + SERVER: shinyapps.io + RSCONNECT_USER: ${{ secrets.RSCONNECT_USER }} + RSCONNECT_TOKEN: ${{ secrets.RSCONNECT_TOKEN }} + RSCONNECT_SECRET: ${{ secrets.RSCONNECT_SECRET }} + run: | + required <- c("RSCONNECT_USER", "RSCONNECT_TOKEN", "RSCONNECT_SECRET") + missing <- required[vapply(required, function(x) Sys.getenv(x) == "", logical(1))] + if (length(missing) > 0) { + stop( + sprintf( + "Missing GitHub Actions secrets: %s", + paste(missing, collapse = ", ") + ), + call. = FALSE + ) + } + if (Sys.getenv("ACCOUNT") == "your-account-name") { + stop( + "Set ACCOUNT in the workflow before deploying.", + call. = FALSE + ) + } + if (Sys.getenv("SERVER") == "") { + stop( + "Set SERVER in the workflow before deploying.", + call. = FALSE + ) + } + shell: Rscript {0} + + - name: Authorize and deploy app + env: + APPNAME: __APPNAME__ + ACCOUNT: your-account-name + SERVER: shinyapps.io + run: | + rsconnect::setAccountInfo("${{ secrets.RSCONNECT_USER }}", "${{ secrets.RSCONNECT_TOKEN }}", "${{ secrets.RSCONNECT_SECRET }}") + rsconnect::deployApp(appDir = ".", appPrimaryDoc = "app.R", appName = Sys.getenv("APPNAME"), account = Sys.getenv("ACCOUNT"), server = Sys.getenv("SERVER")) + shell: Rscript {0} diff --git a/inst/utils/gitlab-ci-template.yml b/inst/utils/gitlab-ci-template.yml new file mode 100644 index 00000000..44a4e830 --- /dev/null +++ b/inst/utils/gitlab-ci-template.yml @@ -0,0 +1,14 @@ +stages: + - deploy + +deploy-shiny: + stage: deploy + image: rocker/verse:latest + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + before_script: + - Rscript -e 'install.packages(c("remotes", "rsconnect"), repos = "https://cran.rstudio.com")' + - Rscript -e 'remotes::install_deps(dependencies = TRUE, repos = "https://cran.rstudio.com"); remotes::install_local(".", dependencies = FALSE, upgrade = "never", repos = "https://cran.rstudio.com")' + script: + - Rscript -e 'required <- c("RSCONNECT_USER", "RSCONNECT_TOKEN", "RSCONNECT_SECRET"); missing <- required[vapply(required, function(x) Sys.getenv(x) == "", logical(1))]; if (length(missing) > 0) stop(sprintf("Missing CI variables: %s", paste(missing, collapse = ", ")), call. = FALSE); if (Sys.getenv("ACCOUNT", unset = "your-account-name") == "your-account-name") stop("Set ACCOUNT before deploying.", call. = FALSE); if (Sys.getenv("SERVER", unset = "") == "") stop("Set SERVER before deploying.", call. = FALSE)' + - Rscript -e 'rsconnect::setAccountInfo(name = Sys.getenv("RSCONNECT_USER"), token = Sys.getenv("RSCONNECT_TOKEN"), secret = Sys.getenv("RSCONNECT_SECRET")); rsconnect::deployApp(appDir = ".", appPrimaryDoc = "app.R", appName = Sys.getenv("APPNAME", unset = "__APPNAME__"), account = Sys.getenv("ACCOUNT", unset = "your-account-name"), server = Sys.getenv("SERVER", unset = "shinyapps.io"))' diff --git a/man/add_github_action.Rd b/man/add_github_action.Rd new file mode 100644 index 00000000..ee1e32ce --- /dev/null +++ b/man/add_github_action.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/add_ci_files.R +\name{add_github_action} +\alias{add_github_action} +\title{Add deployment CI for GitHub Actions} +\usage{ +add_github_action(golem_wd = get_golem_wd(), open = TRUE, pkg) +} +\arguments{ +\item{golem_wd}{Path to the root of the package. Default is \code{get_golem_wd()}.} + +\item{open}{Should the created file be opened?} + +\item{pkg}{Deprecated, please use golem_wd instead} +} +\value{ +The path to the created workflow, invisibly. +} +\description{ +Creates a minimal GitHub Actions workflow for deploying a \code{{golem}} app via +\code{{rsconnect}}. If needed, this function also creates a root \code{app.R} and +\code{.rscignore} by calling \code{\link[=add_positconnect_file]{add_positconnect_file()}}. +} diff --git a/man/add_gitlab_ci.Rd b/man/add_gitlab_ci.Rd new file mode 100644 index 00000000..49d7ae89 --- /dev/null +++ b/man/add_gitlab_ci.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/add_ci_files.R +\name{add_gitlab_ci} +\alias{add_gitlab_ci} +\title{Add deployment CI for GitLab} +\usage{ +add_gitlab_ci(golem_wd = get_golem_wd(), open = TRUE, pkg) +} +\arguments{ +\item{golem_wd}{Path to the root of the package. Default is \code{get_golem_wd()}.} + +\item{open}{Should the created file be opened?} + +\item{pkg}{Deprecated, please use golem_wd instead} +} +\value{ +The path to the created workflow, invisibly. +} +\description{ +Creates a minimal GitLab CI file for deploying a \code{{golem}} app via +\code{{rsconnect}}. If needed, this function also creates a root \code{app.R} and +\code{.rscignore} by calling \code{\link[=add_positconnect_file]{add_positconnect_file()}}. +} diff --git a/tests/testthat/test-add_ci_files.R b/tests/testthat/test-add_ci_files.R new file mode 100644 index 00000000..fc3a04f0 --- /dev/null +++ b/tests/testthat/test-add_ci_files.R @@ -0,0 +1,186 @@ +test_that("add_github_action works in a fresh golem", { + run_quietly_in_a_dummy_golem({ + add_github_action( + golem_wd = ".", + open = FALSE + ) + + expect_exists( + ".github/workflows/shiny-deploy.yaml" + ) + expect_exists( + "app.R" + ) + expect_exists( + ".rscignore" + ) + expect_exists( + ".github/.gitignore" + ) + + workflow <- readLines( + ".github/workflows/shiny-deploy.yaml", + warn = FALSE + ) + + expect_true( + any( + grepl( + "setup-r-dependencies@v2", + workflow, + fixed = TRUE + ) + ) + ) + expect_true( + any( + grepl( + "any::rsconnect, local::.", + workflow, + fixed = TRUE + ) + ) + ) + expect_false( + any( + grepl( + "setup-renv@v2", + workflow, + fixed = TRUE + ) + ) + ) + expect_true( + any( + grepl( + 'appPrimaryDoc = "app.R"', + workflow, + fixed = TRUE + ) + ) + ) + expect_true( + any( + grepl( + "RSCONNECT_USER", + workflow, + fixed = TRUE + ) + ) + ) + expect_true( + grepl( + "\\.github", + paste( + readLines( + ".Rbuildignore", + warn = FALSE + ), + collapse = "\n" + ) + ) + ) + expect_equal( + readLines( + ".github/.gitignore", + warn = FALSE + ), + "*.html" + ) + }) +}) + +test_that("add_gitlab_ci works in a fresh golem", { + run_quietly_in_a_dummy_golem({ + add_gitlab_ci( + golem_wd = ".", + open = FALSE + ) + + expect_exists( + ".gitlab-ci.yml" + ) + expect_exists( + "app.R" + ) + expect_exists( + ".rscignore" + ) + + workflow <- readLines( + ".gitlab-ci.yml", + warn = FALSE + ) + + expect_true( + any( + grepl( + "deploy-shiny", + workflow, + fixed = TRUE + ) + ) + ) + expect_true( + any( + grepl( + "CI_DEFAULT_BRANCH", + workflow, + fixed = TRUE + ) + ) + ) + expect_true( + any( + grepl( + "rsconnect", + workflow, + fixed = TRUE + ) + ) + ) + expect_true( + any( + grepl( + 'appPrimaryDoc = "app.R"', + workflow, + fixed = TRUE + ) + ) + ) + expect_true( + "^\\.gitlab-ci\\.yml$" %in% + readLines( + ".Rbuildignore", + warn = FALSE + ) + ) + }) +}) + +test_that("add_github_action backfills .rscignore when app.R already exists", { + run_quietly_in_a_dummy_golem({ + writeLines( + "# pre-existing app", + "app.R" + ) + + expect_false( + file.exists( + ".rscignore" + ) + ) + + add_github_action( + golem_wd = ".", + open = FALSE + ) + + expect_exists( + ".rscignore" + ) + expect_exists( + ".github/workflows/shiny-deploy.yaml" + ) + }) +})