diff --git a/scripts/go-upgrade/README.md b/scripts/go-upgrade/README.md new file mode 100644 index 000000000..bb42bfab7 --- /dev/null +++ b/scripts/go-upgrade/README.md @@ -0,0 +1,161 @@ +# Multi-Repo Upgrade Utility + +Multi-repository upgrade and dependency maintenance tool for the kptdev ecosystem. + +Coordinates Go version upgrades, linter version upgrades, and cross-repository dependency updates across 4 interdependent repositories: + +``` +krm-functions-sdk (leaf — no upstream deps) + ↓ + ┌───┴───────────────┐ + ↓ ↓ + kpt krm-functions-catalog + ↓ ↓ + └───────┬───────────┘ + ↓ + porch (top — depends on all three) +``` + +## Quick Start + +```bash +# Preview what would change +./upgrade.sh go-version --dry-run + +# Upgrade Go version across all repos +./upgrade.sh go-version + +# Upgrade a single repo +./upgrade.sh go-version --repo=porch + +# Full upgrade: Go + lint + cross-deps +./upgrade.sh all + +# Full upgrade + push PRs (one-shot) +./upgrade.sh all --push + +# Regenerate catalog documentation +./upgrade.sh generate-docs + +# Two-step workflow: upgrade, review diffs, then push separately +./upgrade.sh go-version +# ... inspect git diff in workspace/ ... +./upgrade.sh push --for=go-version +``` + +## Subcommands + +| Subcommand | Description | +|---|---| +| `go-version` | Bump `go` directive in all `go.mod` files, then verify (tidy, fmt, vet, build) | +| `lint-version` | Bump `GOLANGCI_LINT_VERSION` in Makefiles | +| `cross-deps` | Upgrade dependencies owned by other repos in the set to their latest version | +| `generate-docs` | Generate/sync Hugo doc pages in krm-functions-catalog | +| `all` | Run `go-version` + `lint-version` + `cross-deps` sequentially | +| `push` | Create branch, commit, push, and raise draft PR for pending workspace changes | + +## Options + +| Option | Description | +|---|---| +| `--repo=NAME` | Scope to a single repository | +| `--continue` | Don't fail-fast; accumulate errors and report at end | +| `--dry-run` | Show what would change without modifying files | +| `--push` | After successful operations, create branch, commit, push, and raise draft PR | +| `--for=CMD` | With `push` subcommand: specify which upgrade was done (default: `all`) | + +## Configuration + +Edit `config.env` to change target versions, repositories, or exclusions. + +### Fork Owner + +The `FORK_OWNER` variable controls which GitHub org/user to clone from. All repo URLs are derived from it. Defaults to `Nordix` (shared development forks). PRs always target upstream `kptdev/*` regardless of fork owner. + +```bash +# Use your personal fork +FORK_OWNER=myuser ./upgrade.sh go-version + +# Default: uses Nordix forks +./upgrade.sh go-version +``` + +### Target Versions + +```bash +TARGET_GO_VERSION="1.26.5" +TARGET_GOLANGCI_LINT_VERSION="2.12.2" +``` + +### Repository Format + +``` +NAME|GIT_URL|BRANCH|PR_TARGET +``` + +- `NAME` — identifier used with `--repo` and in output +- `GIT_URL` — SSH clone URL +- `BRANCH` — branch to clone and base for PRs +- `PR_TARGET` — GitHub `owner/repo` for cross-fork PRs (empty = PR against same repo) + +### Exclusions + +- `EXCLUDE_MODULES` — glob patterns for modules to skip entirely +- `EXCLUDE_DEPS_GLOBAL` — dependencies never upgraded by `cross-deps` +- `EXCLUDE_DEPS_REPO` — per-repo dependency exclusions + +## How It Works + +1. **Clone** repos into `workspace/` (reuses existing clones) +2. **Clean state** — ensures base branch, discards leftover changes, detects pollution +3. **Run subcommand** — modifies files, verifies each module +4. **Push** (if `--push`) — creates dated branch, commits, pushes, raises draft PR + +### Verification Pipeline + +Each module is verified with `GOWORK=off`: +``` +go mod tidy → go fmt ./... → go vet ./... → go build ./... +``` + +Modules are built standalone to respect their own dependency pins, avoiding false conflicts from workspace version unification. + +### Cross-Deps Resolution + +For each module, dependencies owned by another repo in the set are upgraded to the latest published version. Resolution order: +1. GitHub releases API +2. Go module proxy (stable tags, excluding alpha/dev) +3. Go module proxy (all tags) +4. `@latest` pseudo-version + +## File Structure + +``` +scripts/go-upgrade/ +├── upgrade.sh # Main entry point +├── config.env # Configuration (versions, repos, exclusions) +├── README.md # This file +└── lib/ + ├── common.sh # Logging, colors, failure tracking, helpers + ├── workspace.sh # Clone, clean state, module discovery + ├── verify.sh # tidy/fmt/vet/build pipeline + ├── versions.sh # go-version + lint-version subcommands + ├── deps.sh # cross-deps subcommand + └── push.sh # Branch/commit/push/PR creation +``` + +## Design Decisions + +- **`GOWORK=off` everywhere** — each module is built standalone respecting its own `go.mod` pins +- **No sequential releases needed for Go bumps** — each repo builds independently; PRs can be merged in parallel +- **Cross-fork PRs** — pushes to fork (`Nordix` or personal), raises PRs against upstream `kptdev/*` via GraphQL `createPullRequest` mutation with `headRepositoryId` +- **Fail-fast by default** — first failure stops execution (override with `--continue`) +- **Draft PRs** — all PRs are created as drafts for review before merge +- **Signed commits** — all commits use `--signoff` for DCO compliance + +## Prerequisites + +- `go` (matching `TARGET_GO_VERSION`) +- `jq` +- `gh` (GitHub CLI, only needed with `--push`) +- SSH access to configured repositories diff --git a/scripts/go-upgrade/config.env b/scripts/go-upgrade/config.env new file mode 100644 index 000000000..9cce42379 --- /dev/null +++ b/scripts/go-upgrade/config.env @@ -0,0 +1,64 @@ +# Copyright 2026 The kpt Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# go-upgrade configuration +# See README.md for full documentation of each option. + +# --- Fork owner (override via environment) --- +# Controls which GitHub org/user to clone from. +# Override: FORK_OWNER=myuser ./upgrade.sh go-version +FORK_OWNER="${FORK_OWNER:-Nordix}" + +# --- Target versions --- +TARGET_GO_VERSION="1.26.5" +TARGET_GOLANGCI_LINT_VERSION="2.12.2" + +# --- Workspace --- +WORKSPACE_DIR="workspace" + +# --- Repositories --- +# Format: NAME|GIT_URL|BRANCH|PR_TARGET +# +# PR_TARGET: GitHub owner/repo to raise PR against. +# Leave empty to PR against the same repo derived from GIT_URL. +# +# Ordered by dependency chain (least dependent first). +REPOS=( + "krm-functions-sdk|git@github.com:${FORK_OWNER}/krm-functions-sdk.git|main|kptdev/krm-functions-sdk" + "kpt|git@github.com:${FORK_OWNER}/kpt.git|main|kptdev/kpt" + "krm-functions-catalog|git@github.com:${FORK_OWNER}/krm-functions-catalog.git|main|kptdev/krm-functions-catalog" + "porch|git@github.com:${FORK_OWNER}/porch.git|main|kptdev/porch" +) + +# --- Lint Makefile locations (per repo) --- +declare -A LINT_MAKEFILES=( + [krm-functions-sdk]="go/Makefile" + [kpt]="Makefile" + [krm-functions-catalog]="functions/go/Makefile" + [porch]="make/go.mk" +) + +# --- Exclusions --- + +# Modules to skip entirely (glob patterns, matched against relative path from workspace) +EXCLUDE_MODULES=( + "*/archived/*" + "porch/third_party/*" +) + +# Global dependency exclusions (applies to all repos in cross-deps) +EXCLUDE_DEPS_GLOBAL=() + +# Per-repo dependency exclusions (space-separated) +declare -A EXCLUDE_DEPS_REPO=() diff --git a/scripts/go-upgrade/lib/common.sh b/scripts/go-upgrade/lib/common.sh new file mode 100644 index 000000000..7a2abf4bf --- /dev/null +++ b/scripts/go-upgrade/lib/common.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Copyright 2026 The kpt Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Common utilities: logging, colors, failure tracking, helpers. +# Sourced by upgrade.sh — do not execute directly. + +# --- Colors --- +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' + +# --- Logging --- +log() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERR]${NC} $*" >&2; } +step() { echo -e "${CYAN}[>>]${NC} $*"; } + +# --- Failure tracking --- +FAILURES=() + +record_failure() { + FAILURES+=("$1") + if [[ "$FAIL_FAST" == true ]]; then + err "Fail-fast: $1" + exit 1 + fi +} + +report_failures() { + if [[ ${#FAILURES[@]} -eq 0 ]]; then + log "All operations completed successfully." + return 0 + fi + err "Failures (${#FAILURES[@]}):" + for f in "${FAILURES[@]}"; do + echo -e " ${RED}✗${NC} $f" + done + return 1 +} + +# --- Workspace path --- +WORKSPACE="" +ws() { + if [[ -z "$WORKSPACE" ]]; then + WORKSPACE="${SCRIPT_DIR}/${WORKSPACE_DIR}" + fi + echo "$WORKSPACE" +} + +# Relative path for display +rel_path() { echo "${1#$(ws)/}"; } + +# --- Repo entry parsing --- +# Format: NAME|GIT_URL|BRANCH|PR_TARGET +repo_name() { IFS='|' read -r name _ _ _ <<< "$1"; echo "$name"; } + +repo_url() { IFS='|' read -r _ url _ _ <<< "$1"; echo "$url"; } + +repo_base_branch() { IFS='|' read -r _ _ branch _ <<< "$1"; echo "$branch"; } + +repo_pr_target() { + local entry="$1" + IFS='|' read -r _ url _ pr_target <<< "$entry" + if [[ -n "$pr_target" ]]; then + echo "$pr_target" + else + echo "$url" | sed -E 's|.*[:/]([^/]+/[^/]+)\.git$|\1|' + fi +} + +# Return repos filtered by --repo flag +active_repos() { + for entry in "${REPOS[@]}"; do + local name + name="$(repo_name "$entry")" + if [[ -z "$FILTER_REPO" || "$name" == "$FILTER_REPO" ]]; then + echo "$entry" + fi + done +} + +# --- Exclusion checks --- +is_excluded_module() { + local mod_path="$1" + for pattern in "${EXCLUDE_MODULES[@]}"; do + # shellcheck disable=SC2053 + if [[ "$mod_path" == $pattern ]]; then + return 0 + fi + done + return 1 +} + +is_excluded_dep() { + local repo="$1" dep="$2" + for d in "${EXCLUDE_DEPS_GLOBAL[@]+"${EXCLUDE_DEPS_GLOBAL[@]}"}"; do + [[ "$dep" == "$d" || "$dep" == "$d/"* ]] && return 0 + done + local repo_exclusions="${EXCLUDE_DEPS_REPO[$repo]:-}" + for d in $repo_exclusions; do + [[ "$dep" == "$d" || "$dep" == "$d/"* ]] && return 0 + done + return 1 +} + +# --- Dependency checks --- +check_deps() { + command -v jq >/dev/null 2>&1 || { err "jq is required but not installed"; exit 1; } + + # gh is needed for both `--push` and the `push` subcommand. + if [[ "$GIT_PUSH" == true || "$SUBCOMMAND" == "push" ]]; then + command -v gh >/dev/null 2>&1 || { err "gh (GitHub CLI) is required for PR creation"; exit 1; } + fi + + # Only require Go (and enforce exact version) when we will run Go tooling. + case "$SUBCOMMAND" in + go-version|cross-deps|all) + command -v go >/dev/null 2>&1 || { err "go is required but not installed"; exit 1; } + if [[ "$DRY_RUN" == false ]]; then + local installed_go + installed_go="$(go version | awk '{print $3}' | sed 's/^go//')" + if [[ "$installed_go" != "$TARGET_GO_VERSION" ]]; then + err "Installed Go version is ${installed_go}, but TARGET_GO_VERSION is ${TARGET_GO_VERSION}" + err "Install Go ${TARGET_GO_VERSION} or update TARGET_GO_VERSION in config.env" + exit 1 + fi + fi + ;; + *) + ;; + esac +} diff --git a/scripts/go-upgrade/lib/deps.sh b/scripts/go-upgrade/lib/deps.sh new file mode 100644 index 000000000..3bdb69a2d --- /dev/null +++ b/scripts/go-upgrade/lib/deps.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# Copyright 2026 The kpt Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Cross-repository dependency upgrade subcommand. +# Sourced by upgrade.sh — do not execute directly. + +# --- Module map: module_path -> repo_name --- +declare -A MOD_PATH_TO_REPO=() + +build_module_map() { + for entry in "${REPOS[@]}"; do + local name + name="$(repo_name "$entry")" + [[ ! -d "$(ws)/${name}" ]] && continue + while IFS= read -r mod_abs; do + [[ -z "$mod_abs" ]] && continue + local mod_path + mod_path=$(grep -m1 '^module ' "${mod_abs}/go.mod" | awk '{print $2}') + [[ -z "$mod_path" ]] && continue + MOD_PATH_TO_REPO[$mod_path]="$name" + done < <(discover_modules "$entry") + done +} + +# Find which repo owns a dependency path +find_dep_repo() { + local dep="$1" + for mod_path in "${!MOD_PATH_TO_REPO[@]}"; do + if [[ "$dep" == "$mod_path" || "$dep" == "$mod_path/"* ]]; then + echo "${MOD_PATH_TO_REPO[$mod_path]}" + return + fi + done +} + +# Resolve the latest stable version of a module. +# Tries: GitHub releases → go list (stable) → go list (all) → @latest pseudo-version. +resolve_latest_version() { + local mod_abs="$1" dep="$2" + local version="" + + # Try GitHub releases first (proxy may not have indexed the latest tag yet) + local gh_owner_repo + gh_owner_repo=$(echo "$dep" | sed -n 's|^github\.com/||p') + if [[ -n "$gh_owner_repo" ]] && command -v gh >/dev/null 2>&1; then + version=$(gh api "repos/${gh_owner_repo}/releases" --jq '.[0].tag_name' 2>/dev/null) || true + fi + + # Fallback: Go module proxy — latest tagged version (exclude alpha and dev) + if [[ -z "$version" || "$version" != v* ]]; then + version=$(cd "$mod_abs" && GOWORK=off go list -m -versions "$dep" 2>/dev/null \ + | tr ' ' '\n' | grep -vE '\-(alpha|dev)' | tail -1) || true + fi + # Fallback: latest tagged version including pre-release + if [[ -z "$version" || "$version" != v* ]]; then + version=$(cd "$mod_abs" && GOWORK=off go list -m -versions "$dep" 2>/dev/null | awk '{print $NF}') || true + fi + # Fallback: pseudo-version via @latest + if [[ -z "$version" || "$version" != v* ]]; then + version=$(cd "$mod_abs" && GOWORK=off go list -m "${dep}@latest" 2>/dev/null | awk '{print $2}') || true + fi + echo "$version" +} + +# --- Subcommand: cross-deps --- +cmd_cross_deps() { + log "=== Upgrading cross-repository dependencies ===" + + # Build module path → repo name map once + build_module_map + + while IFS= read -r entry; do + local name + name="$(repo_name "$entry")" + step "Repository: ${name}" + + while IFS= read -r mod_abs; do + [[ -z "$mod_abs" ]] && continue + local gomod="${mod_abs}/go.mod" + + # Find cross-repo deps (direct + indirect) + local cross_deps=() + local all_deps + all_deps=$(go mod edit -json "$gomod" | jq -r '.Require[]? | .Path') + + while IFS= read -r dep; do + [[ -z "$dep" ]] && continue + local dep_repo + dep_repo="$(find_dep_repo "$dep")" + if [[ -n "$dep_repo" && "$dep_repo" != "$name" ]]; then + cross_deps+=("$dep") + fi + done <<< "$all_deps" + + if [[ ${#cross_deps[@]} -eq 0 ]]; then + continue + fi + + step " cross-deps: $(rel_path "$mod_abs")" + + if [[ "$DRY_RUN" == true ]]; then + for dep in "${cross_deps[@]}"; do + if is_excluded_dep "$name" "$dep"; then + log " skip (excluded): ${dep}" + else + log " ${dep} → (latest published)" + fi + done + continue + fi + + local upgrade_args=() + for dep in "${cross_deps[@]}"; do + if is_excluded_dep "$name" "$dep"; then + log " skip (excluded): ${dep}" + continue + fi + local latest_version + latest_version=$(resolve_latest_version "$mod_abs" "$dep") + if [[ -z "$latest_version" || "$latest_version" != v* ]]; then + warn " ${dep}: no valid version found, skipping" + continue + fi + log " ${dep} → ${latest_version}" + upgrade_args+=("${dep}@${latest_version}") + done + + if [[ ${#upgrade_args[@]} -eq 0 ]]; then + continue + fi + + if ! (cd "$mod_abs" && GOWORK=off go get "${upgrade_args[@]}" 2>&1); then + record_failure "cross-deps upgrade: $(rel_path "$mod_abs")" + continue + fi + + verify_module "$mod_abs" || true + done < <(discover_modules "$entry") + done < <(active_repos) +} diff --git a/scripts/go-upgrade/lib/push.sh b/scripts/go-upgrade/lib/push.sh new file mode 100644 index 000000000..8c926bea8 --- /dev/null +++ b/scripts/go-upgrade/lib/push.sh @@ -0,0 +1,226 @@ +#!/bin/bash +# Copyright 2026 The kpt Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Push subcommand: create branch, commit, push, and raise PR. +# Sourced by upgrade.sh — do not execute directly. + +# --- Subcommand: push --- +cmd_push() { + log "=== Creating branches, committing, pushing, and raising PRs ===" + + local branch_name + case "$SUBCOMMAND" in + go-version) branch_name="upgrade-go-${TARGET_GO_VERSION}-$(date +%Y%m%d)" ;; + lint-version) branch_name="upgrade-golangci-lint-${TARGET_GOLANGCI_LINT_VERSION}-$(date +%Y%m%d)" ;; + cross-deps) branch_name="update-cross-deps-$(date +%Y%m%d)" ;; + generate-docs) branch_name="generate-docs-$(date +%Y%m%d)" ;; + all) branch_name="upgrade-go-${TARGET_GO_VERSION}-deps-$(date +%Y%m%d)" ;; + esac + local -a created_prs=() + + while IFS= read -r entry; do + local name + name="$(repo_name "$entry")" + local dir + dir="$(ws)/${name}" + local base_branch + base_branch="$(repo_base_branch "$entry")" + + if [[ ! -d "$dir/.git" ]]; then + warn "Skipping ${name}: not a git repository" + continue + fi + + step "Repository: ${name}" + + # Checkout base branch first + if ! (cd "$dir" && git checkout "$base_branch" 2>&1); then + record_failure "checkout ${base_branch}: ${name}" + continue + fi + + # Check if there are changes to commit + if (cd "$dir" && git diff --quiet && git diff --cached --quiet); then + log " ${name}: no changes to commit, skipping" + continue + fi + + local target + target="$(repo_pr_target "$entry")" + + # Create branch (fail if exists) + if ! (cd "$dir" && git checkout -b "$branch_name" 2>&1); then + record_failure "branch creation: ${name} (branch ${branch_name} may already exist)" + continue + fi + + # Stage modified tracked files and any new go.sum files + (cd "$dir" && git add -u) + (cd "$dir" && find . -name 'go.sum' -not -path '*/.git/*' -exec git add {} + 2>/dev/null) || true + # Stage generated documentation files + if [[ "$SUBCOMMAND" == "generate-docs" ]]; then + (cd "$dir" && git add documentation/content/ 2>/dev/null) || true + fi + + # Build commit message based on what was actually done + local commit_msg="" pr_title="" pr_body_items="" + case "$SUBCOMMAND" in + go-version) + commit_msg="Upgrade Go to ${TARGET_GO_VERSION}" + pr_title="Upgrade Go to ${TARGET_GO_VERSION}" + pr_body_items="- Go version bumped to \`${TARGET_GO_VERSION}\`" + ;; + lint-version) + commit_msg="Upgrade golangci-lint to ${TARGET_GOLANGCI_LINT_VERSION}" + pr_title="Upgrade golangci-lint to ${TARGET_GOLANGCI_LINT_VERSION}" + pr_body_items="- golangci-lint version bumped to \`${TARGET_GOLANGCI_LINT_VERSION}\`" + ;; + cross-deps) + commit_msg="Update cross-repository dependencies" + pr_title="Update cross-repository dependencies" + pr_body_items="- Cross-repository dependencies updated to latest" + ;; + generate-docs) + commit_msg="Regenerate catalog documentation" + pr_title="Regenerate catalog documentation" + pr_body_items="- Hugo doc pages regenerated from function source" + ;; + all) + commit_msg="Upgrade Go to ${TARGET_GO_VERSION} and update dependencies" + pr_title="Upgrade Go to ${TARGET_GO_VERSION} and update dependencies" + pr_body_items="- Go version bumped to \`${TARGET_GO_VERSION}\` +- golangci-lint version bumped to \`${TARGET_GOLANGCI_LINT_VERSION}\` +- Cross-repository dependencies updated to latest" + ;; + esac + local verified_line="" + if [[ "$SUBCOMMAND" != "generate-docs" ]]; then + verified_line=" +- All modules verified (go mod tidy, go fmt, go vet, go build)" + fi + commit_msg="${commit_msg} + +${pr_body_items}${verified_line}" + + local pr_body_verified="" + if [[ "$SUBCOMMAND" != "generate-docs" ]]; then + pr_body_verified=" +- All modules verified (\`go mod tidy\`, \`go fmt\`, \`go vet\`, \`go build\`)" + fi + local pr_body + pr_body="## Description + +${pr_body_items}${pr_body_verified} + +## AI Disclosure + +- [x] **I have used AI in the creation of this PR.** + +Automated via go-upgrade script (AI-assisted development)." + + if ! (cd "$dir" && git commit --signoff -m "$commit_msg" 2>&1); then + record_failure "commit: ${name}" + continue + fi + + log " committed: ${branch_name}" + + # Push + if ! (cd "$dir" && git push -u origin "$branch_name" 2>&1); then + record_failure "push: ${name}" + continue + fi + + log " pushed: ${branch_name}" + + # Verify branch has commits ahead of base before creating PR + if (cd "$dir" && [[ "$(git rev-parse "origin/${base_branch}")" == "$(git rev-parse HEAD)" ]]); then + warn " ${name}: branch has no new commits vs origin/${base_branch}, skipping PR" + continue + fi + + # Determine head ref and repository IDs for cross-fork PR + local origin_repo + origin_repo=$(cd "$dir" && git remote get-url origin | sed -E 's|.*[:/]([^/]+/[^/]+)\.git$|\1|') + + log " PR: ${origin_repo}:${branch_name} → ${target}:${base_branch}" + log " commits ahead: $(cd "$dir" && git log --oneline "origin/${base_branch}..HEAD" | wc -l)" + + # Allow GitHub to propagate the pushed branch + sleep 5 + + # Use GraphQL mutation with headRepositoryId for reliable cross-fork PRs + local target_owner="${target%%/*}" + local target_name="${target##*/}" + local origin_owner="${origin_repo%%/*}" + local origin_name="${origin_repo##*/}" + + local target_repo_id origin_repo_id + target_repo_id=$(gh api graphql \ + -F owner="$target_owner" \ + -F name="$target_name" \ + -f query='query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }' \ + --jq '.data.repository.id') || true + origin_repo_id=$(gh api graphql \ + -F owner="$origin_owner" \ + -F name="$origin_name" \ + -f query='query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }' \ + --jq '.data.repository.id') || true + + if [[ -z "$target_repo_id" || -z "$origin_repo_id" ]]; then + record_failure "PR creation: ${name} (could not resolve repo IDs)" + continue + fi + + local pr_url + if ! pr_url=$(gh api graphql \ + -F repoId="$target_repo_id" \ + -F baseRef="$base_branch" \ + -F headRef="$branch_name" \ + -F headRepoId="$origin_repo_id" \ + -F title="$pr_title" \ + -F body="$pr_body" \ + -f query=' + mutation($repoId: ID!, $baseRef: String!, $headRef: String!, $headRepoId: ID!, $title: String!, $body: String!) { + createPullRequest(input: { + repositoryId: $repoId, + baseRefName: $baseRef, + headRefName: $headRef, + headRepositoryId: $headRepoId, + title: $title, + body: $body, + draft: true + }) { + pullRequest { url } + } + } + ' --jq '.data.createPullRequest.pullRequest.url' 2>&1); then + err " ${pr_url}" + record_failure "PR creation: ${name}" + continue + fi + + created_prs+=("${name}: ${pr_url}") + log " PR created: ${pr_url}" + done < <(active_repos) + + if [[ ${#created_prs[@]} -gt 0 ]]; then + echo "" + log "=== PRs Created ===" + for pr in "${created_prs[@]}"; do + log " ${pr}" + done + fi +} diff --git a/scripts/go-upgrade/lib/verify.sh b/scripts/go-upgrade/lib/verify.sh new file mode 100644 index 000000000..5320317f5 --- /dev/null +++ b/scripts/go-upgrade/lib/verify.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Copyright 2026 The kpt Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Module verification: tidy, fmt, vet, build. +# Sourced by upgrade.sh — do not execute directly. + +# Run go mod tidy + fmt + vet + build for a module. +# Records failures via record_failure and returns non-zero on error. +verify_module() { + local abs_dir="$1" + + # Hugo modules have no Go packages — use hugo mod tidy instead + local has_packages + if ! has_packages=$(cd "$abs_dir" && GOWORK=off go list ./... 2>/dev/null); then + record_failure "list: $(rel_path "$abs_dir")" + return 1 + fi + if [[ -z "$has_packages" ]]; then + if command -v hugo >/dev/null 2>&1; then + (cd "$abs_dir" && hugo mod tidy 2>&1) || true + fi + return 0 + fi + + if ! (cd "$abs_dir" && GOWORK=off go mod tidy 2>&1); then + record_failure "tidy: $(rel_path "$abs_dir")" + return 1 + fi + if ! (cd "$abs_dir" && GOWORK=off go fmt ./... 2>&1); then + record_failure "fmt: $(rel_path "$abs_dir")" + return 1 + fi + if ! (cd "$abs_dir" && GOWORK=off go vet ./... 2>&1); then + record_failure "vet: $(rel_path "$abs_dir")" + return 1 + fi + if ! (cd "$abs_dir" && GOWORK=off go build ./... 2>&1); then + record_failure "build: $(rel_path "$abs_dir")" + return 1 + fi + return 0 +} diff --git a/scripts/go-upgrade/lib/versions.sh b/scripts/go-upgrade/lib/versions.sh new file mode 100644 index 000000000..eaf20b852 --- /dev/null +++ b/scripts/go-upgrade/lib/versions.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# Copyright 2026 The kpt Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Version upgrade subcommands: go-version, lint-version. +# Sourced by upgrade.sh — do not execute directly. + +# --- Subcommand: go-version --- +cmd_go_version() { + log "=== Upgrading Go version to ${TARGET_GO_VERSION} ===" + + while IFS= read -r entry; do + local name + name="$(repo_name "$entry")" + step "Repository: ${name}" + + local modules + modules="$(discover_modules "$entry")" + + while IFS= read -r mod_abs; do + [[ -z "$mod_abs" ]] && continue + local gomod="${mod_abs}/go.mod" + local current + current=$(grep -m1 '^go ' "$gomod" | awk '{print $2}') + + if [[ "$current" == "$TARGET_GO_VERSION" ]]; then + continue + fi + + if [[ "$DRY_RUN" == true ]]; then + log " [dry-run] $(rel_path "$mod_abs"): ${current} → ${TARGET_GO_VERSION}" + continue + fi + + sed -i.bak "s/^go .*/go ${TARGET_GO_VERSION}/" "$gomod" && rm -f "${gomod}.bak" + log " $(rel_path "$mod_abs"): ${current} → ${TARGET_GO_VERSION}" + done <<< "$modules" + + if [[ "$DRY_RUN" == true ]]; then + continue + fi + + while IFS= read -r mod_abs; do + [[ -z "$mod_abs" ]] && continue + step " verify: $(rel_path "$mod_abs")" + verify_module "$mod_abs" || true + done <<< "$modules" + done < <(active_repos) +} + +# --- Subcommand: lint-version --- +cmd_lint_version() { + log "=== Upgrading golangci-lint version to ${TARGET_GOLANGCI_LINT_VERSION} ===" + + while IFS= read -r entry; do + local name + name="$(repo_name "$entry")" + local makefile_rel="${LINT_MAKEFILES[$name]:-}" + + if [[ -z "$makefile_rel" ]]; then + warn "${name}: no lint Makefile configured, skipping" + continue + fi + + local makefile="$(ws)/${name}/${makefile_rel}" + + if [[ ! -f "$makefile" ]]; then + warn "${name}: ${makefile_rel} not found, skipping" + continue + fi + + local current + current=$(grep -Eo 'GOLANGCI_LINT_VERSION[[:space:]]*[:?]?=[[:space:]]*[0-9.]+' "$makefile" | head -1 | sed -E 's/.*=[[:space:]]*//' || true) + + if [[ -z "$current" ]]; then + warn "${name}: GOLANGCI_LINT_VERSION not found in ${makefile_rel}, skipping" + continue + fi + + if [[ "$current" == "$TARGET_GOLANGCI_LINT_VERSION" ]]; then + log " ${name}: already at ${TARGET_GOLANGCI_LINT_VERSION}" + continue + fi + + if [[ "$DRY_RUN" == true ]]; then + log " [dry-run] ${name}: ${current} → ${TARGET_GOLANGCI_LINT_VERSION}" + continue + fi + + sed -i.bak -E "s/(GOLANGCI_LINT_VERSION[[:space:]]*[:?]?=[[:space:]]*)[0-9.]+/\1${TARGET_GOLANGCI_LINT_VERSION}/" "$makefile" && rm -f "${makefile}.bak" + log " ${name}: ${current} → ${TARGET_GOLANGCI_LINT_VERSION}" + done < <(active_repos) +} + +# --- Subcommand: generate-docs --- +cmd_generate_docs() { + log "=== Generating catalog documentation ===" + + local catalog_dir="$(ws)/krm-functions-catalog" + + if [[ ! -d "$catalog_dir" ]]; then + record_failure "generate-docs: krm-functions-catalog not found in workspace" + return + fi + + # Respect --repo filter: skip if filtering to a different repo + if [[ -n "$FILTER_REPO" && "$FILTER_REPO" != "krm-functions-catalog" ]]; then + log " skipped: generate-docs only applies to krm-functions-catalog" + return + fi + + if [[ "$DRY_RUN" == true ]]; then + log " [dry-run] would run: make generate-docs in krm-functions-catalog" + (cd "$catalog_dir" && cd scripts/generate_docs && go run . generate --dry-run 2>&1) || true + return + fi + + if ! (cd "$catalog_dir" && make generate-docs 2>&1); then + record_failure "generate-docs: make generate-docs failed" + return + fi + + log " Done." +} diff --git a/scripts/go-upgrade/lib/workspace.sh b/scripts/go-upgrade/lib/workspace.sh new file mode 100644 index 000000000..e352be032 --- /dev/null +++ b/scripts/go-upgrade/lib/workspace.sh @@ -0,0 +1,137 @@ +#!/bin/bash +# Copyright 2026 The kpt Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Workspace management: cloning, clean state, module discovery. +# Sourced by upgrade.sh — do not execute directly. + +# --- Module discovery with caching --- +declare -A MODULE_CACHE=() + +discover_modules() { + local entry="$1" + local name + name="$(repo_name "$entry")" + + # Return cached result if available + if [[ -n "${MODULE_CACHE[$name]:-}" ]]; then + echo "${MODULE_CACHE[$name]}" + return + fi + + local dir + dir="$(ws)/${name}" + if [[ ! -d "$dir" ]]; then + err "Repository directory not found: ${dir}" + return 1 + fi + + local result="" + while IFS= read -r gomod; do + local mod_dir="${gomod%/go.mod}" + local rel="${mod_dir#$(ws)/}" + if is_excluded_module "$rel"; then + continue + fi + if [[ -n "$result" ]]; then + result+=$'\n' + fi + result+="$mod_dir" + done < <(find "$dir" -name go.mod -not -path '*/.git/*' | sort) + + MODULE_CACHE[$name]="$result" + echo "$result" +} + +# --- Workspace setup --- +ensure_workspace() { + local workspace + workspace="$(ws)" + local need_clone=false + while IFS= read -r entry; do + IFS='|' read -r name url branch _ <<< "$entry" + if [[ ! -d "${workspace}/${name}" ]]; then + need_clone=true + mkdir -p "$workspace" + log "Cloning ${name} (branch: ${branch})..." + git clone --branch "$branch" --single-branch "$url" "${workspace}/${name}" + fi + done < <(active_repos) + if [[ "$need_clone" == false ]]; then + log "Workspace exists, reusing: ${workspace}" + fi +} + +# Ensure each repo is on the correct branch with a clean working tree. +# Discards uncommitted changes from prior interrupted runs. +ensure_clean_state() { + while IFS= read -r entry; do + local name + name="$(repo_name "$entry")" + local dir + dir="$(ws)/${name}" + local base_branch + base_branch="$(repo_base_branch "$entry")" + + [[ ! -d "$dir/.git" ]] && continue + + # Ensure we're on the base branch + local current_branch + current_branch=$(cd "$dir" && git rev-parse --abbrev-ref HEAD) + if [[ "$current_branch" != "$base_branch" ]]; then + warn "${name}: on branch '${current_branch}', switching to '${base_branch}'" + (cd "$dir" && git checkout "$base_branch" 2>&1) || { + record_failure "clean state: cannot checkout ${base_branch} in ${name}" + continue + } + fi + + # Discard any uncommitted changes (leftover from prior run) + if ! (cd "$dir" && git diff --quiet && git diff --cached --quiet); then + warn "${name}: discarding uncommitted changes from prior run" + (cd "$dir" && git reset --hard && git clean -fd) 2>&1 || true + fi + + # Detect base branch pollution: local commits ahead of remote + if ! (cd "$dir" && git fetch -q origin "$base_branch" 2>&1); then + record_failure "clean state: cannot fetch origin/${base_branch} in ${name}" + continue + fi + local ahead + ahead=$(cd "$dir" && git rev-list --count "origin/${base_branch}..${base_branch}" 2>/dev/null) || ahead=0 + if [[ "$ahead" -gt 0 ]]; then + err "${name}: base branch '${base_branch}' is ${ahead} commit(s) ahead of origin/${base_branch}" + err " This likely means a prior run committed directly to the base branch." + err " Fix manually: cd $(ws)/${name} && git reset --hard origin/${base_branch}" + exit 1 + fi + done < <(active_repos) +} + +# --- Git status summary --- +show_git_status() { + log "=== Git Status ===" + while IFS= read -r entry; do + local name + name="$(repo_name "$entry")" + local dir + dir="$(ws)/${name}" + if [[ ! -d "$dir/.git" ]]; then + continue + fi + step "${name}" + (cd "$dir" && git status --short) || true + echo "" + done < <(active_repos) +} diff --git a/scripts/go-upgrade/upgrade.sh b/scripts/go-upgrade/upgrade.sh new file mode 100755 index 000000000..fdcf01473 --- /dev/null +++ b/scripts/go-upgrade/upgrade.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Copyright 2026 The kpt Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Go Upgrade Utility — multi-repo Go version and dependency upgrade tool. +# +# Usage: ./upgrade.sh [options] +# +# See README.md for full documentation. + +# Require bash >= 4 (associative arrays are used throughout the utility). +if ((BASH_VERSINFO[0] < 4)); then + echo "[ERR] bash >= 4 is required (found ${BASH_VERSION}). Please install a newer bash and ensure it's first in PATH." >&2 + exit 1 +fi + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --- Load configuration and library modules --- +source "${SCRIPT_DIR}/config.env" +source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/workspace.sh" +source "${SCRIPT_DIR}/lib/verify.sh" +source "${SCRIPT_DIR}/lib/versions.sh" +source "${SCRIPT_DIR}/lib/deps.sh" +source "${SCRIPT_DIR}/lib/push.sh" + +# --- Options --- +FAIL_FAST=true +DRY_RUN=false +GIT_PUSH=false +FILTER_REPO="" +SUBCOMMAND="" +PUSH_FOR="" + +# --- Usage --- +usage() { + cat < [options] + +Subcommands: + go-version Bump Go version in all go.mod files, tidy, and build + lint-version Bump golangci-lint version in all Makefiles + cross-deps Upgrade cross-repository dependencies to latest published version + generate-docs Generate/sync Hugo doc pages in krm-functions-catalog + all Run go-version + lint-version + cross-deps in sequence + push Create branch, commit, push, and raise PR for pending changes + +Options: + --repo=NAME Run only against the specified repository + --dry-run Show what would change without modifying files + --push After operations, create branch, commit, push, and raise PR + --for=CMD With 'push' subcommand: specify which upgrade was done + (go-version, lint-version, cross-deps, generate-docs, all). Default: all + +Environment: + FORK_OWNER Override fork owner (default: Nordix). Used to derive clone URLs. + Example: FORK_OWNER=myuser ./upgrade.sh go-version + +Configuration: config.env +EOF + exit 1 +} + +# --- Parse args --- +parse_args() { + if [[ $# -eq 0 ]]; then usage; fi + + for arg in "$@"; do + case "$arg" in + go-version|lint-version|cross-deps|generate-docs|all|push) + if [[ -n "$SUBCOMMAND" && "$SUBCOMMAND" != "$arg" ]]; then + err "Multiple subcommands provided: ${SUBCOMMAND} and ${arg}" + usage + fi + SUBCOMMAND="$arg" ;; + --dry-run) + DRY_RUN=true ;; + --push) + GIT_PUSH=true ;; + --for=*) + PUSH_FOR="${arg#--for=}" ;; + --repo=*) + FILTER_REPO="${arg#--repo=}" ;; + -h|--help) + usage ;; + *) + err "Unknown argument: $arg" + usage ;; + esac + done + + if [[ -z "$SUBCOMMAND" ]]; then usage; fi + + # Validate --repo value + if [[ -n "$FILTER_REPO" ]]; then + local valid=false + for entry in "${REPOS[@]}"; do + if [[ "$(repo_name "$entry")" == "$FILTER_REPO" ]]; then + valid=true + break + fi + done + if [[ "$valid" == false ]]; then + err "Unknown repo: ${FILTER_REPO}" + err "Available: $(for e in "${REPOS[@]}"; do repo_name "$e"; done | tr '\n' ' ')" + exit 1 + fi + fi + + # Validate --for value when push subcommand is used + if [[ "$SUBCOMMAND" == "push" && -n "$PUSH_FOR" ]]; then + case "$PUSH_FOR" in + go-version|lint-version|cross-deps|generate-docs|all) ;; + *) + err "Invalid --for value: ${PUSH_FOR}" + err "Valid values: go-version, lint-version, cross-deps, generate-docs, all" + exit 1 ;; + esac + fi +} + +# --- Main --- +main() { + parse_args "$@" + check_deps + + log "=== Go Upgrade Utility ===" + log "Subcommand: ${SUBCOMMAND}" + log "Target Go: ${TARGET_GO_VERSION}" + log "Target golangci-lint: ${TARGET_GOLANGCI_LINT_VERSION}" + log "Fork owner: ${FORK_OWNER}" + if [[ "$DRY_RUN" == true ]]; then log "Mode: dry-run"; fi + if [[ "$GIT_PUSH" == true ]]; then log "Mode: push enabled"; fi + if [[ -n "$FILTER_REPO" ]]; then log "Repo filter: ${FILTER_REPO}"; fi + echo "" + + # cross-deps needs all repos present to build the module-path→repo map, + # so temporarily disable filtering for the clone step. + # generate-docs only needs krm-functions-catalog. + local orig_filter="$FILTER_REPO" + if [[ "$SUBCOMMAND" == "cross-deps" || "$SUBCOMMAND" == "all" ]]; then + FILTER_REPO="" + elif [[ "$SUBCOMMAND" == "generate-docs" && -z "$FILTER_REPO" ]]; then + FILTER_REPO="krm-functions-catalog" + fi + ensure_workspace + FILTER_REPO="$orig_filter" + ensure_clean_state + + local push_done=false + case "$SUBCOMMAND" in + go-version) cmd_go_version ;; + lint-version) cmd_lint_version ;; + cross-deps) cmd_cross_deps ;; + generate-docs) cmd_generate_docs ;; + all) + cmd_go_version + cmd_lint_version + cmd_cross_deps + ;; + push) + # Standalone push: use --for to determine branch/commit naming + SUBCOMMAND="${PUSH_FOR:-all}" + cmd_push + push_done=true + ;; + esac + + if [[ "$push_done" == false && "$GIT_PUSH" == true && "$DRY_RUN" == false ]]; then + if [[ ${#FAILURES[@]} -gt 0 ]]; then + warn "Skipping push: ${#FAILURES[@]} failure(s) during upgrade" + else + cmd_push + fi + fi + + echo "" + if [[ "$DRY_RUN" == false ]]; then show_git_status; fi + report_failures || exit 1 +} + +main "$@"