Skip to content

Commit aa48efd

Browse files
authored
Merge pull request #1237 from ilyaZar/fix-1115
feat(CI): add support for CI boilerplate in golem
2 parents 4f340aa + e985dab commit aa48efd

9 files changed

Lines changed: 795 additions & 3 deletions

File tree

NEWS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
## New features / user-visible changes
66

7+
- New `add_github_action()` and `add_gitlab_ci()` helpers generate minimal
8+
deployment CI for fresh `{golem}` apps.
9+
- The deployment CI helpers restore `renv.lock` when it is present, fall back
10+
to `DESCRIPTION` when it is not, and declare `{pkgload}` for the generated
11+
Posit Connect entrypoint.
712
- The `add_dockerfile_with_renv_*` function now generates a multi-stage Dockerfile by default (use `single_file = FALSE` to retain the previous behavior).
813
- 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).
914
- Print functions have be reworked standardized using the `{cli}` package (@ilyaZar, #89)

R/add_ci_files.R

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#' Add deployment CI for GitHub Actions
2+
#'
3+
#' Creates a minimal GitHub Actions workflow for deploying a `{golem}` app to
4+
#' Posit Connect via `{rsconnect}`. If needed, this function also creates a
5+
#' root `app.R` and `.rscignore` by calling [add_positconnect_file()]. The
6+
#' generated Posit Connect entrypoint uses `{pkgload}`, so `{pkgload}` is added
7+
#' to `DESCRIPTION`.
8+
#'
9+
#' @inheritParams add_module
10+
#'
11+
#' @export
12+
#'
13+
#' @return The path to the created workflow, invisibly.
14+
add_github_action <- function(
15+
golem_wd = get_golem_wd(),
16+
open = TRUE
17+
) {
18+
add_deploy_ci_(
19+
template = "github-action-template.yml",
20+
output = fs_path(
21+
golem_wd,
22+
".github",
23+
"workflows",
24+
"shiny-deploy.yaml"
25+
),
26+
golem_wd = golem_wd,
27+
open = open
28+
)
29+
}
30+
31+
#' Add deployment CI for GitLab
32+
#'
33+
#' Creates a minimal GitLab CI file for deploying a `{golem}` app to Posit
34+
#' Connect via `{rsconnect}`. If needed, this function also creates a root
35+
#' `app.R` and `.rscignore` by calling [add_positconnect_file()]. The
36+
#' generated Posit Connect entrypoint uses `{pkgload}`, so `{pkgload}` is added
37+
#' to `DESCRIPTION`.
38+
#'
39+
#' @inheritParams add_module
40+
#'
41+
#' @export
42+
#'
43+
#' @return The path to the created GitLab CI file, invisibly.
44+
add_gitlab_ci <- function(golem_wd = get_golem_wd(), open = TRUE) {
45+
add_deploy_ci_(
46+
template = "gitlab-ci-template.yml",
47+
output = fs_path(golem_wd, ".gitlab-ci.yml"),
48+
golem_wd = golem_wd,
49+
open = open
50+
)
51+
}
52+
53+
#' @noRd
54+
add_deploy_ci_ <- function(
55+
template,
56+
output,
57+
golem_wd = get_golem_wd(),
58+
open = TRUE
59+
) {
60+
golem_wd <- fs_path_abs(golem_wd)
61+
62+
ensure_deploy_entrypoint_(golem_wd = golem_wd)
63+
ensure_deploy_dependencies_(golem_wd = golem_wd)
64+
65+
if (fs_file_exists(output)) {
66+
cli_alert_info(sprintf("The '%s'-file already exists.", basename(output)))
67+
return(open_or_go_to(output, open))
68+
}
69+
70+
fs_dir_create(dirname(output), recurse = TRUE)
71+
72+
writeLines(
73+
render_ci_template_(template = template, golem_wd = golem_wd),
74+
con = output
75+
)
76+
77+
if (basename(output) == "shiny-deploy.yaml") {
78+
ensure_github_gitignore_(golem_wd = golem_wd)
79+
usethis_use_build_ignore(".github")
80+
} else {
81+
usethis_use_build_ignore(".gitlab-ci.yml")
82+
}
83+
84+
cat_created(output)
85+
open_or_go_to(output, open)
86+
}
87+
88+
#' @noRd
89+
render_ci_template_ <- function(template, golem_wd = get_golem_wd()) {
90+
app_name <- get_golem_name(golem_wd = golem_wd)
91+
92+
template_lines <- readLines(
93+
golem_sys("utils", template),
94+
warn = FALSE
95+
)
96+
97+
gsub("__APPNAME__", app_name, template_lines, fixed = TRUE)
98+
}
99+
100+
#' @noRd
101+
ensure_deploy_entrypoint_ <- function(golem_wd = get_golem_wd()) {
102+
app_file <- fs_path(golem_wd, "app.R")
103+
rscignore_file <- fs_path(golem_wd, ".rscignore")
104+
105+
if (!fs_file_exists(app_file)) {
106+
add_positconnect_file(golem_wd = golem_wd, open = FALSE)
107+
return(invisible(golem_wd))
108+
}
109+
110+
if (!fs_file_exists(rscignore_file)) {
111+
add_rscignore_file(golem_wd = golem_wd, open = FALSE)
112+
}
113+
114+
return(invisible(golem_wd))
115+
}
116+
117+
#' @noRd
118+
ensure_deploy_dependencies_ <- function(golem_wd = get_golem_wd()) {
119+
desc_file <- fs_path(golem_wd, "DESCRIPTION")
120+
deps <- desc_get_deps(file = desc_file)
121+
has_pkgload <- any(
122+
deps$package == "pkgload" &
123+
deps$type %in% c("Depends", "Imports")
124+
)
125+
126+
if (!has_pkgload) {
127+
desc_set_dep("pkgload", type = "Imports", file = desc_file)
128+
}
129+
130+
return(invisible(golem_wd))
131+
}
132+
133+
#' @noRd
134+
ensure_github_gitignore_ <- function(golem_wd = get_golem_wd()) {
135+
where <- fs_path(golem_wd, ".github", ".gitignore")
136+
137+
if (!fs_file_exists(where)) {
138+
writeLines("*.html", con = where)
139+
return(invisible(where))
140+
}
141+
142+
content <- readLines(where, warn = FALSE)
143+
144+
if (!"*.html" %in% content) {
145+
writeLines(
146+
c(content, "*.html"),
147+
con = where
148+
)
149+
}
150+
151+
return(invisible(where))
152+
}

R/bootstrap_desc.R

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ desc_get_deps <- function(
3939
)
4040
}
4141

42+
desc_set_dep <- function(
43+
package,
44+
type = "Imports",
45+
file = NULL
46+
) {
47+
check_desc_installed()
48+
desc::desc_set_dep(
49+
package = package,
50+
type = type,
51+
file = file
52+
)
53+
}
54+
4255
desc_get_field <- function(
4356
key
4457
) {

inst/shinyexample/dev/02_dev.R

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@ covrpage::covrpage()
6565
usethis::use_github()
6666

6767
# GitHub Actions
68-
usethis::use_github_action()
69-
# Chose one of the three
68+
# Add a Posit Connect deployment workflow for Shiny apps
69+
golem::add_github_action()
70+
# Or use the generic usethis GitHub Actions helpers
71+
# Choose one of the three
7072
# See https://usethis.r-lib.org/reference/use_github_action.html
7173
usethis::use_github_action_check_release()
7274
usethis::use_github_action_check_standard()
@@ -82,7 +84,8 @@ usethis::use_circleci_badge()
8284
usethis::use_jenkins()
8385

8486
# GitLab CI
85-
usethis::use_gitlab_ci()
87+
# Add a Posit Connect deployment pipeline for Shiny apps
88+
golem::add_gitlab_ci()
8689

8790
# You're now set! ----
8891
# go to dev/03_deploy.R
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
2+
# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
3+
on:
4+
push:
5+
branches: [main, master]
6+
7+
name: shiny-deploy.yaml
8+
9+
permissions: read-all
10+
11+
jobs:
12+
shiny-deploy:
13+
runs-on: ubuntu-latest
14+
env:
15+
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
16+
APPNAME: __APPNAME__
17+
CONNECT_SERVER: ${{ secrets.CONNECT_SERVER }}
18+
CONNECT_API_KEY: ${{ secrets.CONNECT_API_KEY }}
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: r-lib/actions/setup-pandoc@v2
23+
24+
- uses: r-lib/actions/setup-r@v2
25+
with:
26+
use-public-rspm: true
27+
28+
- uses: r-lib/actions/setup-renv@v2
29+
if: ${{ hashFiles('renv.lock') != '' }}
30+
31+
- uses: r-lib/actions/setup-r-dependencies@v2
32+
if: ${{ hashFiles('renv.lock') == '' }}
33+
with:
34+
extra-packages: any::rsconnect, local::.
35+
needs: shiny
36+
37+
- name: Install deployment tooling
38+
if: ${{ hashFiles('renv.lock') != '' }}
39+
run: |
40+
install.packages("rsconnect")
41+
shell: Rscript {0}
42+
43+
- name: Check deployment dependencies
44+
run: |
45+
required <- c("pkgload", "rsconnect")
46+
missing <- required[
47+
!vapply(required, requireNamespace, logical(1), quietly = TRUE)
48+
]
49+
if (length(missing) > 0) {
50+
stop(
51+
sprintf(
52+
paste(
53+
"Missing deployment packages: %s.",
54+
"Run renv::snapshot() after generating this workflow",
55+
"if you use renv."
56+
),
57+
paste(missing, collapse = ", ")
58+
),
59+
call. = FALSE
60+
)
61+
}
62+
shell: Rscript {0}
63+
64+
- name: Validate deployment settings
65+
run: |
66+
required <- c("CONNECT_SERVER", "CONNECT_API_KEY")
67+
missing <- required[vapply(required, function(x) Sys.getenv(x) == "", logical(1))]
68+
if (length(missing) > 0) {
69+
stop(
70+
sprintf(
71+
"Missing GitHub Actions secrets: %s",
72+
paste(missing, collapse = ", ")
73+
),
74+
call. = FALSE
75+
)
76+
}
77+
if (Sys.getenv("APPNAME") == "") {
78+
stop(
79+
"Set APPNAME in the workflow before deploying.",
80+
call. = FALSE
81+
)
82+
}
83+
shell: Rscript {0}
84+
85+
- name: Generate deployment manifest
86+
run: |
87+
rsconnect::writeManifest(appDir = ".", appPrimaryDoc = "app.R")
88+
shell: Rscript {0}
89+
90+
- name: Authorize and deploy app
91+
run: |
92+
rsconnect::connectApiUser(server = Sys.getenv("CONNECT_SERVER"), apiKey = Sys.getenv("CONNECT_API_KEY"), quiet = TRUE)
93+
rsconnect::deployApp(appDir = ".", appPrimaryDoc = "app.R", appName = Sys.getenv("APPNAME"), server = Sys.getenv("CONNECT_SERVER"), forceUpdate = TRUE)
94+
shell: Rscript {0}

inst/utils/gitlab-ci-template.yml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
stages:
2+
- deploy
3+
4+
deploy-shiny:
5+
stage: deploy
6+
image: rocker/verse:4.5.3
7+
variables:
8+
APPNAME: __APPNAME__
9+
rules:
10+
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
11+
before_script:
12+
- |
13+
Rscript - <<'RSCRIPT'
14+
if (file.exists("renv.lock")) {
15+
install.packages("renv", repos = "https://cran.rstudio.com")
16+
renv::restore(prompt = FALSE)
17+
install.packages("rsconnect", repos = "https://cran.rstudio.com")
18+
} else {
19+
install.packages(
20+
c("remotes", "rsconnect"),
21+
repos = "https://cran.rstudio.com"
22+
)
23+
remotes::install_deps(
24+
dependencies = TRUE,
25+
repos = "https://cran.rstudio.com"
26+
)
27+
remotes::install_local(
28+
".",
29+
dependencies = FALSE,
30+
upgrade = "never",
31+
repos = "https://cran.rstudio.com"
32+
)
33+
}
34+
RSCRIPT
35+
script:
36+
- |
37+
Rscript - <<'RSCRIPT'
38+
required <- c("pkgload", "rsconnect")
39+
missing <- required[
40+
!vapply(required, requireNamespace, logical(1), quietly = TRUE)
41+
]
42+
if (length(missing) > 0) {
43+
stop(
44+
sprintf(
45+
paste(
46+
"Missing deployment packages: %s.",
47+
"Run renv::snapshot() after generating this workflow",
48+
"if you use renv."
49+
),
50+
paste(missing, collapse = ", ")
51+
),
52+
call. = FALSE
53+
)
54+
}
55+
RSCRIPT
56+
- |
57+
Rscript - <<'RSCRIPT'
58+
required <- c("CONNECT_SERVER", "CONNECT_API_KEY")
59+
missing <- required[
60+
vapply(required, function(x) Sys.getenv(x) == "", logical(1))
61+
]
62+
if (length(missing) > 0) {
63+
stop(
64+
sprintf(
65+
"Missing CI variables: %s",
66+
paste(missing, collapse = ", ")
67+
),
68+
call. = FALSE
69+
)
70+
}
71+
if (Sys.getenv("APPNAME") == "") {
72+
stop("Set APPNAME before deploying.", call. = FALSE)
73+
}
74+
RSCRIPT
75+
- |
76+
Rscript - <<'RSCRIPT'
77+
rsconnect::writeManifest(appDir = ".", appPrimaryDoc = "app.R")
78+
RSCRIPT
79+
- |
80+
Rscript - <<'RSCRIPT'
81+
rsconnect::connectApiUser(
82+
server = Sys.getenv("CONNECT_SERVER"),
83+
apiKey = Sys.getenv("CONNECT_API_KEY"),
84+
quiet = TRUE
85+
)
86+
rsconnect::deployApp(
87+
appDir = ".",
88+
appPrimaryDoc = "app.R",
89+
appName = Sys.getenv("APPNAME"),
90+
server = Sys.getenv("CONNECT_SERVER"),
91+
forceUpdate = TRUE
92+
)
93+
RSCRIPT

0 commit comments

Comments
 (0)