Skip to content

Commit 5ece5d7

Browse files
committed
Create a CRAN-like binary distribution repository.
Use a docs directory 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 5ece5d7

3 files changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
23+
- name: Set up R
24+
uses: r-lib/actions/setup-r@a51a8012b0aab7c32ef9d19bf54da93f3254335e # v2.12.0
25+
26+
- name: Download Release Assets
27+
env:
28+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29+
run: |
30+
mkdir -p temp_packages
31+
gh release download ${{ github.event.release.tag_name }} --dir temp_packages
32+
33+
- name: Route Assets and Build PACKAGES Files
34+
shell: bash
35+
run: |
36+
# Run the R script and always do the cleanup of the temp_packages directory,
37+
# step returns the exit code from the R script.
38+
(
39+
Rscript update_cran_repo.R \
40+
--packages_dir temp_packages \
41+
--base_cran_dir docs \
42+
--repo_url https://github.com/${{ github.repository }} \
43+
--tag ${{ github.event.release.tag_name }}
44+
status=$?
45+
rm -rf temp_packages
46+
exit $status
47+
)
48+
49+
- name: Commit and Push Changes
50+
run: |
51+
git config --global user.name "github-actions[bot]"
52+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
53+
git add docs/
54+
if ! git diff --cached --quiet; then
55+
git commit -m "Update CRAN repository index for release ${{ github.event.release.tag_name }}"
56+
git push
57+
fi

docs/.gitkeep

Whitespace-only changes.

update_cran_repo.R

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

0 commit comments

Comments
 (0)