From 7bc74caa63118bccaef8b8ed695de30333ec2c4c Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 23 Mar 2026 19:19:16 +0000 Subject: [PATCH] fix: Push to public mirrors after merge (#614) Add fire-and-forget mirror push support so merges to the primary branch are automatically pushed to configured public mirrors (GitHub, Codeberg, etc.). Mirror failures are logged but never block the pipeline. - lib/mirrors.sh: new shared mirror_push() helper - lib/load-project.sh: parse [mirrors] TOML section into MIRROR_* env vars - dev/phase-handler.sh: call mirror_push after do_merge() success - dev/dev-poll.sh: call mirror_push after try_direct_merge() success - gardener/gardener-run.sh: call mirror_push after _gardener_merge() success - bin/disinto: set up mirror remotes during init, add commented mirrors to generated TOML - projects/*.toml.example: show [mirrors] section (commented out) Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/disinto | 22 ++++++++++++++++++++++ dev/dev-poll.sh | 7 +++++++ dev/phase-handler.sh | 9 +++++++++ gardener/gardener-run.sh | 12 ++++++++++++ lib/load-project.sh | 11 ++++++++++- lib/mirrors.sh | 30 ++++++++++++++++++++++++++++++ projects/disinto.toml.example | 4 ++++ projects/harb.toml.example | 4 ++++ projects/versi.toml.example | 4 ++++ 9 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 lib/mirrors.sh diff --git a/bin/disinto b/bin/disinto index abf6b85..f40d589 100755 --- a/bin/disinto +++ b/bin/disinto @@ -505,6 +505,10 @@ containers = [] check_prs = true check_dev_agent = true check_pipeline_stall = false + +# [mirrors] +# github = "git@github.com:user/repo.git" +# codeberg = "git@codeberg.org:user/repo.git" EOF } @@ -894,6 +898,24 @@ p.write_text(text) # Install cron jobs install_cron "$project_name" "$toml_path" "$auto_yes" + # Set up mirror remotes if [mirrors] configured in TOML + source "${FACTORY_ROOT}/lib/load-project.sh" "$toml_path" + if [ -n "${MIRROR_NAMES:-}" ]; then + echo "Mirrors: setting up remotes" + local mname murl + for mname in $MIRROR_NAMES; do + murl=$(eval "echo \"\$MIRROR_$(echo "$mname" | tr '[:lower:]' '[:upper:]')\"") || true + [ -z "$murl" ] && continue + git -C "$repo_root" remote add "$mname" "$murl" 2>/dev/null \ + || git -C "$repo_root" remote set-url "$mname" "$murl" 2>/dev/null || true + echo " + ${mname} -> ${murl}" + done + # Initial sync: push current primary branch to mirrors + source "${FACTORY_ROOT}/lib/mirrors.sh" + export PROJECT_REPO_ROOT="$repo_root" + mirror_push + fi + # Encrypt secrets if SOPS + age are available write_secrets_encrypted diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh index e304612..83b76d8 100755 --- a/dev/dev-poll.sh +++ b/dev/dev-poll.sh @@ -20,6 +20,8 @@ set -euo pipefail export PROJECT_TOML="${1:-}" source "$(dirname "$0")/../lib/env.sh" source "$(dirname "$0")/../lib/ci-helpers.sh" +# shellcheck source=../lib/mirrors.sh +source "$(dirname "$0")/../lib/mirrors.sh" # Gitea labels API requires []int64 — look up the "underspecified" label ID once UNDERSPECIFIED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \ @@ -206,6 +208,11 @@ try_direct_merge() { else matrix_send "dev" "✅ PR #${pr_num} merged directly by dev-poll (chore)" 2>/dev/null || true fi + # Pull merged primary branch and push to mirrors + git -C "${PROJECT_REPO_ROOT:-}" fetch origin "${PRIMARY_BRANCH:-}" 2>/dev/null || true + git -C "${PROJECT_REPO_ROOT:-}" checkout "${PRIMARY_BRANCH:-}" 2>/dev/null || true + git -C "${PROJECT_REPO_ROOT:-}" pull --ff-only origin "${PRIMARY_BRANCH:-}" 2>/dev/null || true + mirror_push # Clean up CI fix tracker ci_fix_reset "$pr_num" return 0 diff --git a/dev/phase-handler.sh b/dev/phase-handler.sh index 92d1833..b72ab5b 100644 --- a/dev/phase-handler.sh +++ b/dev/phase-handler.sh @@ -30,6 +30,10 @@ source "$(dirname "${BASH_SOURCE[0]}")/../lib/secret-scan.sh" # shellcheck source=../lib/ci-helpers.sh source "$(dirname "${BASH_SOURCE[0]}")/../lib/ci-helpers.sh" +# Load mirror push helper +# shellcheck source=../lib/mirrors.sh +source "$(dirname "${BASH_SOURCE[0]}")/../lib/mirrors.sh" + # --- Default globals (agents can override after sourcing) --- : "${CI_POLL_TIMEOUT:=1800}" : "${REVIEW_POLL_TIMEOUT:=10800}" @@ -549,6 +553,11 @@ Instructions: -H 'Content-Type: application/json' \ "${API}/issues/${ISSUE}" \ -d '{"state":"closed"}' >/dev/null 2>&1 || true + # Pull merged primary branch and push to mirrors + git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true + git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true + git -C "$PROJECT_REPO_ROOT" pull --ff-only origin "$PRIMARY_BRANCH" 2>/dev/null || true + mirror_push printf 'PHASE:done\n' > "$PHASE_FILE" elif [ "$_merge_rc" -ne 2 ]; then # Other merge failure (conflict, etc.) — delegate to Claude for rebase + retry diff --git a/gardener/gardener-run.sh b/gardener/gardener-run.sh index 1a86364..39f5928 100755 --- a/gardener/gardener-run.sh +++ b/gardener/gardener-run.sh @@ -26,6 +26,8 @@ source "$FACTORY_ROOT/lib/agent-session.sh" source "$FACTORY_ROOT/lib/formula-session.sh" # shellcheck source=../lib/ci-helpers.sh source "$FACTORY_ROOT/lib/ci-helpers.sh" +# shellcheck source=../lib/mirrors.sh +source "$FACTORY_ROOT/lib/mirrors.sh" LOG_FILE="$SCRIPT_DIR/gardener.log" # shellcheck disable=SC2034 # consumed by run_formula_and_monitor @@ -292,6 +294,11 @@ _gardener_merge() { if [ "$merge_http_code" = "200" ] || [ "$merge_http_code" = "204" ]; then log "gardener PR #${_GARDENER_PR} merged" + # Pull merged primary branch and push to mirrors + git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true + git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true + git -C "$PROJECT_REPO_ROOT" pull --ff-only origin "$PRIMARY_BRANCH" 2>/dev/null || true + mirror_push _gardener_execute_manifest printf 'PHASE:done\n' > "$PHASE_FILE" return 0 @@ -304,6 +311,11 @@ _gardener_merge() { "${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.merged // false') || true if [ "$pr_merged" = "true" ]; then log "gardener PR #${_GARDENER_PR} already merged" + # Pull merged primary branch and push to mirrors + git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true + git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true + git -C "$PROJECT_REPO_ROOT" pull --ff-only origin "$PRIMARY_BRANCH" 2>/dev/null || true + mirror_push _gardener_execute_manifest printf 'PHASE:done\n' > "$PHASE_FILE" return 0 diff --git a/lib/load-project.sh b/lib/load-project.sh index d3032fd..49e4199 100755 --- a/lib/load-project.sh +++ b/lib/load-project.sh @@ -8,7 +8,8 @@ # PROJECT_NAME, FORGE_REPO, FORGE_API, FORGE_WEB, FORGE_URL, # PROJECT_REPO_ROOT, PRIMARY_BRANCH, WOODPECKER_REPO_ID, # PROJECT_CONTAINERS, CHECK_PRS, CHECK_DEV_AGENT, -# CHECK_PIPELINE_STALL, CI_STALE_MINUTES +# CHECK_PIPELINE_STALL, CI_STALE_MINUTES, +# MIRROR_NAMES, MIRROR_URLS, MIRROR_ (per configured mirror) # (plus backwards-compat aliases: CODEBERG_REPO, CODEBERG_API, CODEBERG_WEB) # # If no argument given, does nothing (allows poll scripts to work with @@ -71,6 +72,14 @@ mon = cfg.get('monitoring', {}) for key in ['check_prs', 'check_dev_agent', 'check_pipeline_stall']: if key in mon: emit(key.upper(), mon[key]) + +# [mirrors] section +mirrors = cfg.get('mirrors', {}) +for name, url in mirrors.items(): + emit(f'MIRROR_{name.upper()}', url) +if mirrors: + emit('MIRROR_NAMES', list(mirrors.keys())) + emit('MIRROR_URLS', list(mirrors.values())) " "$_PROJECT_TOML" 2>/dev/null) || { echo "WARNING: failed to parse project TOML: $_PROJECT_TOML" >&2 return 1 2>/dev/null || exit 1 diff --git a/lib/mirrors.sh b/lib/mirrors.sh new file mode 100644 index 0000000..e6dfba1 --- /dev/null +++ b/lib/mirrors.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# mirrors.sh — Push primary branch + tags to configured mirror remotes. +# +# Usage: source lib/mirrors.sh; mirror_push +# Requires: PROJECT_REPO_ROOT, PRIMARY_BRANCH, MIRROR_* vars from load-project.sh + +# shellcheck disable=SC2154 # globals set by load-project.sh / calling script + +mirror_push() { + [ -z "${MIRROR_NAMES:-}" ] && return 0 + [ -z "${PROJECT_REPO_ROOT:-}" ] && return 0 + [ -z "${PRIMARY_BRANCH:-}" ] && return 0 + + local name url + for name in $MIRROR_NAMES; do + url=$(eval "echo \"\$MIRROR_$(echo "$name" | tr '[:lower:]' '[:upper:]')\"") || true + [ -z "$url" ] && continue + + # Ensure remote exists with correct URL + if git -C "$PROJECT_REPO_ROOT" remote get-url "$name" &>/dev/null; then + git -C "$PROJECT_REPO_ROOT" remote set-url "$name" "$url" 2>/dev/null || true + else + git -C "$PROJECT_REPO_ROOT" remote add "$name" "$url" 2>/dev/null || true + fi + + # Fire-and-forget push (background, no failure propagation) + git -C "$PROJECT_REPO_ROOT" push "$name" "$PRIMARY_BRANCH" --tags 2>/dev/null & + log "mirror: pushed to ${name} (pid $!)" + done +} diff --git a/projects/disinto.toml.example b/projects/disinto.toml.example index 5db6e99..759865f 100644 --- a/projects/disinto.toml.example +++ b/projects/disinto.toml.example @@ -25,3 +25,7 @@ token_env = "DISINTO_MATRIX_TOKEN" check_prs = true check_dev_agent = true check_pipeline_stall = false + +# [mirrors] +# github = "git@github.com:johba/disinto.git" +# codeberg = "git@codeberg.org:johba/disinto.git" diff --git a/projects/harb.toml.example b/projects/harb.toml.example index 1a4081a..0761a59 100644 --- a/projects/harb.toml.example +++ b/projects/harb.toml.example @@ -25,3 +25,7 @@ token_env = "MATRIX_TOKEN" check_prs = true check_dev_agent = true check_pipeline_stall = true + +# [mirrors] +# github = "git@github.com:johba/harb.git" +# codeberg = "git@codeberg.org:johba/harb.git" diff --git a/projects/versi.toml.example b/projects/versi.toml.example index 56326dc..2fbc189 100644 --- a/projects/versi.toml.example +++ b/projects/versi.toml.example @@ -20,3 +20,7 @@ containers = [] check_prs = true check_dev_agent = true check_pipeline_stall = true + +# [mirrors] +# github = "git@github.com:johba/versi.git" +# codeberg = "git@codeberg.org:johba/versi.git"