Skip to content

Commit 7bb4041

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 7bb4041

3 files changed

Lines changed: 339 additions & 3 deletions

File tree

.github/workflows/main.yml

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
fail-fast: false
2525
matrix:
2626
R: [ '4.4.3', '4.5.3', '4.6.0' ]
27-
os: [ 'macos-15-intel', 'ubuntu-latest', 'windows-latest']
27+
os: [ 'macos-15-intel', 'ubuntu-24.04', 'windows-latest']
2828
runs-on: ${{ matrix.os }}
2929
name: ${{ matrix.R }} ${{ matrix.os }} build
3030

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

0 commit comments

Comments
 (0)