feat(local): auto-apply registry patches with a build-env trial-build gate #124
7 changed files with 633 additions and 2 deletions
65
.crow/auto-apply-patches.yaml
Normal file
65
.crow/auto-apply-patches.yaml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Auto-apply registry patches (issue #115, step 3 automation).
|
||||||
|
# Classifies `single_builds` failures and, for the top-N auto-proposable
|
||||||
|
# candidates by failure volume, writes the registry entries onto the reused
|
||||||
|
# `auto/registry-patch-proposals` branch and opens/updates a single PR.
|
||||||
|
# Nothing merges: the `trial-build-registry` pipeline is the merge gate, and a
|
||||||
|
# human reviews the PR. Novel source diffs / unknown signatures are never
|
||||||
|
# proposed. Global across platforms, so a single job -- no matrix.
|
||||||
|
#
|
||||||
|
# Needs a write token (REPO_RW_TOKEN) to push and FORGEJO_TOKEN to open the PR.
|
||||||
|
# Register the `auto-apply-patches` cron in the crow UI, or run manually:
|
||||||
|
# woodpecker-cli pipeline create --var task=auto-apply-patches --branch=main 7
|
||||||
|
variables:
|
||||||
|
patch_limit:
|
||||||
|
description: 'Max candidates to propose per run (top by failure volume).'
|
||||||
|
default: '10'
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: manual
|
||||||
|
evaluate: 'task == "auto-apply-patches"'
|
||||||
|
- event: cron
|
||||||
|
cron: auto-apply-patches
|
||||||
|
|
||||||
|
skip_clone: true
|
||||||
|
|
||||||
|
labels:
|
||||||
|
group: rpkgs-amd64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 'Auto-apply registry patches'
|
||||||
|
image: reg.devxy.io/rpkgs/build-env-alpine:3.24
|
||||||
|
pull: true
|
||||||
|
environment:
|
||||||
|
PGPASS:
|
||||||
|
from_secret: PGPASS
|
||||||
|
REPO_RO_TOKEN:
|
||||||
|
from_secret: REPO_RO_TOKEN
|
||||||
|
REPO_RW_TOKEN:
|
||||||
|
from_secret: REPO_RW_TOKEN
|
||||||
|
FORGEJO_TOKEN:
|
||||||
|
from_secret: FORGEJO_TOKEN
|
||||||
|
GIT_USER: devxy-bot
|
||||||
|
GIT_EMAIL: bot@devxy.io
|
||||||
|
PATCH_LIMIT: ${patch_limit}
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
R_LIBS_USER: /mnt/cache/R-pkgs
|
||||||
|
commands:
|
||||||
|
- git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git .
|
||||||
|
- mkdir -p /mnt/cache/R-pkgs
|
||||||
|
- rm -rf /mnt/cache/R-pkgs/00LOCK-*
|
||||||
|
- /opt/R/$R_VERSION/bin/R -q -e 'pak::pak(c("httr2", "jsonlite"))'
|
||||||
|
- /opt/R/$R_VERSION/bin/Rscript local/propose-patches.R --open-pr --limit $PATCH_LIMIT
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 1Gi
|
||||||
|
cpu: 2000m
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2000m
|
||||||
|
tolerations:
|
||||||
|
- key: 'CI'
|
||||||
|
operator: 'Equal'
|
||||||
|
value: 'true'
|
||||||
|
effect: 'NoSchedule'
|
||||||
140
.crow/trial-build-registry.yaml
Normal file
140
.crow/trial-build-registry.yaml
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
# Merge gate for the auto-patch PR (issue #115, step 3).
|
||||||
|
# For each platform, trial-builds every registry entry the auto-patch branch
|
||||||
|
# ADDS (vs main) in that platform's own `reg.devxy.io/rpkgs/build-env-*` image,
|
||||||
|
# with the registry applied. A row with no new entries for its platform is a
|
||||||
|
# fast no-op. The pipeline is green only if every new entry builds, so it gates
|
||||||
|
# the PR before merge. Nothing is uploaded/archived/recorded.
|
||||||
|
#
|
||||||
|
# The repo uses no `pull_request` triggers, so this runs manually against the
|
||||||
|
# branch (or on a cron); point it at the auto-patch branch via `patch_branch`:
|
||||||
|
# woodpecker-cli pipeline create --var task=trial-build-registry \
|
||||||
|
# --var patch_branch=auto/registry-patch-proposals --branch=main 7
|
||||||
|
variables:
|
||||||
|
patch_branch:
|
||||||
|
description: 'Branch whose new registry entries to trial-build.'
|
||||||
|
default: auto/registry-patch-proposals
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: manual
|
||||||
|
evaluate: 'task == "trial-build-registry"'
|
||||||
|
- event: cron
|
||||||
|
cron: trial-build-registry
|
||||||
|
|
||||||
|
skip_clone: true
|
||||||
|
|
||||||
|
labels:
|
||||||
|
group: rpkgs-${ARCH}
|
||||||
|
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- OS: alpine-322
|
||||||
|
ARCH: amd64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: alpine:3.24
|
||||||
|
- OS: alpine-322
|
||||||
|
ARCH: arm64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: alpine:3.24
|
||||||
|
- OS: alpine-323
|
||||||
|
ARCH: amd64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: alpine:3.24
|
||||||
|
- OS: alpine-323
|
||||||
|
ARCH: arm64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: alpine:3.24
|
||||||
|
- OS: alpine-324
|
||||||
|
ARCH: amd64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: alpine:3.24
|
||||||
|
- OS: alpine-324
|
||||||
|
ARCH: arm64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: alpine:3.24
|
||||||
|
- OS: redhat-8
|
||||||
|
ARCH: amd64
|
||||||
|
R_VERSION: 4.4.3
|
||||||
|
IMG: redhat:8
|
||||||
|
- OS: redhat-8
|
||||||
|
ARCH: arm64
|
||||||
|
R_VERSION: 4.4.3
|
||||||
|
IMG: redhat:8
|
||||||
|
- OS: redhat-9
|
||||||
|
ARCH: amd64
|
||||||
|
R_VERSION: 4.4.3
|
||||||
|
IMG: redhat:9
|
||||||
|
- OS: redhat-9
|
||||||
|
ARCH: arm64
|
||||||
|
R_VERSION: 4.4.3
|
||||||
|
IMG: redhat:9
|
||||||
|
- OS: redhat-10
|
||||||
|
ARCH: amd64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: redhat:10
|
||||||
|
- OS: redhat-10
|
||||||
|
ARCH: arm64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: redhat:10
|
||||||
|
- OS: ubuntu-2204
|
||||||
|
ARCH: amd64
|
||||||
|
R_VERSION: 4.4.3
|
||||||
|
IMG: ubuntu:jammy
|
||||||
|
- OS: ubuntu-2204
|
||||||
|
ARCH: arm64
|
||||||
|
R_VERSION: 4.4.3
|
||||||
|
IMG: ubuntu:jammy
|
||||||
|
- OS: ubuntu-2404
|
||||||
|
ARCH: amd64
|
||||||
|
R_VERSION: 4.4.3
|
||||||
|
IMG: ubuntu:noble
|
||||||
|
- OS: ubuntu-2404
|
||||||
|
ARCH: arm64
|
||||||
|
R_VERSION: 4.4.3
|
||||||
|
IMG: ubuntu:noble
|
||||||
|
- OS: ubuntu-2604
|
||||||
|
ARCH: amd64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: ubuntu:resolute
|
||||||
|
- OS: ubuntu-2604
|
||||||
|
ARCH: arm64
|
||||||
|
R_VERSION: 4.5.3
|
||||||
|
IMG: ubuntu:resolute
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 'Trial-build new registry entries'
|
||||||
|
image: reg.devxy.io/rpkgs/build-env-${IMG}
|
||||||
|
pull: true
|
||||||
|
environment:
|
||||||
|
B2_S3_ACCESS_KEY:
|
||||||
|
from_secret: B2_S3_ACCESS_KEY
|
||||||
|
B2_S3_SECRET_KEY:
|
||||||
|
from_secret: B2_S3_SECRET_KEY
|
||||||
|
REPO_RO_TOKEN:
|
||||||
|
from_secret: REPO_RO_TOKEN
|
||||||
|
GITHUB_PAT:
|
||||||
|
from_secret: GITHUB_PAT
|
||||||
|
PLATFORM: ${OS}
|
||||||
|
ARCH: ${ARCH}
|
||||||
|
R_VERSION: ${R_VERSION}
|
||||||
|
R_LIBS_USER: /mnt/cache/R-pkgs
|
||||||
|
commands:
|
||||||
|
- git clone -q --branch ${patch_branch} https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git .
|
||||||
|
- git fetch -q origin main
|
||||||
|
- mkdir -p /mnt/cache/R-pkgs
|
||||||
|
- rm -rf /mnt/cache/R-pkgs/00LOCK-*
|
||||||
|
- /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R
|
||||||
|
- /opt/R/$R_VERSION/bin/Rscript local/trial-build-registry.R origin/main
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2000m
|
||||||
|
limits:
|
||||||
|
memory: 4Gi
|
||||||
|
cpu: 2000m
|
||||||
|
tolerations:
|
||||||
|
- key: 'CI'
|
||||||
|
operator: 'Equal'
|
||||||
|
value: 'true'
|
||||||
|
effect: 'NoSchedule'
|
||||||
|
|
@ -81,6 +81,19 @@ Run the trial build inside the failing platform's build-env image; it uploads/ar
|
||||||
Rscript local/trial-build-patch.R <package>
|
Rscript local/trial-build-patch.R <package>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Autonomous PR + trial-build gate
|
||||||
|
|
||||||
|
`--open-pr` closes the loop: it writes the top-N candidates (by failure volume) onto the reused `auto/registry-patch-proposals` branch and opens/updates a single PR.
|
||||||
|
`.crow/auto-apply-patches.yaml` runs this on a cron (needs `FORGEJO_TOKEN` and a write-scoped `REPO_RW_TOKEN`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bounded batch; opens/updates one PR.
|
||||||
|
PGPASS=... FORGEJO_TOKEN=... REPO_RW_TOKEN=... Rscript local/propose-patches.R --open-pr --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
The merge gate is `.crow/trial-build-registry.yaml`: matrixed over the build-env images, each platform trial-builds only the entries the branch **adds** (`local/trial-build-registry.R`, which diffs the registry against `main`) and is green only if every new entry builds.
|
||||||
|
The repo uses no `pull_request` triggers, so this gate runs manually or on a cron against the auto-patch branch (`--var patch_branch=...`); wiring it to fire on the PR needs `event: pull_request` enabled on the forge.
|
||||||
|
|
||||||
`--write` and `--open-issue` also append to `local/patches/proposals-log.json`, a ledger of what was proposed.
|
`--write` and `--open-issue` also append to `local/patches/proposals-log.json`, a ledger of what was proposed.
|
||||||
|
|
||||||
### Feedback loop (step 4)
|
### Feedback loop (step 4)
|
||||||
|
|
|
||||||
|
|
@ -175,4 +175,37 @@ blocked_summary <- function(report, max_pkgs = 15L) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Does a registry entry's `platforms` apply to a build on `os` (e.g.
|
||||||
|
# "ubuntu-2604")? Mirrors bincraft's token match: an entry applies if any of its
|
||||||
|
# platform tokens is "*", the OS codename, or the distro family ("ubuntu").
|
||||||
|
entry_applies_to_os <- function(entry_platforms, os) {
|
||||||
|
toks <- as.character(unlist(entry_platforms))
|
||||||
|
family <- sub("-.*$", "", os) # ubuntu-2604 -> ubuntu
|
||||||
|
any(toks %in% c("*", os, family))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Registry entries present in `current` but not in `base` (matched on
|
||||||
|
# package|platforms|versions), optionally restricted to those that apply to a
|
||||||
|
# given `os`. Used by the trial-build gate to build only the entries a PR adds.
|
||||||
|
new_registry_packages <- function(current, base, os = NULL) {
|
||||||
|
key <- function(e) {
|
||||||
|
sprintf(
|
||||||
|
"%s|%s|%s",
|
||||||
|
e$package %||% "?",
|
||||||
|
paste(sort(as.character(unlist(e$platforms))), collapse = ","),
|
||||||
|
e$versions %||% "?"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
base_keys <- vapply(base %||% list(), key, character(1L))
|
||||||
|
added <- Filter(function(e) !(key(e) %in% base_keys), current %||% list())
|
||||||
|
if (!is.null(os)) {
|
||||||
|
added <- Filter(function(e) entry_applies_to_os(e$platforms, os), added)
|
||||||
|
}
|
||||||
|
unique(vapply(
|
||||||
|
added,
|
||||||
|
function(e) as.character(e$package %||% ""),
|
||||||
|
character(1L)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
`%||%` <- function(a, b) if (is.null(a)) b else a
|
`%||%` <- function(a, b) if (is.null(a)) b else a
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,17 @@
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# PGPASS=... Rscript local/propose-patches.R [--platform P] [--arch A] [--min N]
|
# PGPASS=... Rscript local/propose-patches.R [--platform P] [--arch A] [--min N]
|
||||||
|
# [--limit N]
|
||||||
# (default) print candidates + validation, take no action
|
# (default) print candidates + validation, take no action
|
||||||
# --write append candidates to local/patches/registry.json and
|
# --write append candidates to local/patches/registry.json and
|
||||||
# the proposals ledger (commit + open a PR yourself)
|
# the proposals ledger (commit + open a PR yourself)
|
||||||
# --open-issue post/update a Forgejo tracking issue (needs FORGEJO_TOKEN)
|
# --open-issue post/update a Forgejo tracking issue (needs FORGEJO_TOKEN)
|
||||||
|
# --open-pr write the entries, push the `auto/registry-patch-proposals`
|
||||||
|
# branch, and open/update a PR autonomously (needs
|
||||||
|
# FORGEJO_TOKEN, and REPO_RW_TOKEN to push in CI). The
|
||||||
|
# `trial-build-registry` pipeline is the merge gate.
|
||||||
|
# --limit N only act on the top-N candidates by failure volume
|
||||||
|
# (bounded batch; the rest are picked up on the next run)
|
||||||
# --json PATH also write the machine-readable candidate list to PATH
|
# --json PATH also write the machine-readable candidate list to PATH
|
||||||
|
|
||||||
options(error = function() {
|
options(error = function() {
|
||||||
|
|
@ -58,6 +65,8 @@ if (is.na(min_count)) {
|
||||||
}
|
}
|
||||||
do_write <- "--write" %in% args
|
do_write <- "--write" %in% args
|
||||||
do_issue <- "--open-issue" %in% args
|
do_issue <- "--open-issue" %in% args
|
||||||
|
do_pr <- "--open-pr" %in% args
|
||||||
|
limit <- suppressWarnings(as.integer(opt_val("--limit", NA_character_)))
|
||||||
|
|
||||||
if (nchar(Sys.getenv("PGPASS")) == 0L) {
|
if (nchar(Sys.getenv("PGPASS")) == 0L) {
|
||||||
stop("PGPASS env var is not set; a DB password is required.")
|
stop("PGPASS env var is not set; a DB password is required.")
|
||||||
|
|
@ -65,6 +74,8 @@ if (nchar(Sys.getenv("PGPASS")) == 0L) {
|
||||||
|
|
||||||
registry_file <- file.path(script_dir, "patches", "registry.json")
|
registry_file <- file.path(script_dir, "patches", "registry.json")
|
||||||
ledger_file <- file.path(script_dir, "patches", "proposals-log.json")
|
ledger_file <- file.path(script_dir, "patches", "proposals-log.json")
|
||||||
|
# Branch the autonomous PR reuses, so re-runs update one PR instead of piling up.
|
||||||
|
pr_branch <- "auto/registry-patch-proposals"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Query failing builds (same shape as failing-builds-report.R)
|
# Query failing builds (same shape as failing-builds-report.R)
|
||||||
|
|
@ -118,7 +129,8 @@ registered_pkgs <- unique(vapply(
|
||||||
report <- build_triage_report(failures, registered_pkgs)
|
report <- build_triage_report(failures, registered_pkgs)
|
||||||
report <- Filter(function(r) r$build_count >= min_count, report)
|
report <- Filter(function(r) r$build_count >= min_count, report)
|
||||||
|
|
||||||
# Flatten auto-proposable groups into candidate records.
|
# Flatten auto-proposable groups into candidate records, carrying the group's
|
||||||
|
# build volume so we can prioritise the highest-impact fixes.
|
||||||
candidates <- list()
|
candidates <- list()
|
||||||
for (r in report) {
|
for (r in report) {
|
||||||
if (is.null(r$proposed_entries)) {
|
if (is.null(r$proposed_entries)) {
|
||||||
|
|
@ -130,6 +142,7 @@ for (r in report) {
|
||||||
signature = r$signature,
|
signature = r$signature,
|
||||||
confidence = r$confidence,
|
confidence = r$confidence,
|
||||||
tier = r$tier,
|
tier = r$tier,
|
||||||
|
build_count = r$build_count,
|
||||||
entry = r$proposed_entries[[pkg]]
|
entry = r$proposed_entries[[pkg]]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +186,22 @@ if (length(candidates) == 0L) {
|
||||||
q(status = 0)
|
q(status = 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Prioritise by failure volume, then apply --limit so one run tackles a bounded
|
||||||
|
# batch (the rest are picked up on the next run).
|
||||||
|
candidates <- candidates[order(
|
||||||
|
-vapply(candidates, function(c) c$build_count %||% 0L, integer(1L))
|
||||||
|
)]
|
||||||
|
deferred <- 0L
|
||||||
|
if (!is.na(limit) && limit >= 0L && length(candidates) > limit) {
|
||||||
|
deferred <- length(candidates) - limit
|
||||||
|
candidates <- utils::head(candidates, limit)
|
||||||
|
cat(sprintf(
|
||||||
|
"\nLimiting to top %d candidate(s) by failure volume; %d deferred to a later run.\n",
|
||||||
|
limit,
|
||||||
|
deferred
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
cat(sprintf(
|
cat(sprintf(
|
||||||
"\n%d candidate registry %s:\n",
|
"\n%d candidate registry %s:\n",
|
||||||
length(candidates),
|
length(candidates),
|
||||||
|
|
@ -386,8 +415,163 @@ if (do_write) {
|
||||||
cat(sprintf("Opened tracking issue #%d.\n", created$number))
|
cat(sprintf("Opened tracking issue #%d.\n", created$number))
|
||||||
}
|
}
|
||||||
save_ledger(merge_ledger(load_ledger(), new_ledger_records))
|
save_ledger(merge_ledger(load_ledger(), new_ledger_records))
|
||||||
|
} else if (do_pr) {
|
||||||
|
forgejo_token <- Sys.getenv("FORGEJO_TOKEN")
|
||||||
|
if (nchar(forgejo_token) == 0L) {
|
||||||
|
stop("--open-pr requires FORGEJO_TOKEN (to open the PR).")
|
||||||
|
}
|
||||||
|
suppressPackageStartupMessages(library(httr2, quietly = TRUE))
|
||||||
|
forgejo_base <- "https://git.devxy.io/api/v1"
|
||||||
|
repo <- "devxy/build-cran-binaries"
|
||||||
|
|
||||||
|
# Write the entries + ledger, then commit them onto the reused auto branch.
|
||||||
|
jsonlite::write_json(
|
||||||
|
merged,
|
||||||
|
registry_file,
|
||||||
|
auto_unbox = TRUE,
|
||||||
|
pretty = TRUE,
|
||||||
|
null = "null"
|
||||||
|
)
|
||||||
|
save_ledger(merge_ledger(load_ledger(), new_ledger_records))
|
||||||
|
|
||||||
|
git <- function(...) {
|
||||||
|
st <- system2("git", c(...), stdout = TRUE, stderr = TRUE)
|
||||||
|
if (!identical(attr(st, "status"), NULL)) {
|
||||||
|
stop(sprintf(
|
||||||
|
"git %s failed:\n%s",
|
||||||
|
paste(..., collapse = " "),
|
||||||
|
paste(st, collapse = "\n")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
invisible(st)
|
||||||
|
}
|
||||||
|
git("config", "user.name", Sys.getenv("GIT_USER", "devxy-bot"))
|
||||||
|
git(
|
||||||
|
"config",
|
||||||
|
"user.email",
|
||||||
|
Sys.getenv("GIT_EMAIL", "bot@devxy.io")
|
||||||
|
)
|
||||||
|
git("checkout", "-B", pr_branch)
|
||||||
|
git(
|
||||||
|
"add",
|
||||||
|
file.path(script_dir, "patches", "registry.json"),
|
||||||
|
ledger_file
|
||||||
|
)
|
||||||
|
git(
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
sprintf(
|
||||||
|
"feat(patches): auto-propose %d registry %s from classified failures",
|
||||||
|
length(candidate_entries),
|
||||||
|
if (length(candidate_entries) == 1L) "entry" else "entries"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Push with a write token when provided (CI); otherwise rely on origin creds.
|
||||||
|
rw_token <- Sys.getenv("REPO_RW_TOKEN")
|
||||||
|
push_target <- if (nchar(rw_token) > 0L) {
|
||||||
|
sprintf(
|
||||||
|
"https://%s:%s@git.devxy.io/%s.git",
|
||||||
|
Sys.getenv("GIT_USER", "devxy-bot"),
|
||||||
|
rw_token,
|
||||||
|
repo
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"origin"
|
||||||
|
}
|
||||||
|
git("push", "-f", push_target, sprintf("HEAD:refs/heads/%s", pr_branch))
|
||||||
|
|
||||||
|
pr_title <- sprintf(
|
||||||
|
"feat(patches): auto-proposed registry patches (%s)",
|
||||||
|
now
|
||||||
|
)
|
||||||
|
body_lines <- c(
|
||||||
|
sprintf(
|
||||||
|
"_Auto-generated %s by `local/propose-patches.R --open-pr` from classified `single_builds` failures._",
|
||||||
|
now
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
sprintf(
|
||||||
|
"Adds %d known-lever registry %s (top by failure volume%s).",
|
||||||
|
length(candidate_entries),
|
||||||
|
if (length(candidate_entries) == 1L) "entry" else "entries",
|
||||||
|
if (deferred > 0L) {
|
||||||
|
sprintf("; %d deferred to a later run", deferred)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
"**Merge gate:** the `trial-build-registry` pipeline builds each new entry in its target build-env image; merge only once it is green.",
|
||||||
|
"Novel source diffs and unknown signatures are never auto-proposed.",
|
||||||
|
"",
|
||||||
|
"| package | signature | platforms |",
|
||||||
|
"| --- | --- | --- |"
|
||||||
|
)
|
||||||
|
for (c in candidates) {
|
||||||
|
body_lines <- c(
|
||||||
|
body_lines,
|
||||||
|
sprintf(
|
||||||
|
"| %s | %s | %s |",
|
||||||
|
c$package,
|
||||||
|
c$signature,
|
||||||
|
toString(as.character(c$entry$platforms))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
new_body <- paste(body_lines, collapse = "\n")
|
||||||
|
|
||||||
|
# One PR per reused branch: update if open, else create.
|
||||||
|
pulls_url <- sprintf(
|
||||||
|
"%s/repos/%s/pulls?state=open&limit=50",
|
||||||
|
forgejo_base,
|
||||||
|
repo
|
||||||
|
)
|
||||||
|
open_pulls <- httr2::request(pulls_url) |>
|
||||||
|
httr2::req_headers(Authorization = paste("token", forgejo_token)) |>
|
||||||
|
httr2::req_perform() |>
|
||||||
|
httr2::resp_body_json(simplifyVector = FALSE)
|
||||||
|
match_idx <- which(vapply(
|
||||||
|
open_pulls,
|
||||||
|
function(p) identical(p$head$ref, pr_branch),
|
||||||
|
logical(1L)
|
||||||
|
))
|
||||||
|
if (length(match_idx) > 0L) {
|
||||||
|
num <- open_pulls[[match_idx[1]]]$number
|
||||||
|
httr2::request(sprintf("%s/repos/%s/pulls/%d", forgejo_base, repo, num)) |>
|
||||||
|
httr2::req_headers(
|
||||||
|
Authorization = paste("token", forgejo_token),
|
||||||
|
`Content-Type` = "application/json"
|
||||||
|
) |>
|
||||||
|
httr2::req_body_json(list(title = pr_title, body = new_body)) |>
|
||||||
|
httr2::req_method("PATCH") |>
|
||||||
|
httr2::req_perform()
|
||||||
|
cat(sprintf("\nUpdated auto-patch PR #%d (branch %s).\n", num, pr_branch))
|
||||||
|
} else {
|
||||||
|
created <- httr2::request(sprintf(
|
||||||
|
"%s/repos/%s/pulls",
|
||||||
|
forgejo_base,
|
||||||
|
repo
|
||||||
|
)) |>
|
||||||
|
httr2::req_headers(
|
||||||
|
Authorization = paste("token", forgejo_token),
|
||||||
|
`Content-Type` = "application/json"
|
||||||
|
) |>
|
||||||
|
httr2::req_body_json(list(
|
||||||
|
title = pr_title,
|
||||||
|
head = pr_branch,
|
||||||
|
base = "main",
|
||||||
|
body = new_body
|
||||||
|
)) |>
|
||||||
|
httr2::req_perform() |>
|
||||||
|
httr2::resp_body_json()
|
||||||
|
cat(sprintf(
|
||||||
|
"\nOpened auto-patch PR #%d (branch %s).\n",
|
||||||
|
created$number,
|
||||||
|
pr_branch
|
||||||
|
))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
cat(
|
cat(
|
||||||
"\nDry run: no changes made. Re-run with --write or --open-issue to act.\n"
|
"\nDry run: no changes made. Re-run with --write, --open-issue, or --open-pr to act.\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,57 @@ test_that("blocked_summary lists each dependency and its dependent count", {
|
||||||
expect_true(b[[1L]]$packages_truncated)
|
expect_true(b[[1L]]$packages_truncated)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test_that("entry_applies_to_os matches codename, family, and wildcard", {
|
||||||
|
expect_true(entry_applies_to_os(list("ubuntu-2604"), "ubuntu-2604"))
|
||||||
|
expect_true(entry_applies_to_os(list("ubuntu"), "ubuntu-2604")) # family
|
||||||
|
expect_true(entry_applies_to_os(list("*"), "ubuntu-2604"))
|
||||||
|
expect_true(entry_applies_to_os(list("alpine", "ubuntu-2604"), "ubuntu-2604"))
|
||||||
|
expect_false(entry_applies_to_os(list("alpine-324"), "ubuntu-2604"))
|
||||||
|
expect_false(entry_applies_to_os(list("ubuntu-2404"), "ubuntu-2604")) # other codename
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("new_registry_packages returns only added entries for the platform", {
|
||||||
|
base <- list(
|
||||||
|
list(
|
||||||
|
package = "RcppParallel",
|
||||||
|
platforms = list("alpine", "ubuntu-2604"),
|
||||||
|
versions = "*"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current <- list(
|
||||||
|
base[[1L]], # unchanged -> not "new"
|
||||||
|
list(package = "BFpack", platforms = list("ubuntu-2604"), versions = "*"),
|
||||||
|
list(
|
||||||
|
package = "someAlpinePkg",
|
||||||
|
platforms = list("alpine-324"),
|
||||||
|
versions = "*"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# For ubuntu-2604: only the newly-added BFpack (RcppParallel is unchanged,
|
||||||
|
# someAlpinePkg does not apply to this OS).
|
||||||
|
expect_identical(
|
||||||
|
new_registry_packages(current, base, os = "ubuntu-2604"),
|
||||||
|
"BFpack"
|
||||||
|
)
|
||||||
|
# For alpine-324: the alpine package is new and applies.
|
||||||
|
expect_identical(
|
||||||
|
new_registry_packages(current, base, os = "alpine-324"),
|
||||||
|
"someAlpinePkg"
|
||||||
|
)
|
||||||
|
# Without an OS filter, both additions are returned.
|
||||||
|
expect_setequal(
|
||||||
|
new_registry_packages(current, base),
|
||||||
|
c("BFpack", "someAlpinePkg")
|
||||||
|
)
|
||||||
|
# A changed platform set on the same package counts as a new entry.
|
||||||
|
widened <- list(list(
|
||||||
|
package = "RcppParallel",
|
||||||
|
platforms = list("*"),
|
||||||
|
versions = "*"
|
||||||
|
))
|
||||||
|
expect_identical(new_registry_packages(widened, base), "RcppParallel")
|
||||||
|
})
|
||||||
|
|
||||||
test_that("retirement_candidates flags entries whose package no longer fails", {
|
test_that("retirement_candidates flags entries whose package no longer fails", {
|
||||||
entries <- list(
|
entries <- list(
|
||||||
list(package = "RcppParallel"),
|
list(package = "RcppParallel"),
|
||||||
|
|
|
||||||
145
local/trial-build-registry.R
Normal file
145
local/trial-build-registry.R
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
#!/usr/bin/env Rscript
|
||||||
|
|
||||||
|
# Merge gate for the auto-patch PR (issue #115, step 3): for every registry
|
||||||
|
# entry the PR ADDS that applies to this platform, trial-build the package with
|
||||||
|
# the registry applied, in this platform's own build-env image. Nothing is
|
||||||
|
# uploaded, archived, or written to the metadata DB.
|
||||||
|
#
|
||||||
|
# Exit 0 only if every new entry's package builds; exit 1 if any fails, so it
|
||||||
|
# gates the PR. A platform with no new entries is a fast no-op.
|
||||||
|
#
|
||||||
|
# Usage (inside a build-env image):
|
||||||
|
# PLATFORM=ubuntu-2604 Rscript local/trial-build-registry.R [base_ref]
|
||||||
|
# base_ref git ref to diff the registry against (default: origin/main)
|
||||||
|
|
||||||
|
options(error = function() {
|
||||||
|
cat("ERROR:", geterrmessage(), "\n", file = stdout())
|
||||||
|
q(status = 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
suppressPackageStartupMessages({
|
||||||
|
library(jsonlite, quietly = TRUE)
|
||||||
|
library(bincraft, quietly = TRUE)
|
||||||
|
})
|
||||||
|
|
||||||
|
script_path <- local({
|
||||||
|
a <- commandArgs(trailingOnly = FALSE)
|
||||||
|
f <- sub("^--file=", "", a[grepl("^--file=", a)])
|
||||||
|
if (length(f) == 1L && nzchar(f)) normalizePath(f) else NA_character_
|
||||||
|
})
|
||||||
|
script_dir <- if (is.na(script_path)) "local" else dirname(script_path)
|
||||||
|
source(file.path(script_dir, "proposal-tracking-lib.R"))
|
||||||
|
|
||||||
|
args <- commandArgs(trailingOnly = TRUE)
|
||||||
|
base_ref <- if (length(args) >= 1L) {
|
||||||
|
args[[1L]]
|
||||||
|
} else {
|
||||||
|
Sys.getenv("BASE_REF", "origin/main")
|
||||||
|
}
|
||||||
|
os <- Sys.getenv("PLATFORM", "")
|
||||||
|
if (!nzchar(os)) {
|
||||||
|
stop("PLATFORM env var is not set (e.g. ubuntu-2604).")
|
||||||
|
}
|
||||||
|
|
||||||
|
patches_dir <- file.path(script_dir, "patches")
|
||||||
|
registry_file <- file.path(patches_dir, "registry.json")
|
||||||
|
current <- if (file.exists(registry_file)) {
|
||||||
|
jsonlite::fromJSON(registry_file, simplifyVector = FALSE)
|
||||||
|
} else {
|
||||||
|
list()
|
||||||
|
}
|
||||||
|
# Read the registry at base_ref. Fail loud if the ref or file can't be read:
|
||||||
|
# silently treating the base as empty would trial-build the WHOLE registry
|
||||||
|
# instead of just the entries the branch adds.
|
||||||
|
registry_rel <- "local/patches/registry.json"
|
||||||
|
ref_ok <- suppressWarnings(system2(
|
||||||
|
"git",
|
||||||
|
c("rev-parse", "--verify", "--quiet", sprintf("%s^{commit}", base_ref)),
|
||||||
|
stdout = TRUE,
|
||||||
|
stderr = FALSE
|
||||||
|
))
|
||||||
|
if (!is.null(attr(ref_ok, "status"))) {
|
||||||
|
stop(sprintf("base ref %s does not resolve to a commit.", base_ref))
|
||||||
|
}
|
||||||
|
in_base <- suppressWarnings(system2(
|
||||||
|
"git",
|
||||||
|
c("ls-tree", base_ref, "--", registry_rel),
|
||||||
|
stdout = TRUE,
|
||||||
|
stderr = FALSE
|
||||||
|
))
|
||||||
|
file_in_base <- length(in_base) > 0L && any(nzchar(in_base))
|
||||||
|
base_json <- suppressWarnings(system2(
|
||||||
|
"git",
|
||||||
|
c("show", sprintf("%s:%s", base_ref, registry_rel)),
|
||||||
|
stdout = TRUE,
|
||||||
|
stderr = FALSE
|
||||||
|
))
|
||||||
|
show_ok <- is.null(attr(base_json, "status"))
|
||||||
|
if (file_in_base && !show_ok) {
|
||||||
|
stop(sprintf(
|
||||||
|
"could not read %s at %s; refusing to build the whole registry.",
|
||||||
|
registry_rel,
|
||||||
|
base_ref
|
||||||
|
))
|
||||||
|
}
|
||||||
|
base <- if (show_ok && length(base_json) > 0L) {
|
||||||
|
jsonlite::fromJSON(paste(base_json, collapse = "\n"), simplifyVector = FALSE)
|
||||||
|
} else {
|
||||||
|
list() # file genuinely absent at base -> every entry is new
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs <- new_registry_packages(current, base, os = os)
|
||||||
|
if (length(pkgs) == 0L) {
|
||||||
|
cat(sprintf(
|
||||||
|
"No new registry entries apply to %s; nothing to trial-build.\n",
|
||||||
|
os
|
||||||
|
))
|
||||||
|
q(status = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
cat(sprintf(
|
||||||
|
"Trial-building %d new registry %s on %s (vs %s):\n %s\n",
|
||||||
|
length(pkgs),
|
||||||
|
if (length(pkgs) == 1L) "entry" else "entries",
|
||||||
|
os,
|
||||||
|
base_ref,
|
||||||
|
toString(pkgs)
|
||||||
|
))
|
||||||
|
|
||||||
|
results <- vapply(
|
||||||
|
pkgs,
|
||||||
|
function(pkg) {
|
||||||
|
cat(sprintf("\n=== trial build: %s ===\n", pkg))
|
||||||
|
tryCatch(
|
||||||
|
{
|
||||||
|
bincraft::build_binary_package(
|
||||||
|
pkg,
|
||||||
|
tag_limit = 1L,
|
||||||
|
patches = patches_dir,
|
||||||
|
archive = FALSE,
|
||||||
|
upload = FALSE,
|
||||||
|
store_build_metadata = FALSE
|
||||||
|
)
|
||||||
|
TRUE
|
||||||
|
},
|
||||||
|
error = function(e) {
|
||||||
|
cat(sprintf("FAILED %s: %s\n", pkg, conditionMessage(e)))
|
||||||
|
FALSE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
logical(1L)
|
||||||
|
)
|
||||||
|
|
||||||
|
failed <- pkgs[!results]
|
||||||
|
cat(sprintf(
|
||||||
|
"\n%d/%d passed on %s.%s\n",
|
||||||
|
sum(results),
|
||||||
|
length(results),
|
||||||
|
os,
|
||||||
|
if (length(failed) > 0L) sprintf(" Failed: %s", toString(failed)) else ""
|
||||||
|
))
|
||||||
|
if (length(failed) > 0L) {
|
||||||
|
q(status = 1)
|
||||||
|
}
|
||||||
|
q(status = 0)
|
||||||
Loading…
Reference in a new issue