Skip to content

Commit bc4355a

Browse files
committed
Create a CRAN-like binary distribution repository.
Use a gh-pages branch for GitHub pages hosting of a CRAN-like repository. When a release is created, there is a workflow that automatically updates the CRAN-like repository using the information from the release assets. Note that the CRAN-like repository does not include the binary packages themselves, it forwards to the URLs of the release assets on GitHub.
1 parent 9cfc3bd commit bc4355a

3 files changed

Lines changed: 311 additions & 2 deletions

File tree

.github/workflows/main.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,19 @@ jobs:
161161
draft: true
162162
name: SimpleITK ${{ github.ref_name }} R Package Release
163163
body: |
164-
Please review and test the packages before publishing this release.
165-
Then remove the "Draft" status to make it public and delete this message.
164+
**Please review and test the packages before publishing this release. Then remove this line and make it public.**
165+
166+
Install from the SimpleITK CRAN-like repository hosted on GitHub Pages:
167+
168+
```r
169+
install.packages(
170+
"SimpleITK",
171+
repos = c("https://simpleitk.github.io/SimpleITKRInstaller/${{ github.ref_name }}"),
172+
type = "binary"
173+
)
174+
```
175+
176+
The GitHub Pages repository serves platform-specific binaries. If a binary is not available, you will need to build SimpleITK locally using the remotes installer. See the [README](https://github.com/${{ github.repository }}/blob/main/README.md) for detailed instructions.
166177
files: |
167178
release-artifacts/*
168179
fail_on_unmatched_files: true
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Update CRAN Repository
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
permissions:
8+
contents: write
9+
10+
concurrency:
11+
group: update-cran-repository
12+
cancel-in-progress: false
13+
14+
jobs:
15+
update-repo:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
20+
with:
21+
fetch-depth: 0
22+
ref: main
23+
24+
- name: Set up R
25+
uses: r-lib/actions/setup-r@a51a8012b0aab7c32ef9d19bf54da93f3254335e # v2.12.0
26+
27+
- name: Download Release Assets
28+
env:
29+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30+
run: |
31+
mkdir -p temp_packages
32+
gh release download ${{ github.event.release.tag_name }} --dir temp_packages
33+
34+
- name: Route Assets and Build PACKAGES Files
35+
shell: bash
36+
run: |
37+
# Run the R script and always do the cleanup of the temp_packages directory,
38+
# step returns the exit code from the R script.
39+
(
40+
Rscript update_cran_repo.R \
41+
--packages_dir temp_packages \
42+
--base_cran_dir . \
43+
--repo_url https://github.com/${{ github.repository }} \
44+
--tag ${{ github.event.release.tag_name }}
45+
status=$?
46+
rm -rf temp_packages
47+
exit $status
48+
)
49+
50+
- name: Commit and Push Changes
51+
run: |
52+
git config --global user.name "github-actions[bot]"
53+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
54+
git checkout gh-pages
55+
git add .
56+
if ! git diff --cached --quiet; then
57+
git commit -m "Update CRAN repository index for release ${{ github.event.release.tag_name }}"
58+
git push
59+
fi

update_cran_repo.R

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
#!/usr/bin/env Rscript
2+
3+
# Script to update CRAN-like repository with a package release which points
4+
# to GitHub release assets. This allows us to maintain a CRAN-like website
5+
# for users to easily install packages while hosting the actual binaries
6+
# on GitHub releases. The script merges new package entries with existing
7+
# ones in the PACKAGES file or creates it if it doesn't exist.
8+
#
9+
# CRAN like directory structure using the posit style structure for linux, treat
10+
# it as if it were a source distribution and not binary
11+
# (https://docs.posit.co/rspm/admin/serving-binaries.html).
12+
# CRAN does not distribute linux binaries, so nothing to mimic there.
13+
# Each version gets its own directory under the base CRAN directory,
14+
#
15+
# vMAJOR.MINOR.PATCH/
16+
#
17+
# ├── __linux__/ (using posit structure for source packages)
18+
# │ ├── ubuntu-jammy/
19+
# │ │ └── src/
20+
# │ │ └── contrib/
21+
# │ │ ├── PACKAGES
22+
# │ │ ├── PACKAGES.gz
23+
# │ │ ├── PACKAGES.rds
24+
# │ │ └── package_version.tar.gz (omitted in our case)
25+
# │ └── rhel-9/
26+
# │ └── src/
27+
# │ └── contrib/
28+
# │ ├── PACKAGES
29+
# │ ├── PACKAGES.gz
30+
# │ ├── PACKAGES.rds
31+
# │ └── package_version.tar.gz (omitted in our case)
32+
#
33+
# ├── bin/
34+
# ├── windows/
35+
# │ └── contrib/
36+
# │ └── 4.4/
37+
# │ ├── PACKAGES
38+
# │ ├── PACKAGES.gz
39+
# │ ├── PACKAGES.rds
40+
# │ └── package_version.zip (omitted in our case)
41+
# └── macosx/
42+
# ├── contrib/ (intel)
43+
# │ └── 4.4/
44+
# │ ├── PACKAGES
45+
# │ ├── PACKAGES.gz
46+
# │ ├── PACKAGES.rds
47+
# │ └── package_version.tgz (omitted in our case)
48+
# ├── big-sur-arm64/
49+
# │ └── contrib/
50+
# │ └── 4.4/
51+
# │ ├── PACKAGES
52+
# │ ├── PACKAGES.gz
53+
# │ ├── PACKAGES.rds
54+
# │ └── package_version.tgz (omitted in our case)
55+
# └── sonoma-arm64/
56+
# └── contrib/
57+
# └── 4.6/
58+
# ├── PACKAGES
59+
# ├── PACKAGES.gz
60+
# ├── PACKAGES.rds
61+
# └── package_version.tgz (omitted in our case)
62+
#
63+
#
64+
# Usage:
65+
# Rscript update_cran_repo.R --packages_dir <path> --base_cran_dir <path> --repo_url <url> --tag <tag>
66+
#
67+
# Arguments:
68+
# --packages_dir Directory containing downloaded package files. The package
69+
# files are expected to be named in the format:
70+
# SimpleITK_{VERSION}_R{R_VERSION}_{PLATFORM}.{extension}
71+
# --base_cran_dir Base directory for CRAN-like repository (e.g., "docs" for a GitHub pages site)
72+
# --repo_url URL of the GitHub repository storing the binary files as release assets (e.g., https://github.com/user/repo)
73+
# --tag Release tag name (e.g. v2.5.5)
74+
#
75+
# Example:
76+
# Rscript update_cran_repo.R \
77+
# --packages_dir temp_packages \
78+
# --base_cran_dir docs \
79+
# --repo_url https://github.com/SimpleITK/SimpleITKRInstaller \
80+
# --tag v2.5.5
81+
82+
library(tools)
83+
84+
# Utility function to merge and sort package entries
85+
merge_packages <- function(existing_packages, new_package, new_url, version) {
86+
# Replace or add "File" field from write_PACKAGES with release URL,
87+
# ensures PACKAGES has a single File entry.
88+
if (!is.null(colnames(new_package)) && "File" %in% colnames(new_package)) {
89+
new_package <- new_package[, colnames(new_package) != "File", drop = FALSE]
90+
}
91+
new_package <- cbind(new_package, File = new_url)
92+
93+
# Combine with existing packages
94+
if (!is.null(existing_packages) && nrow(existing_packages) > 0) {
95+
# Remove any existing entry for the same version (to handle re-releases)
96+
existing_packages <- existing_packages[existing_packages[, "Version"] != version, , drop = FALSE]
97+
all_packages <- rbind(new_package, existing_packages)
98+
} else {
99+
all_packages <- new_package
100+
}
101+
102+
# Sort by version (newest first)
103+
if (nrow(all_packages) > 1) {
104+
versions <- package_version(all_packages[, "Version"])
105+
all_packages <- all_packages[order(versions, decreasing = TRUE), , drop = FALSE]
106+
}
107+
108+
return(all_packages)
109+
}
110+
111+
# Parse command-line arguments in --key value format
112+
# Takes a character vector of arguments and returns a named list
113+
# Validates that all required arguments are present and that no consecutive keys are present
114+
# Example: c("--packages_dir", "temp", "--tag", "v1.0") -> list("packages_dir" = "temp", "tag" = "v1.0")
115+
parse_args <- function(args, required_args = NULL) {
116+
parsed <- list()
117+
i <- 1
118+
while (i <= length(args)) {
119+
if (startsWith(args[i], "--")) {
120+
key <- sub("^--", "", args[i])
121+
if (i < length(args) && !startsWith(args[i + 1], "--")) {
122+
parsed[[key]] <- args[i + 1]
123+
i <- i + 2
124+
} else {
125+
stop(sprintf("Missing or invalid value for argument: --%s", key))
126+
}
127+
} else {
128+
stop(sprintf("Unexpected argument format: %s (expected a key starting with --)", args[i]))
129+
}
130+
}
131+
# Validate required arguments
132+
if (!is.null(required_args)) {
133+
missing_args <- setdiff(required_args, names(parsed))
134+
if (length(missing_args) > 0) {
135+
stop("Missing required arguments: ", paste(missing_args, collapse = ", "))
136+
}
137+
}
138+
return(parsed)
139+
}
140+
141+
142+
143+
# Package platform to [repository path, type] mapping
144+
platform_map <- list(
145+
"windows-x86_64" = list(path = "bin/windows/contrib", type = "win.binary"),
146+
"macos-x86_64" = list(path = "bin/macosx/contrib", type = "mac.binary"),
147+
"macos-arm64" = list(path = "bin/macosx/big-sur-arm64/contrib", type = "mac.binary"),
148+
"linux-x86_64" = list(path = "__linux__/jammy/src/contrib", type = "source")
149+
)
150+
151+
# Expected package filename pattern
152+
package_pattern <- "^SimpleITK_([^_]+)_R([0-9]+\\.[0-9]+)_([^\\.]+)\\.(.*)$"
153+
154+
# Parse command line arguments
155+
args <- commandArgs(trailingOnly = TRUE)
156+
parsed_args <- parse_args(args, required_args = c("packages_dir", "base_cran_dir", "repo_url", "tag"))
157+
158+
packages_dir <- parsed_args[["packages_dir"]]
159+
base_cran_dir <- parsed_args[["base_cran_dir"]]
160+
repo_url <- parsed_args[["repo_url"]]
161+
tag <- parsed_args[["tag"]]
162+
effective_base_cran_dir <- file.path(base_cran_dir, tag)
163+
164+
# Validate packages directory exists
165+
if (!dir.exists(packages_dir)) {
166+
quit(save = "no", status = 0)
167+
}
168+
169+
# Get all files in packages_dir that match the expected pattern
170+
all_files <- list.files(packages_dir, full.names = TRUE)
171+
files <- all_files[grepl(package_pattern, basename(all_files))]
172+
173+
for (file in files) {
174+
tryCatch({
175+
filename <- basename(file)
176+
177+
matches <- regmatches(filename, regexec(package_pattern, filename))[[1]]
178+
version <- matches[2]
179+
r_version <- matches[3]
180+
platform <- matches[4]
181+
extension <- matches[5]
182+
183+
if (!platform %in% names(platform_map)) {
184+
message("Unknown platform: ", platform)
185+
next
186+
}
187+
188+
# Create destination directory
189+
dest_dir <- file.path(effective_base_cran_dir, platform_map[[platform]]$path, r_version)
190+
dir.create(dest_dir, recursive = TRUE, showWarnings = FALSE)
191+
192+
# Read existing PACKAGES file BEFORE it gets overwritten
193+
packages_file <- file.path(dest_dir, "PACKAGES")
194+
existing_packages <- if (file.exists(packages_file)) {
195+
read.dcf(packages_file)
196+
} else {
197+
NULL
198+
}
199+
200+
# Copy file temporarily to generate PACKAGES metadata
201+
cleaned_name <- sprintf("SimpleITK_%s.%s", version, extension)
202+
temp_dest <- file.path(dest_dir, cleaned_name)
203+
if (!file.copy(file, temp_dest, overwrite = TRUE)) {
204+
stop(sprintf("Failed to copy %s to %s", file, temp_dest))
205+
}
206+
207+
# Generate PACKAGES file with metadata from the binary (this overwrites existing)
208+
write_PACKAGES(dest_dir, type = platform_map[[platform]]$type, latestOnly = FALSE)
209+
210+
# Read the newly generated package entry
211+
new_package <- read.dcf(packages_file)
212+
213+
# Merge with existing packages and sort, also adds
214+
# the File field using GitHub URL
215+
merged_packages <- merge_packages(existing_packages,
216+
new_package,
217+
sprintf("%s/releases/download/%s/%s", repo_url, tag, filename),
218+
version)
219+
220+
# Write merged PACKAGES file
221+
write.dcf(merged_packages, packages_file)
222+
223+
# Generate compressed version
224+
gzf <- gzfile(file.path(dest_dir, "PACKAGES.gz"), "w")
225+
write.dcf(merged_packages, gzf)
226+
close(gzf)
227+
228+
# Generate PACKAGES.rds version
229+
saveRDS(merged_packages, file.path(dest_dir, "PACKAGES.rds"), version = 2)
230+
231+
# Remove the temporary binary package file from the CRAN-like directory
232+
unlink(temp_dest)
233+
}, error = function(e) {
234+
message(sprintf("Error processing file %s: %s", file, e$message))
235+
quit(save = "no", status = 1)
236+
})
237+
}
238+
239+

0 commit comments

Comments
 (0)