diff --git a/README.md b/README.md index 996047e..6dd7217 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,27 @@ It: - requires a clean worktree before publishing - uses `gh` to create the GitHub release that triggers `.github/workflows/release.yml` +## Update PRs + +`update.sh` is the interactive helper for testing upstream app bumps on a throwaway branch and PR before they land on `main`. + +```bash +./update.sh --dry-run +./update.sh n8n +./update.sh openwebui --yes +./update.sh --all --yes +``` + +It: + +- checks the latest supported upstream pins for `n8n` and `openwebui` +- updates the matching checked-in refs, versions, and image digests in the repo +- creates a `test-update--` branch from the repo default branch +- runs `./scripts/smoke-test.sh --syntax` and `./release.sh --dry-run` +- commits the pin refresh, pushes the branch, and opens a draft PR to trigger CI + +Use `--no-push` to keep the branch local or `--no-pr` to stop after pushing. + ## Verification ```bash diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 3e4fcae..a8938c0 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -108,7 +108,7 @@ curl_edge() { echo "=== Syntax checks ===" -for file in "$PROJECT_DIR/deploy.sh" "$PROJECT_DIR/scripts/"*.sh; do +for file in "$PROJECT_DIR/deploy.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/scripts/"*.sh; do if bash -n "$file" >/dev/null 2>&1; then pass "bash -n $(basename "$file")" else @@ -126,7 +126,7 @@ for file in "$PROJECT_DIR/standalone/entrypoint.sh" "$PROJECT_DIR/standalone/con done if command -v shellcheck >/dev/null 2>&1; then - shellcheck_out="$(shellcheck -x "$PROJECT_DIR/deploy.sh" "$PROJECT_DIR/scripts/"*.sh 2>&1)" || true + shellcheck_out="$(shellcheck -x "$PROJECT_DIR/deploy.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/scripts/"*.sh 2>&1)" || true if [ -z "$shellcheck_out" ]; then pass "shellcheck shell scripts" else diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..8a34129 --- /dev/null +++ b/update.sh @@ -0,0 +1,930 @@ +#!/usr/bin/env bash +# +# update.sh +# +# Interactive helper for refreshing checked-in upstream pins, validating the +# result locally, then pushing a test PR so CI can shake out overlay breakage +# before anything lands on main. +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +DOCKERFILE_LOCAL_REPO="$SCRIPT_DIR/Dockerfile.local-repo" +DOCKERFILE_N8N="$SCRIPT_DIR/Dockerfile.n8n" +DEPLOY_SCRIPT_PATH="$SCRIPT_DIR/deploy.sh" +COMPOSE_FILE_PATH="$SCRIPT_DIR/docker-compose.yml" +ENV_EXAMPLE_PATH="$SCRIPT_DIR/.env.example" +SMOKE_TEST_PATH="$SCRIPT_DIR/scripts/smoke-test.sh" +RELEASE_HELPER_PATH="$SCRIPT_DIR/release.sh" +DEFAULT_REMOTE="${GIT_REMOTE:-}" + +PLATFORM_OS="linux" +PLATFORM_ARCH="amd64" + +DRY_RUN=false +YES=false +ALL_COMPONENTS=false +PUSH_CHANGES=true +OPEN_PR=true +BASE_BRANCH_OVERRIDE="" + +REQUESTED_COMPONENTS=() +SELECTED_COMPONENTS=() + +N8N_HAS_UPDATE=false +N8N_CURRENT_REF="" +N8N_CURRENT_SHA="" +N8N_CURRENT_NODE_VERSION="" +N8N_CURRENT_NODE_IMAGE="" +N8N_CURRENT_BASE_IMAGE="" +N8N_LATEST_VERSION="" +N8N_LATEST_REF="" +N8N_LATEST_SHA="" +N8N_LATEST_NODE_VERSION="" +N8N_LATEST_NODE_IMAGE="" +N8N_LATEST_BASE_IMAGE="" + +OPENWEBUI_HAS_UPDATE=false +OPENWEBUI_CURRENT_VERSION="" +OPENWEBUI_CURRENT_IMAGE="" +OPENWEBUI_LATEST_VERSION="" +OPENWEBUI_LATEST_IMAGE="" + +usage() { + cat <<'EOF' +Usage: ./update.sh [component ...] [options] + +Components: + n8n Refresh the pinned n8n source ref/sha and its builder/runtime images + openwebui Refresh the pinned OpenWebUI release tag and image digest + +Options: + --all Consider every supported component + --base BR Base branch for the PR (defaults to the repo default branch, usually main) + --dry-run Print available updates and the branch/PR plan without changing files + --yes Accept the proposed updates and publish flow without prompting + --no-push Commit the branch locally but do not push or open a PR + --no-pr Push the branch but stop before opening a PR + -h, --help Show this help +EOF +} + +log() { + printf '[update] %s\n' "$1" +} + +warn() { + printf '[update] warning: %s\n' "$1" >&2 +} + +err() { + printf '[update] error: %s\n' "$1" >&2 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + err "$1 is required" + exit 1 + } +} + +have_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +current_branch() { + git rev-parse --abbrev-ref HEAD +} + +current_head() { + git rev-parse --short=12 HEAD +} + +preferred_remote() { + local upstream_remote="" + local first_remote="" + + if [ -n "$DEFAULT_REMOTE" ]; then + printf '%s' "$DEFAULT_REMOTE" + return + fi + + upstream_remote="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null | sed 's#/.*##' || true)" + if [ -n "$upstream_remote" ]; then + printf '%s' "$upstream_remote" + return + fi + + if git remote | grep -qx origin; then + printf 'origin' + return + fi + + first_remote="$(git remote | head -n 1 || true)" + printf '%s' "$first_remote" +} + +ensure_clean_worktree() { + local status + status="$(git status --porcelain)" + if [ -n "$status" ]; then + err "working tree is not clean; commit or stash changes before running update.sh" + exit 1 + fi +} + +ensure_gh_ready() { + if ! have_cmd gh; then + err "gh is required" + err "install docs: https://cli.github.com/manual/installation" + exit 1 + fi + + if ! gh auth status >/dev/null 2>&1; then + err "gh is not authenticated; run 'gh auth login' first" + exit 1 + fi +} + +ensure_docker_ready() { + require_cmd docker + if ! docker buildx version >/dev/null 2>&1; then + err "docker buildx is required" + exit 1 + fi +} + +extract_arg() { + local file="$1" + local name="$2" + sed -n "s/^ARG ${name}=//p" "$file" | head -n 1 +} + +set_shell_assignment() { + local file="$1" + local key="$2" + local value="$3" + + python3 - "$file" "$key" "$value" <<'PY' +from pathlib import Path +import re +import sys + +path = Path(sys.argv[1]) +key = sys.argv[2] +value = sys.argv[3] +pattern = re.compile(rf"^{re.escape(key)}=\".*\"$", re.MULTILINE) +replacement = f'{key}="{value}"' +text = path.read_text() +updated, count = pattern.subn(replacement, text, count=1) +if count != 1: + raise SystemExit(f"{path}: expected exactly one assignment for {key}, found {count}") +path.write_text(updated) +PY +} + +set_docker_arg() { + local file="$1" + local key="$2" + local value="$3" + + python3 - "$file" "$key" "$value" <<'PY' +from pathlib import Path +import re +import sys + +path = Path(sys.argv[1]) +key = sys.argv[2] +value = sys.argv[3] +pattern = re.compile(rf"^ARG {re.escape(key)}=.*$", re.MULTILINE) +replacement = f"ARG {key}={value}" +text = path.read_text() +updated, count = pattern.subn(replacement, text, count=1) +if count != 1: + raise SystemExit(f"{path}: expected exactly one ARG for {key}, found {count}") +path.write_text(updated) +PY +} + +set_compose_default() { + local file="$1" + local key="$2" + local value="$3" + + python3 - "$file" "$key" "$value" <<'PY' +from pathlib import Path +import re +import sys + +path = Path(sys.argv[1]) +key = sys.argv[2] +value = sys.argv[3] +pattern = re.compile( + rf"^(?P\s*{re.escape(key)}:\s*)\$\{{{re.escape(key)}:-.*\}}$", + re.MULTILINE, +) +text = path.read_text() + +def repl(match): + return match.group("indent") + "${" + key + ":-" + value + "}" + +updated, count = pattern.subn(repl, text, count=1) +if count != 1: + raise SystemExit(f"{path}: expected exactly one compose default for {key}, found {count}") +path.write_text(updated) +PY +} + +github_file_contents() { + local endpoint="$1" + gh api "$endpoint" --jq .content | python3 -c ' +import base64 +import sys + +payload = sys.stdin.read().strip().replace("\n", "") +sys.stdout.write(base64.b64decode(payload).decode()) +' +} + +resolve_manifest_digest() { + local image_ref="$1" + + docker buildx imagetools inspect "$image_ref" --format '{{json .Manifest}}' | \ + python3 -c ' +import json +import sys + +target_os = sys.argv[1] +target_arch = sys.argv[2] +image_ref = sys.argv[3] +payload = json.load(sys.stdin) + +manifests = payload.get("manifests") or [] +if manifests: + for manifest in manifests: + platform = manifest.get("platform") or {} + if platform.get("os") == target_os and platform.get("architecture") == target_arch: + print(manifest["digest"]) + raise SystemExit(0) + raise SystemExit(f"{image_ref}: no {target_os}/{target_arch} manifest found") + +digest = payload.get("digest") +if digest: + print(digest) + raise SystemExit(0) + +raise SystemExit(f"{image_ref}: could not determine manifest digest") +' "$PLATFORM_OS" "$PLATFORM_ARCH" "$image_ref" +} + +latest_n8n_tag() { + gh api -X GET 'repos/n8n-io/n8n/tags?per_page=100' --paginate --jq '.[].name' | python3 -c ' +import re +import sys + +pattern = re.compile(r"^n8n@(\d+)\.(\d+)\.(\d+)$") +tags = [] +for raw in sys.stdin: + tag = raw.strip() + match = pattern.match(tag) + if not match: + continue + version = tuple(int(part) for part in match.groups()) + tags.append((version, tag)) + +if not tags: + raise SystemExit("no stable n8n tags found") + +tags.sort() +print(tags[-1][1]) +' +} + +n8n_node_version_for_tag() { + local tag="$1" + github_file_contents "repos/n8n-io/n8n/contents/docker/images/n8n-base/Dockerfile?ref=${tag}" | \ + sed -n 's/^ARG NODE_VERSION=//p' | head -n 1 +} + +short_sha() { + local value="$1" + if [ -z "$value" ]; then + printf '' + else + printf '%.12s' "$value" + fi +} + +short_digest() { + local value="${1#*@}" + value="${value#sha256:}" + if [ -z "$value" ]; then + printf '' + else + printf 'sha256:%.12s' "$value" + fi +} + +node_version_from_builder_image() { + local image="$1" + local value="${image#node:}" + value="${value%%-alpine*}" + printf '%s' "$value" +} + +component_label() { + case "$1" in + n8n) printf 'n8n' ;; + openwebui) printf 'OpenWebUI' ;; + *) + err "unsupported component: $1" + exit 1 + ;; + esac +} + +component_abbr() { + case "$1" in + n8n) printf 'n8n' ;; + openwebui) printf 'owui' ;; + *) + err "unsupported component: $1" + exit 1 + ;; + esac +} + +component_has_update() { + case "$1" in + n8n) [ "$N8N_HAS_UPDATE" = true ] ;; + openwebui) [ "$OPENWEBUI_HAS_UPDATE" = true ] ;; + *) + err "unsupported component: $1" + exit 1 + ;; + esac +} + +component_current_summary() { + case "$1" in + n8n) + printf '%s (%s), node %s' \ + "$N8N_CURRENT_REF" \ + "$(short_sha "$N8N_CURRENT_SHA")" \ + "$N8N_CURRENT_NODE_VERSION" + ;; + openwebui) + printf '%s (%s)' \ + "$OPENWEBUI_CURRENT_VERSION" \ + "$(short_digest "$OPENWEBUI_CURRENT_IMAGE")" + ;; + *) + err "unsupported component: $1" + exit 1 + ;; + esac +} + +component_latest_summary() { + case "$1" in + n8n) + printf '%s (%s), node %s' \ + "$N8N_LATEST_REF" \ + "$(short_sha "$N8N_LATEST_SHA")" \ + "$N8N_LATEST_NODE_VERSION" + ;; + openwebui) + printf '%s (%s)' \ + "$OPENWEBUI_LATEST_VERSION" \ + "$(short_digest "$OPENWEBUI_LATEST_IMAGE")" + ;; + *) + err "unsupported component: $1" + exit 1 + ;; + esac +} + +component_pr_summary() { + case "$1" in + n8n) + cat </dev/null 2>&1; do + suffix=$((suffix + 1)) + candidate="${branch}-$(printf '%02d' "$suffix")" + done + + printf '%s' "$candidate" +} + +prepare_base_branch() { + local git_remote="$1" + local base_branch="$2" + + log "fetching ${git_remote}/${base_branch}" + git fetch "$git_remote" "$base_branch" + + if [ "$(current_branch)" != "$base_branch" ]; then + log "switching to ${base_branch}" + git switch "$base_branch" + fi + + log "fast-forwarding ${base_branch}" + git pull --ff-only "$git_remote" "$base_branch" +} + +apply_n8n_update() { + log "refreshing the n8n source and builder/runtime image pins" + set_docker_arg "$DOCKERFILE_LOCAL_REPO" "N8N_SOURCE_REF" "$N8N_LATEST_REF" + set_docker_arg "$DOCKERFILE_LOCAL_REPO" "N8N_SOURCE_SHA" "$N8N_LATEST_SHA" + set_docker_arg "$DOCKERFILE_LOCAL_REPO" "NODE_BUILDER_IMAGE" "$N8N_LATEST_NODE_IMAGE" + set_docker_arg "$DOCKERFILE_LOCAL_REPO" "N8N_BASE_IMAGE" "$N8N_LATEST_BASE_IMAGE" + set_docker_arg "$DOCKERFILE_N8N" "N8N_SOURCE_REF" "$N8N_LATEST_REF" + set_shell_assignment "$DEPLOY_SCRIPT_PATH" "PROJECT_N8N_VERSION" "$N8N_LATEST_VERSION" + set_shell_assignment "$DEPLOY_SCRIPT_PATH" "PROJECT_N8N_SOURCE_SHA" "$N8N_LATEST_SHA" + set_shell_assignment "$ENV_EXAMPLE_PATH" "N8N_VERSION" "$N8N_LATEST_VERSION" + set_shell_assignment "$ENV_EXAMPLE_PATH" "N8N_SOURCE_REF" "$N8N_LATEST_REF" + set_shell_assignment "$ENV_EXAMPLE_PATH" "N8N_SOURCE_SHA" "$N8N_LATEST_SHA" + set_compose_default "$COMPOSE_FILE_PATH" "N8N_SOURCE_REF" "$N8N_LATEST_REF" + set_compose_default "$COMPOSE_FILE_PATH" "N8N_SOURCE_SHA" "$N8N_LATEST_SHA" +} + +apply_openwebui_update() { + log "refreshing the OpenWebUI release tag and image digest" + set_docker_arg "$DOCKERFILE_LOCAL_REPO" "OPENWEBUI_VERSION" "$OPENWEBUI_LATEST_VERSION" + set_docker_arg "$DOCKERFILE_LOCAL_REPO" "OPENWEBUI_IMAGE" "$OPENWEBUI_LATEST_IMAGE" + set_shell_assignment "$DEPLOY_SCRIPT_PATH" "PROJECT_OPENWEBUI_VERSION" "$OPENWEBUI_LATEST_VERSION" + set_shell_assignment "$DEPLOY_SCRIPT_PATH" "PROJECT_OPENWEBUI_IMAGE" "$OPENWEBUI_LATEST_IMAGE" + set_shell_assignment "$ENV_EXAMPLE_PATH" "OPENWEBUI_VERSION" "$OPENWEBUI_LATEST_VERSION" + set_shell_assignment "$ENV_EXAMPLE_PATH" "OPENWEBUI_IMAGE" "$OPENWEBUI_LATEST_IMAGE" + set_compose_default "$COMPOSE_FILE_PATH" "OPENWEBUI_VERSION" "$OPENWEBUI_LATEST_VERSION" + set_compose_default "$COMPOSE_FILE_PATH" "OPENWEBUI_IMAGE" "$OPENWEBUI_LATEST_IMAGE" +} + +run_validation() { + log "running syntax smoke checks" + "$SMOKE_TEST_PATH" --syntax + + log "running release helper dry-run" + "$RELEASE_HELPER_PATH" --dry-run /dev/null +} + +commit_title_for_selection() { + printf 'Update %s pins' "$(human_component_list "${SELECTED_COMPONENTS[@]}")" +} + +write_pr_body() { + local body_file="$1" + local component + + { + printf '## Summary\n\n' + for component in "${SELECTED_COMPONENTS[@]}"; do + component_pr_summary "$component" + done + printf '\n## Validation\n\n' + printf -- "- \`./scripts/smoke-test.sh --syntax\`\n" + printf -- "- \`./release.sh --dry-run\`\n" + printf '\n## Notes\n\n' + printf -- '- This PR is meant to trigger CI against the refreshed upstream pins before anything ships.\n' + } >"$body_file" +} + +stage_changed_files() { + git add \ + "$DOCKERFILE_LOCAL_REPO" \ + "$DOCKERFILE_N8N" \ + "$DEPLOY_SCRIPT_PATH" \ + "$COMPOSE_FILE_PATH" \ + "$ENV_EXAMPLE_PATH" +} + +print_plan() { + local base_branch="$1" + local branch_name="$2" + local component + + cat <} + base: $base_branch + +Selected updates +EOF + + if [ "${#SELECTED_COMPONENTS[@]}" -eq 0 ]; then + printf ' \n' + return + fi + + for component in "${SELECTED_COMPONENTS[@]}"; do + printf ' - %s\n' "$(component_label "$component")" + printf ' current: %s\n' "$(component_current_summary "$component")" + printf ' latest: %s\n' "$(component_latest_summary "$component")" + done +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --all) + ALL_COMPONENTS=true + ;; + --base=*) + BASE_BRANCH_OVERRIDE="${1#*=}" + ;; + --base) + shift || true + if [ "$#" -eq 0 ]; then + err "--base requires a value" + exit 1 + fi + BASE_BRANCH_OVERRIDE="$1" + ;; + --dry-run) + DRY_RUN=true + ;; + --yes) + YES=true + ;; + --no-push) + PUSH_CHANGES=false + OPEN_PR=false + ;; + --no-pr) + OPEN_PR=false + ;; + -h|--help) + usage + exit 0 + ;; + *) + validate_component_name "$1" + REQUESTED_COMPONENTS+=("$1") + ;; + esac + shift +done + +require_cmd git +require_cmd python3 +require_cmd awk +ensure_gh_ready +ensure_docker_ready + +git_remote="$(preferred_remote)" +if [ -z "$git_remote" ]; then + err "no git remote configured" + exit 1 +fi + +discover_n8n_update +discover_openwebui_update + +printf 'Available updates\n' +print_component_report n8n +print_component_report openwebui +printf '\n' + +build_selection + +base_branch="$(default_branch)" +planned_branch="" +if [ "${#SELECTED_COMPONENTS[@]}" -gt 0 ]; then + planned_branch="$(branch_name_for_selection "$git_remote")" +fi +print_plan "$base_branch" "$planned_branch" +printf '\n' + +if [ "$DRY_RUN" = true ]; then + exit 0 +fi + +if [ "${#SELECTED_COMPONENTS[@]}" -eq 0 ]; then + log "nothing selected; exiting" + exit 0 +fi + +if ! prompt_yes_no_default "Create the update branch and draft PR?" "yes"; then + log "update cancelled" + exit 0 +fi + +ensure_clean_worktree +prepare_base_branch "$git_remote" "$base_branch" + +branch_name="$(branch_name_for_selection "$git_remote")" +log "creating ${branch_name}" +git switch -c "$branch_name" + +for component in "${SELECTED_COMPONENTS[@]}"; do + case "$component" in + n8n) apply_n8n_update ;; + openwebui) apply_openwebui_update ;; + *) + err "unsupported component: $component" + exit 1 + ;; + esac +done + +if git diff --quiet; then + log "no file changes were produced" + exit 0 +fi + +run_validation + +stage_changed_files + +commit_title="$(commit_title_for_selection)" +git commit -m "$commit_title" + +pr_url="" +if [ "$PUSH_CHANGES" = true ]; then + log "pushing ${branch_name}" + git push -u "$git_remote" "$branch_name" + + if [ "$OPEN_PR" = true ]; then + pr_body="$(mktemp)" + trap 'rm -f "$pr_body"' EXIT + write_pr_body "$pr_body" + log "opening a draft PR against ${base_branch}" + pr_url="$(gh pr create \ + --draft \ + --base "$base_branch" \ + --head "$branch_name" \ + --title "$commit_title" \ + --body-file "$pr_body")" + fi +fi + +printf '\nDone\n' +printf ' branch: %s\n' "$branch_name" +printf ' commit: %s\n' "$(current_head)" +if [ "$PUSH_CHANGES" = true ]; then + printf ' push: %s/%s\n' "$git_remote" "$branch_name" +else + printf ' push: skipped (--no-push)\n' +fi +if [ "$OPEN_PR" = true ]; then + printf ' pr: %s\n' "${pr_url:-}" +else + printf ' pr: skipped\n' +fi