diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 41647e1..95e2ee1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -44,6 +44,7 @@ "./skills/ci/rhdh-prow-jobs", "./skills/ci/rhdh-prow-release-branch", "./skills/ci/rhdh-prow-trigger", + "./skills/ci/rhdh-must-gather-helm-bump", "./skills/ci/rhdh-yarn-bump" ] }, diff --git a/README.md b/README.md index 7d67430..152063c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ one. It performs no work itself. and the single source of truth for membership. This file does not restate it. Two skills are human-invoked and never selected automatically: `/ask-rhdh` and -`/setup-rhdh-skills`. The other 39 are model-invoked, and can also be called by +`/setup-rhdh-skills`. The other 40 are model-invoked, and can also be called by name. Skills are grouped into six folders: @@ -57,7 +57,7 @@ Skills are grouped into six folders: | --- | --- | | `jira/` | Creating, refining, updating, and reporting on RHIDP, RHDHPLAN, RHDHBUGS, and RHDHSUPP work, plus sprint ceremonies and linking PRs to issues. | | `plugins/` | Authoring, wiring, exporting, and fixing Backstage dynamic plugins; the overlays repository; local RHDH; opening and reviewing pull requests; midstream propagation. | -| `ci/` | Prow job configuration and nightly triggers, Konflux and Tekton task updates, base images, and Yarn bumps. | +| `ci/` | Prow job configuration and nightly triggers, Konflux and Tekton task updates, base images, must-gather Helm bumps, and Yarn bumps. | | `release/` | Release status and readiness, milestone schedules, freeze announcements, teams, test-plan review, platform lifecycle, and the plugin CVE export. | | `reference/` | The reusable layer other skills invoke by name: repository and version context, the forge read seam, the write gate, and the Jira and Backstage reference material. | | `meta/` | The two human-invoked entry points, plus skill authoring and repository agent-readiness. | diff --git a/skills/ci/rhdh-must-gather-helm-bump/SKILL.md b/skills/ci/rhdh-must-gather-helm-bump/SKILL.md new file mode 100644 index 0000000..3a82097 --- /dev/null +++ b/skills/ci/rhdh-must-gather-helm-bump/SKILL.md @@ -0,0 +1,108 @@ +--- +name: rhdh-must-gather-helm-bump +description: >- + Bumps the Helm CLI baked into redhat-developer/rhdh-must-gather, mirrors + helm-related files into gitlab.cee.redhat.com/rhidp/rhdh distgit, patches + Konflux Tekton prefetch (CGW generic vs vendored gomod), and updates + sync/upstream_SHA. Use when bumping must-gather's HELM_VERSION (upstream or + downstream), switching between CGW binary and vendored Helm build paths, or + diagnosing a Konflux rhdh-must-gather prefetch failure after a Helm release. +--- + +# RHDH must-gather Helm bump + +## Goal + +Propagate a **Helm CLI** version bump across upstream GitHub and midstream GitLab: + +| Repo | Path | What changes | +|------|------|--------------| +| [`redhat-developer/rhdh-must-gather`](https://github.com/redhat-developer/rhdh-must-gather) | repo root | `Makefile` `HELM_VERSION`, `artifacts.lock.yaml` **or** `vendor/helm/`, Stage 2a/2b, hack scripts | +| [`gitlab.cee.redhat.com/rhidp/rhdh`](https://gitlab.cee.redhat.com/rhidp/rhdh) | `distgit/containers/rhdh-must-gather/` | Mirror helm-related files; regenerate hermetic `Containerfile` from `.rhdh/docker/Containerfile` | +| Same (midstream) | `.tekton/rhdh-must-gather-2-{pull,push}.yaml` | `prefetch-input` (`generic` vs `gomod`) | +| Same | `.tekton-templates/components.yaml` | `must-gather.prefetch_input` only | +| Same | `sync/upstream_SHA_rhdh-must-gather` | Upstream commit SHA after sync | + +## Essential principles + +- **Helm CLI ≠ RHDH chart version.** This skill only bumps the CLI in the must-gather image, not `oci://quay.io/rhdh/chart`. +- **CGW binary path is preferred** when `mirror.openshift.com/pub/cgw/helm//` has linux amd64/arm64 tarballs — smaller tree, faster Konflux builds. +- **Execute the bundled script** ([scripts/bump-must-gather-helm.py](scripts/bump-must-gather-helm.py)); do not reimplement lockfile, Stage flip, or Tekton edits inline. +- **`--check` and `--dry-run` are the preview.** Apply is a write: follow `/mutation-gate`. Approval to preview is not approval to write. +- **Commit / PR / MR only when the user asks.** + +## Prerequisites + +- Python 3.9+, `bash`, `curl`, `git`, `rsync` +- Network access to CGW mirror and both repo checkouts +- Clean git trees in upstream and downstream (or pass `--allow-dirty`) +- Upstream checkout on a branch that contains `hack/update-helm-lockfile.sh` + +## Usage + +```bash +SKILL= + +# Probe CGW + planned install path +python3 "${SKILL}/scripts/bump-must-gather-helm.py" --to 4.3.0 --check --parent-dir ~/RHDH + +# Dry-run, then apply (apply only after /mutation-gate) +python3 "${SKILL}/scripts/bump-must-gather-helm.py" --to 4.3.0 --dry-run --parent-dir ~/RHDH +python3 "${SKILL}/scripts/bump-must-gather-helm.py" --to 4.3.0 --parent-dir ~/RHDH +``` + +Full flag reference: `python3 "${SKILL}/scripts/bump-must-gather-helm.py" --help`. + +**Default:** updates upstream, syncs helm-related paths into distgit, flips Stage 2a/2b, regenerates distgit `Containerfile`, patches Tekton prefetch, writes `sync/upstream_SHA_rhdh-must-gather`. Does **not** commit, push, or open PR/MR. + +## Workflow + +1. Confirm target `--to` version against [Helm releases](https://github.com/helm/helm/releases). +2. Resolve `--upstream` / `--downstream` (or `--parent-dir`). +3. Run `--check` and `--dry-run`. That pair is the preview. +4. Apply only after `/mutation-gate` approval, with upstream and distgit/Tekton as separate operations. Approval to dry-run is not approval to write. +5. Review `git diff` in both repos, then the gates in [references/verification.md](references/verification.md). +6. Commit / PR or MR only when the user asks, again through `/mutation-gate`. + +## Completion + +`--check` and `--dry-run` are the preview. They are not a write and do not +approve an apply. + +An apply is done when `/mutation-gate` approved the writes (upstream and +distgit/Tekton as separate operations), the script ran without `--dry-run`, +and the trees contain: + +- upstream `HELM_VERSION` plus lockfile or `vendor/helm/` matching `--to` +- Stage 2a active on `mode=cgw`, Stage 2b on `mode=vendor` (upstream + distgit) +- distgit helm-related files synced; `vendor/helm` absent on `mode=cgw` +- Tekton `prefetch-input` matching install path (`generic` vs `gomod`) for + must-gather only (other `components.yaml` entries untouched) +- `sync/upstream_SHA_rhdh-must-gather` pointing at a **committed** upstream HEAD + (the script refuses to pin a dirty HEAD) + +Report `mode=`, paths touched, and any verification still pending +([references/verification.md](references/verification.md)). Working trees stay +uncommitted; if the user asks for a commit, PR, or MR, follow `/mutation-gate` +and attach Jira via `/rhdh-jira-link`. + +## Anti-patterns + +- Blaming a Helm bump when only GitHub E2E fails — triage chart/E2E harness first ([references/verification.md](references/verification.md)). + +## Reference index + +| Reference | Load when... | +|-----------|--------------| +| [references/install-paths.md](references/install-paths.md) | Choosing or switching CGW binary vs vendored source (Stage 2a/2b) | +| [references/tekton-prefetch.md](references/tekton-prefetch.md) | Editing or debugging Konflux `prefetch-input` JSON | +| [references/verification.md](references/verification.md) | Validating the bump (unit tests, Konflux, E2E triage) | +| [references/helm4-notes.md](references/helm4-notes.md) | Debugging E2E after a major Helm version bump | + +## Related + +- Upstream `make vendor` / `make helm-lockfile-update` — same logic, upstream only +- `/rhdh-base-images` — UBI/RPM bumps for overlapping repos +- `/rhdh-konflux-tasks` — task-bundle digest bumps in the same midstream `.tekton` trees +- CGW binary approach: [rhdh-must-gather#284](https://github.com/redhat-developer/rhdh-must-gather/pull/284) +- Vendored fallback reference: [rhdh-must-gather#282](https://github.com/redhat-developer/rhdh-must-gather/pull/282) diff --git a/skills/ci/rhdh-must-gather-helm-bump/agents/openai.yaml b/skills/ci/rhdh-must-gather-helm-bump/agents/openai.yaml new file mode 100644 index 0000000..a74ae34 --- /dev/null +++ b/skills/ci/rhdh-must-gather-helm-bump/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "RHDH Must-Gather Helm Bump" + short_description: "Bump Helm CLI in rhdh-must-gather upstream and midstream distgit/Tekton" diff --git a/skills/ci/rhdh-must-gather-helm-bump/references/helm4-notes.md b/skills/ci/rhdh-must-gather-helm-bump/references/helm4-notes.md new file mode 100644 index 0000000..2c35c02 --- /dev/null +++ b/skills/ci/rhdh-must-gather-helm-bump/references/helm4-notes.md @@ -0,0 +1,23 @@ +# Helm 4 notes for must-gather + +Helm 4 (currently installed from CGW in must-gather) differs from Helm 3 in ways that affect **E2E**, not the bump script itself. + +## OCI pull status on stdout + +When templating `oci://` charts, Helm 4 prints lines like: + +```text +Pulled: quay.io/rhdh/chart:2.0-59-CI +Digest: sha256:... +--- +apiVersion: v1 +``` + +Piping `helm template … | kubectl apply` fails validation (`apiVersion not set, kind not set`) unless those status lines are stripped. Upstream E2E uses `helm_template_yaml()` in `tests/e2e/lib/test-utils.sh`. + +Must-gather **collection** scripts do not pipe template output to kubectl today. If a future collector adds that pattern, filter pull lines or use `--output-dir` + `kubectl apply -f`. + +## When this matters for a bump + +- Major Helm upgrades (3 → 4): re-run upstream E2E; expect harness fixes separate from lockfile bumps. +- Patch/minor bumps within Helm 4: unlikely to need E2E changes unless CGW tarball layout or CLI flags change. diff --git a/skills/ci/rhdh-must-gather-helm-bump/references/install-paths.md b/skills/ci/rhdh-must-gather-helm-bump/references/install-paths.md new file mode 100644 index 0000000..5c90d36 --- /dev/null +++ b/skills/ci/rhdh-must-gather-helm-bump/references/install-paths.md @@ -0,0 +1,35 @@ +# CGW binary vs vendored Helm source + +The bump script probes `hack/check-helm-binary-available.sh ` against `mirror.openshift.com/pub/cgw/helm/`. + +## Decision table + +| CGW mirror has linux amd64/arm64? | Upstream action | Konflux prefetch | Containerfile | +|-----------------------------------|-----------------|------------------|---------------| +| **Yes** (preferred) | `hack/update-helm-lockfile.sh` | `generic` on distgit root (`artifacts.lock.yaml`) | Stage **2a** active (CGW binary) | +| **No** | `hack/update-vendor.sh helm` | `gomod` on `vendor/helm` | Stage **2b** active (go-toolset build) | + +The script flips Stage 2a/2b bidirectionally on: + +- upstream `Containerfile` +- upstream `.rhdh/docker/Containerfile` +- distgit `Containerfile` (after regenerating it from `.rhdh/docker/Containerfile`) + +## Distgit sync (what the script copies) + +- `Makefile`, `artifacts.lock.yaml` +- entire `hack/` +- `.rhdh/docker/Containerfile` +- entire `vendor/` — on `mode=cgw`, **omit/delete** `vendor/helm` (other vendor trees such as `websocat` stay) + +It does **not** copy upstream root `Containerfile` onto distgit. Distgit root `Containerfile` is regenerated from `.rhdh/docker/Containerfile`, preserving existing `RHDH_MUST_GATHER_VERSION` and the Brew/Konflux metadata footer (`ENV SUMMARY=` / `LABEL` block) that `sync-midstream.sh` appends. On each regenerate it increments `release="N"` → `N+1` and updates `konflux.additional-tags` so the `version-release` suffix matches with a numeric boundary (`2.0-1` → `2.0-2`, and `2.0-10` → `2.0-11` not `2.0-20`). + +## CGW tarball layout + +CGW publishes flat tarballs: member `helm-linux-amd64` (or `helm-linux-arm64`) at the archive root, **not** `linux-amd64/helm`. Upstream `hack/install-helm-binary.sh` extracts the correct member; lockfile entries use filenames `helm-linux-amd64.tar.gz` and `helm-linux-arm64.tar.gz`. + +If Konflux prefetch install fails after a bump, verify tarball layout before blaming Hermeto. + +## Stage 2a / 2b (script-owned) + +When `mode=vendor`, Stage 2a lines are commented and Stage 2b lines are uncommented. When `mode=cgw`, the reverse. Doc-only comments (`# Comment this out…`, `# https://…`, `# update via…`) stay commented. diff --git a/skills/ci/rhdh-must-gather-helm-bump/references/tekton-prefetch.md b/skills/ci/rhdh-must-gather-helm-bump/references/tekton-prefetch.md new file mode 100644 index 0000000..ec7b1a6 --- /dev/null +++ b/skills/ci/rhdh-must-gather-helm-bump/references/tekton-prefetch.md @@ -0,0 +1,39 @@ +# Tekton prefetch for rhdh-must-gather + +Konflux `prefetch-dependencies` input is a JSON array on PipelineRun param `prefetch-input` and in `.tekton-templates/components.yaml` under `must-gather.prefetch_input` only (other components in that file must not change). + +## CGW binary (Stage 2a — default) + +Helm linux-amd64/arm64 tarballs are listed in `artifacts.lock.yaml`. Hermeto **generic** fetcher prefetches them. + +CGW tarballs use a flat layout (`helm-linux-amd64` at archive root). See [install-paths.md](install-paths.md) for Stage 2a/2b and distgit sync rules. + +```json +[ + {"type": "rpm", "path": "distgit/containers/rhdh-must-gather"}, + {"type": "pip", "path": "distgit/containers/rhdh-must-gather", "allow_binary": "false"}, + {"type": "generic", "path": "distgit/containers/rhdh-must-gather"}, + {"type": "cargo", "path": "distgit/containers/rhdh-must-gather/vendor/websocat"} +] +``` + +Files the script patches in `rhidp/rhdh`: + +- `.tekton/rhdh-must-gather-2-pull.yaml` — `spec.params` → `prefetch-input` +- `.tekton/rhdh-must-gather-2-push.yaml` — same +- `.tekton-templates/components.yaml` — `must-gather.prefetch_input` only + +## Vendored source (Stage 2b) + +When CGW has no binaries, helm is built from `vendor/helm` with go-toolset. Use **gomod** prefetch: + +```json +[ + {"type": "rpm", "path": "distgit/containers/rhdh-must-gather"}, + {"type": "pip", "path": "distgit/containers/rhdh-must-gather", "allow_binary": "false"}, + {"type": "gomod", "path": "distgit/containers/rhdh-must-gather/vendor/helm"}, + {"type": "cargo", "path": "distgit/containers/rhdh-must-gather/vendor/websocat"} +] +``` + +Stage 2a/2b Containerfile swap is owned by the bump script — see [install-paths.md](install-paths.md). Regenerating PLRs from templates is **not** required for prefetch-only edits — patch the three files above directly. diff --git a/skills/ci/rhdh-must-gather-helm-bump/references/verification.md b/skills/ci/rhdh-must-gather-helm-bump/references/verification.md new file mode 100644 index 0000000..3e7a1c2 --- /dev/null +++ b/skills/ci/rhdh-must-gather-helm-bump/references/verification.md @@ -0,0 +1,31 @@ +# Verification after a must-gather Helm bump + +## Upstream (GitHub) + +| Gate | Command / location | Pass when | +|------|-------------------|-----------| +| Unit tests | `make test` in `rhdh-must-gather` | BATS suite green | +| Local helm | `make local-setup && ./bin/helm version` | Version matches `HELM_VERSION`; tarball checksum verified | +| Image build | GitHub Actions “Build PR Container Image” | Image builds; `helm version` works in container | +| E2E | GitHub Actions “E2E Tests” | Kind E2E completes | + +Konflux hermetic builds are validated downstream; upstream CI is the primary gate for script and E2E harness changes. + +## Downstream (GitLab midstream) + +| Gate | Command / location | Pass when | +|------|-------------------|-----------| +| Distgit sync | `git diff distgit/containers/rhdh-must-gather` | Helm files match upstream, including `hack/verify-helm-tarball.sh` | +| Upstream SHA | `sync/upstream_SHA_rhdh-must-gather` | Matches upstream commit synced | +| Konflux | `rhdh-must-gather-2-on-pull` PipelineRun | Prefetch + build succeed | + +## E2E triage (upstream only) + +If unit tests and image build pass but **E2E fails**, the Helm bump may be unrelated. Common false positives from [rhdh-must-gather#284](https://github.com/redhat-developer/rhdh-must-gather/pull/284): + +- Chart resolution from `oci://quay.io/rhdh/chart` (not GitHub Releases) +- RHDH 2.x chart values and init-container pod lifecycle in E2E waits +- Helm 4 printing `Pulled:` / `Digest:` to stdout on `helm template oci://…` (see [helm4-notes.md](helm4-notes.md)) +- `wait_for_*` loops under `set -o pipefail`: capture `kubectl` with `|| true` and avoid piping status JSON under pipefail + +Check E2E job logs for the first `ERROR` or `make: ***` line — ignore debug must-gather SUCCESS lines from the EXIT trap. diff --git a/skills/ci/rhdh-must-gather-helm-bump/scripts/bump-must-gather-helm.py b/skills/ci/rhdh-must-gather-helm-bump/scripts/bump-must-gather-helm.py new file mode 100755 index 0000000..874b28f --- /dev/null +++ b/skills/ci/rhdh-must-gather-helm-bump/scripts/bump-must-gather-helm.py @@ -0,0 +1,720 @@ +#!/usr/bin/env python3 +"""Bump Helm in rhdh-must-gather upstream and mirror into rhidp/rhdh distgit + Tekton. + +See SKILL.md beside this scripts/ directory. + +SPDX-License-Identifier: Apache-2.0 +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + +DISTGIT_REL = "distgit/containers/rhdh-must-gather" +UPSTREAM_SHA_REL = "sync/upstream_SHA_rhdh-must-gather" +RHDH_DOCKER_CF = ".rhdh/docker/Containerfile" + +PREFETCH_CGW = ( + '[{"type": "rpm", "path": "distgit/containers/rhdh-must-gather"},' + '{"type": "pip", "path": "distgit/containers/rhdh-must-gather", "allow_binary": "false"},' + '{"type": "generic", "path": "distgit/containers/rhdh-must-gather"},' + '{"type": "cargo", "path": "distgit/containers/rhdh-must-gather/vendor/websocat"}]' +) + +PREFETCH_VENDOR = ( + '[{"type": "rpm", "path": "distgit/containers/rhdh-must-gather"},' + '{"type": "pip", "path": "distgit/containers/rhdh-must-gather", "allow_binary": "false"},' + '{"type": "gomod", "path": "distgit/containers/rhdh-must-gather/vendor/helm"},' + '{"type": "cargo", "path": "distgit/containers/rhdh-must-gather/vendor/websocat"}]' +) + +VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$") +DOC_COMMENT_RE = re.compile(r"^# (Comment this out|Swap with|update via|https://)") +STAGE_HEADER_RE = re.compile(r"^# Stage (\S+):") + + +def die(msg: str) -> None: + print(f"[ERROR] {msg}", file=sys.stderr) + raise SystemExit(1) + + +def log(msg: str) -> None: + print(f"[INFO] {msg}", file=sys.stderr) + + +def warn(msg: str) -> None: + print(f"[WARN] {msg}", file=sys.stderr) + + +def normalize_version(raw: str) -> str: + v = raw[1:] if raw.startswith("v") else raw + if not VERSION_RE.match(v): + die(f"Invalid Helm version '{v}'. Expected semver like 4.3.0") + return v + + +def require_on_path(name: str) -> str: + found = shutil.which(name) + if not found: + die(f"Required command not found on PATH: {name}") + return found + + +def discover_repo(parent: Path, *names: str, required: str | None = None) -> Path | None: + for name in names: + candidate = parent / name + if not candidate.is_dir(): + continue + if required is not None and not (candidate / required).exists(): + continue + return candidate.resolve() + return None + + +def validate_upstream(path: Path) -> None: + if not (path / "Makefile").is_file(): + die(f"Not a must-gather repo (missing Makefile): {path}") + if not (path / "hack" / "update-helm-lockfile.sh").is_file(): + die(f"Missing hack/update-helm-lockfile.sh in {path}") + if not (path / "hack" / "check-helm-binary-available.sh").is_file(): + die(f"Missing hack/check-helm-binary-available.sh in {path}") + if not (path / "collection-scripts").is_dir(): + die(f"Missing collection-scripts/ in {path}") + + +def validate_downstream(path: Path) -> None: + if not (path / DISTGIT_REL).is_dir(): + die(f"Missing {DISTGIT_REL} in {path}") + if not (path / ".tekton" / "rhdh-must-gather-2-pull.yaml").is_file(): + die(f"Missing .tekton/rhdh-must-gather-2-pull.yaml in {path}") + if not (path / ".tekton" / "rhdh-must-gather-2-push.yaml").is_file(): + die(f"Missing .tekton/rhdh-must-gather-2-push.yaml in {path}") + if not (path / ".tekton-templates" / "components.yaml").is_file(): + die(f"Missing .tekton-templates/components.yaml in {path}") + + +def git_status_porcelain(path: Path, label: str) -> str: + result = subprocess.run( + ["git", "-C", str(path), "status", "--porcelain"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + die( + f"{label} is not a git repository or git status failed ({path}): " + f"{result.stderr.strip() or 'unknown error'}" + ) + return result.stdout.strip() + + +def assert_clean_tree(path: Path, label: str, *, allow_dirty: bool) -> None: + if allow_dirty: + return + if git_status_porcelain(path, label): + die( + f"{label} has uncommitted or untracked changes ({path}). " + "Commit/stash or pass --allow-dirty" + ) + + +def cgw_available(upstream: Path, version: str) -> bool: + script = upstream / "hack" / "check-helm-binary-available.sh" + if not script.is_file(): + die(f"Missing hack/check-helm-binary-available.sh in {upstream}") + bash = require_on_path("bash") + result = subprocess.run( + [bash, str(script), version], + cwd=str(upstream), + check=False, + ) + return result.returncode == 0 + + +def read_makefile_helm_version(makefile: Path) -> str: + for line in makefile.read_text(encoding="utf-8").splitlines(): + if line.startswith("HELM_VERSION := "): + return line[len("HELM_VERSION := ") :] + die(f"No HELM_VERSION := line in {makefile}") + raise AssertionError("unreachable") + + +def run_cmd(dry_run: bool, *argv: str, cwd: Path | None = None) -> None: + if dry_run: + print(f"[DRY-RUN] {' '.join(argv)}", file=sys.stderr) + return + subprocess.run(list(argv), cwd=str(cwd) if cwd else None, check=True) + + +def bump_upstream_cgw(upstream: Path, version: str, *, dry_run: bool) -> None: + log(f"Upstream: refreshing CGW lockfile for helm v{version}...") + run_cmd( + dry_run, + "bash", + "-c", + f"./hack/update-helm-lockfile.sh 'v{version}'", + cwd=upstream, + ) + + +def bump_upstream_vendor(upstream: Path, version: str, *, dry_run: bool) -> None: + log(f"Upstream: vendoring helm v{version} source...") + run_cmd( + dry_run, + "bash", + "-c", + f"./hack/update-vendor.sh helm 'v{version}'", + cwd=upstream, + ) + + +def flip_helm_stages(file: Path, mode: str, *, dry_run: bool) -> None: + """Bidirectional Stage 2a/2b comment flip. Doc-only comments stay commented.""" + if not file.is_file(): + die(f"Missing Containerfile for stage flip: {file}") + + if dry_run: + print(f"[DRY-RUN] flip Stage 2a/2b to mode={mode} in {file}", file=sys.stderr) + return + + stage = "" + out_lines: list[str] = [] + for line in file.read_text(encoding="utf-8").splitlines(keepends=True): + raw = line.rstrip("\n") + header = STAGE_HEADER_RE.match(raw) + if header: + stage_id = header.group(1) + if stage_id in ("2a", "2b"): + stage = stage_id + else: + stage = "" + out_lines.append(line) + continue + + if stage == "": + out_lines.append(line) + continue + + if DOC_COMMENT_RE.match(raw) or raw.strip() == "": + out_lines.append(line) + continue + + want_active = (stage == "2a" and mode == "cgw") or (stage == "2b" and mode == "vendor") + if want_active: + if raw.startswith("# "): + out_lines.append(raw[2:] + ("\n" if line.endswith("\n") else "")) + else: + out_lines.append(line) + else: + if raw.startswith("# "): + out_lines.append(line) + else: + out_lines.append("# " + raw + ("\n" if line.endswith("\n") else "")) + + file.write_text("".join(out_lines), encoding="utf-8") + log(f"Flipped Stage 2a/2b to mode={mode} in {file}") + + +def sync_path(src: Path, dst: Path, *, dry_run: bool) -> None: + if dry_run: + print(f"[DRY-RUN] cp -pPR '{src}' -> '{dst}'", file=sys.stderr) + return + rsync = require_on_path("rsync") + dst.parent.mkdir(parents=True, exist_ok=True) + if src.is_dir(): + dst.mkdir(parents=True, exist_ok=True) + subprocess.run( + [rsync, "-a", "--delete", f"{src}/", f"{dst}/"], + check=True, + ) + else: + shutil.copy2(src, dst) + + +def sync_to_distgit(upstream: Path, downstream: Path, mode: str, *, dry_run: bool) -> None: + dest = downstream / DISTGIT_REL + log(f"Syncing helm-related files to {dest}...") + + for rel in ("Makefile", "artifacts.lock.yaml"): + src = upstream / rel + if not src.exists(): + warn(f"Skipping missing upstream path: {rel}") + continue + sync_path(src, dest / rel, dry_run=dry_run) + + if (upstream / "hack").is_dir(): + sync_path(upstream / "hack", dest / "hack", dry_run=dry_run) + else: + warn("Skipping missing upstream hack/") + + upstream_cf = upstream / RHDH_DOCKER_CF + if upstream_cf.is_file(): + sync_path(upstream_cf, dest / RHDH_DOCKER_CF, dry_run=dry_run) + else: + warn(f"Skipping missing upstream {RHDH_DOCKER_CF}") + + vendor_src = upstream / "vendor" + vendor_dst = dest / "vendor" + if vendor_src.is_dir(): + if dry_run: + if mode == "cgw": + print("[DRY-RUN] rsync vendor/ -> distgit (exclude helm/)", file=sys.stderr) + else: + print("[DRY-RUN] rsync vendor/ -> distgit (include helm/)", file=sys.stderr) + else: + vendor_dst.mkdir(parents=True, exist_ok=True) + rsync = require_on_path("rsync") + if mode == "cgw": + subprocess.run( + [ + rsync, + "-a", + "--delete", + "--exclude", + "helm/", + f"{vendor_src}/", + f"{vendor_dst}/", + ], + check=True, + ) + helm_dir = vendor_dst / "helm" + if helm_dir.is_dir(): + log("Removing stale distgit vendor/helm/ (CGW path active)") + shutil.rmtree(helm_dir) + else: + subprocess.run( + [rsync, "-a", "--delete", f"{vendor_src}/", f"{vendor_dst}/"], + check=True, + ) + else: + warn("Skipping missing upstream vendor/") + helm_dir = vendor_dst / "helm" + if mode == "cgw" and helm_dir.is_dir(): + if dry_run: + print(f"[DRY-RUN] rm -rf '{helm_dir}'", file=sys.stderr) + else: + log("Removing stale distgit vendor/helm/ (CGW path active)") + shutil.rmtree(helm_dir) + + +def bump_footer_release(footer: str) -> str: + """Bump LABEL release=\"N\" → N+1 and rewrite konflux.additional-tags suffix.""" + current_m = re.search(r'(?:^|[ \t])release="([0-9]+)"', footer, re.M) + if not current_m: + current_m = re.search(r'^[ \t]*release="([0-9]+)"', footer, re.M) + if not current_m: + warn("Could not parse numeric release= in Containerfile footer; leaving labels unchanged") + return footer + + current = current_m.group(1) + next_n = str(int(current) + 1) + + version_m = re.search(r'(?:^|[ \t])version="([^"]*)"', footer, re.M) + if not version_m: + version_m = re.search(r'^[ \t]*version="([^"]*)"', footer, re.M) + version = version_m.group(1) if version_m else "" + + footer = footer.replace(f'release="{current}"', f'release="{next_n}"') + if version: + footer = re.sub( + rf"{re.escape(version)}-{re.escape(current)}(?![0-9])", + f"{version}-{next_n}", + footer, + ) + else: + footer = re.sub( + rf'(konflux\.additional-tags="[^"]*-){re.escape(current)}(?![0-9])', + rf"\g<1>{next_n}", + footer, + ) + log(f"Bumped Containerfile release {current} -> {next_n}") + return footer + + +def regenerate_distgit_containerfile(dest: Path, *, dry_run: bool) -> None: + src = dest / RHDH_DOCKER_CF + out = dest / "Containerfile" + if not src.is_file() and not dry_run: + die(f"Missing {src}; cannot regenerate distgit Containerfile") + + existing_version = "" + footer = "" + if out.is_file(): + text = out.read_text(encoding="utf-8") + for line in text.splitlines(): + m = re.match(r'^ARG RHDH_MUST_GATHER_VERSION="(.*)"', line) + if m: + existing_version = m.group(1) + break + lines = text.splitlines(keepends=True) + collecting = False + footer_parts: list[str] = [] + for line in lines: + if line.startswith("ENV SUMMARY="): + collecting = True + if collecting: + footer_parts.append(line) + footer = "".join(footer_parts) + if footer: + footer = bump_footer_release(footer) + + if dry_run: + has_footer = "yes" if footer else "no" + print( + f"[DRY-RUN] regenerate {out} from {src} " + f"(preserve VERSION='{existing_version}' footer={has_footer}; bump release)", + file=sys.stderr, + ) + return + + body = src.read_text(encoding="utf-8") + if existing_version: + body = re.sub( + r"RHDH_MUST_GATHER_VERSION=.*", + f'RHDH_MUST_GATHER_VERSION="{existing_version}"', + body, + count=1, + ) + if footer: + if not body.endswith("\n"): + body += "\n" + body += "\n" + footer + if not footer.endswith("\n"): + body += "\n" + out.write_text(body, encoding="utf-8") + log(f"Regenerated {DISTGIT_REL}/Containerfile from {RHDH_DOCKER_CF}") + + +def update_upstream_sha( + upstream: Path, + downstream: Path, + *, + dry_run: bool, + started_dirty: bool, +) -> None: + if started_dirty: + die( + "Refusing to write upstream_SHA from a dirty HEAD. " + "Commit the upstream bump first, then re-run with --skip-upstream" + ) + sha_file = downstream / UPSTREAM_SHA_REL + sha = subprocess.check_output( + ["git", "-C", str(upstream), "rev-parse", "--short", "HEAD"], + text=True, + ).strip() + branch = subprocess.check_output( + ["git", "-C", str(upstream), "rev-parse", "--abbrev-ref", "HEAD"], + text=True, + ).strip() + remote = subprocess.run( + ["git", "-C", str(upstream), "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=False, + ) + remote_url = remote.stdout.strip() or "https://github.com/redhat-developer/rhdh-must-gather" + line = f"{sha} = {branch} @ {remote_url}" + + if dry_run: + print(f"[DRY-RUN] write '{sha_file}': {line}", file=sys.stderr) + return + + sha_file.parent.mkdir(parents=True, exist_ok=True) + sha_file.write_text(line + "\n", encoding="utf-8") + log(f"Updated {UPSTREAM_SHA_REL} -> {sha}") + + +def replace_prefetch_plr(file: Path, prefetch: str, *, dry_run: bool) -> None: + if dry_run: + print(f"[DRY-RUN] update prefetch-input in {file}", file=sys.stderr) + return + + lines = file.read_text(encoding="utf-8").splitlines(keepends=True) + out: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + out.append(line) + if re.match(r"^[ \t]*- name: prefetch-input$", line.rstrip("\n")): + if i + 1 < len(lines) and re.match(r"^[ \t]*value:", lines[i + 1].rstrip("\n")): + indent_m = re.match(r"^([ \t]*)", lines[i + 1]) + indent = indent_m.group(1) if indent_m else "" + out.append(f"{indent}value: '{prefetch}'\n") + i += 2 + continue + i += 1 + file.write_text("".join(out), encoding="utf-8") + + +def replace_prefetch_components(file: Path, prefetch: str, *, dry_run: bool) -> None: + if dry_run: + print(f"[DRY-RUN] update must-gather.prefetch_input in {file}", file=sys.stderr) + return + + lines = file.read_text(encoding="utf-8").splitlines(keepends=True) + out: list[str] = [] + in_mg = False + for line in lines: + stripped = line.rstrip("\n") + if re.match(r"^[a-zA-Z0-9_-]+:", stripped): + in_mg = bool(re.match(r"^must-gather:", stripped)) + out.append(line) + continue + if in_mg and re.match(r"^[ \t]*prefetch_input:", stripped): + indent_m = re.match(r"^([ \t]*)", stripped) + indent = indent_m.group(1) if indent_m else "" + out.append(f"{indent}prefetch_input: '{prefetch}'\n") + continue + out.append(line) + file.write_text("".join(out), encoding="utf-8") + + +def update_tekton_prefetch(downstream: Path, mode: str, *, dry_run: bool) -> None: + prefetch = PREFETCH_VENDOR if mode == "vendor" else PREFETCH_CGW + log(f"Downstream: setting Tekton prefetch to {mode} mode (must-gather only)...") + replace_prefetch_plr( + downstream / ".tekton" / "rhdh-must-gather-2-pull.yaml", + prefetch, + dry_run=dry_run, + ) + replace_prefetch_plr( + downstream / ".tekton" / "rhdh-must-gather-2-push.yaml", + prefetch, + dry_run=dry_run, + ) + replace_prefetch_components( + downstream / ".tekton-templates" / "components.yaml", + prefetch, + dry_run=dry_run, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="bump-must-gather-helm.py", + description=( + "Bump Helm in rhdh-must-gather and mirror into rhidp/rhdh distgit + .tekton prefetch." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + bump-must-gather-helm.py --to 4.3.0 --parent-dir ~/RHDH + bump-must-gather-helm.py --to v4.3.0 --upstream ~/RHDH/1-must-gather --downstream ~/RHDH/4-rhdh + bump-must-gather-helm.py --to 4.3.0 --check --parent-dir ~/RHDH +""", + ) + parser.add_argument( + "--to", + metavar="VERSION", + help="Target Helm version (4.3.0 or v4.3.0)", + ) + parser.add_argument("--upstream", metavar="PATH", help="rhdh-must-gather checkout") + parser.add_argument("--downstream", metavar="PATH", help="rhidp/rhdh midstream checkout") + parser.add_argument( + "--parent-dir", + metavar="PATH", + help="Auto-discover 1-must-gather + 4-rhdh / rhdh-downstream (and aliases)", + ) + parser.add_argument( + "--check", + action="store_true", + help="Probe CGW only; print planned path (helm_version=, mode=, ...)", + ) + parser.add_argument( + "--skip-upstream", + action="store_true", + help="Sync downstream + Tekton + upstream SHA only", + ) + parser.add_argument( + "--skip-downstream", + action="store_true", + help="Bump upstream only", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print actions without writing", + ) + parser.add_argument( + "--allow-dirty", + action="store_true", + help="Proceed with uncommitted changes", + ) + return parser + + +def parse_argv(argv: list[str]) -> argparse.Namespace: + """Parse argv with bash-compatible required-value errors for unit tests.""" + if argv and argv[0] in ("-h", "--help"): + build_parser().print_help() + raise SystemExit(0) + + # Detect bare flags that need a value before argparse (clearer errors). + needs_value = { + "--to", + "--upstream", + "--downstream", + "--parent-dir", + } + i = 0 + while i < len(argv): + arg = argv[i] + if arg in needs_value: + if i + 1 >= len(argv) or argv[i + 1].startswith("-"): + die(f"{arg} requires a value") + i += 2 + continue + i += 1 + + parser = build_parser() + # Suppress argparse's default required handling; we validate below. + args = parser.parse_args(argv) + if not args.to: + die("--to VERSION is required") + args.to = normalize_version(args.to) + return args + + +def resolve_repos(args: argparse.Namespace) -> tuple[Path | None, Path | None]: + upstream: Path | None = Path(args.upstream).resolve() if args.upstream else None + downstream: Path | None = Path(args.downstream).resolve() if args.downstream else None + + if args.parent_dir: + parent = Path(args.parent_dir).expanduser().resolve() + if upstream is None: + found = discover_repo( + parent, + "1-must-gather", + "1-rhdh-must-gather", + "rhdh-must-gather", + "must-gather", + ) + if found is None: + die(f"Could not find must-gather under {parent}") + upstream = found + if downstream is None: + found = discover_repo( + parent, + "4-rhdh", + "rhdh-downstream", + "rhidp-rhdh", + required=DISTGIT_REL, + ) + if found is None: + die(f"Could not find rhidp/rhdh midstream under {parent}") + downstream = found + + return upstream, downstream + + +def infer_mode( + *, + upstream: Path | None, + to_version: str, +) -> str: + if upstream is None: + die("Cannot infer CGW vs vendor mode without an upstream checkout") + if cgw_available(upstream, to_version): + log(f"CGW mirror has helm v{to_version} linux-amd64/arm64 binaries") + return "cgw" + warn(f"CGW mirror has no helm v{to_version} linux-amd64/arm64 — vendored source path") + return "vendor" + + +def main(argv: list[str] | None = None) -> int: + args = parse_argv(list(argv if argv is not None else sys.argv[1:])) + upstream, downstream = resolve_repos(args) + + if not args.skip_upstream: + if upstream is None: + die("Set --upstream or --parent-dir") + validate_upstream(upstream) + + if not args.skip_downstream: + if downstream is None: + die("Set --downstream or --parent-dir") + validate_downstream(downstream) + + if args.skip_upstream and not args.skip_downstream: + if upstream is None: + die("--skip-upstream still needs --upstream (or --parent-dir) as the sync source") + validate_upstream(upstream) + + if upstream is not None and downstream is not None: + log(f"Upstream: {upstream}") + log(f"Downstream: {downstream}") + + require_on_path("bash") + if not args.check and not args.skip_downstream: + require_on_path("rsync") + + mode = infer_mode( + upstream=upstream, + to_version=args.to, + ) + + if args.check: + print(f"helm_version={args.to}") + print(f"mode={mode}") + print(f"upstream={upstream or ''}") + print(f"downstream={downstream or ''}") + return 0 + + dry_run = args.dry_run + upstream_started_dirty = False + if upstream is not None: + upstream_started_dirty = bool(git_status_porcelain(upstream, "Upstream")) + + if not args.skip_upstream: + assert upstream is not None + assert_clean_tree(upstream, "Upstream", allow_dirty=args.allow_dirty) + current = read_makefile_helm_version(upstream / "Makefile") + if current == args.to: + log(f"Upstream HELM_VERSION already {args.to}") + else: + log(f"Upstream HELM_VERSION: {current} -> {args.to}") + if mode == "cgw": + bump_upstream_cgw(upstream, args.to, dry_run=dry_run) + else: + bump_upstream_vendor(upstream, args.to, dry_run=dry_run) + flip_helm_stages(upstream / "Containerfile", mode, dry_run=dry_run) + rhdh_cf = upstream / RHDH_DOCKER_CF + if rhdh_cf.is_file(): + flip_helm_stages(rhdh_cf, mode, dry_run=dry_run) + else: + warn(f"No {RHDH_DOCKER_CF} in upstream; skipped stage flip there") + + if not args.skip_downstream: + assert downstream is not None + assert upstream is not None + assert_clean_tree(downstream, "Downstream", allow_dirty=args.allow_dirty) + sync_to_distgit(upstream, downstream, mode, dry_run=dry_run) + regenerate_distgit_containerfile(downstream / DISTGIT_REL, dry_run=dry_run) + flip_helm_stages( + downstream / DISTGIT_REL / "Containerfile", + mode, + dry_run=dry_run, + ) + update_tekton_prefetch(downstream, mode, dry_run=dry_run) + update_upstream_sha( + upstream, + downstream, + dry_run=dry_run, + started_dirty=upstream_started_dirty, + ) + + log("Done. Review git diff in each repo before committing.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except BrokenPipeError: + sys.stderr.close() + raise SystemExit(1) diff --git a/skills/meta/ask-rhdh/SKILL.md b/skills/meta/ask-rhdh/SKILL.md index 099c0c7..4c90d18 100644 --- a/skills/meta/ask-rhdh/SKILL.md +++ b/skills/meta/ask-rhdh/SKILL.md @@ -44,6 +44,7 @@ competing inventory, which is what this table used to be. | Lists, generates, and removes RHDH test entries and Hive ClusterPools in the openshift/release Prow ci-operator configuration. Covers OCP `e2e-ocp-vX-Y-helm-nightly` entries, `cluster_claim.version`, the `rhdh-cluster-pool` Hive pools, and AKS/EKS/GKE entries with `MAPT_KUBERNETES_VERSION`. Use for "add an OCP test entry", "which OCP versions are we testing", "list the cluster pools", "what AKS/EKS/GKE version is configured", or an OCP coverage-gap analysis. | `/rhdh-prow-jobs` | | Commissions or decommissions the openshift/release Prow configuration for a single RHDH release branch: the ci-operator config `redhat-developer-rhdh-release-{version}.yaml`, the jobs `make update` generates from it, the `release-{version}` branch-protection block in `_prowconfig.yaml`, and the `#rhdh-e2e-alerts-X-Y` Slack channel and `rhdh-send-alert` Vault webhook. Use for "set up CI for release 1.11", "commission the 1.12 branch jobs", or "decommission 1.8". Prow configuration for a release branch only — the release itself is rhdh-release-status, rhdh-release-schedule, and rhdh-release-announce. | `/rhdh-prow-release-branch` | | Runs an RHDH nightly ProwJob on demand through the OpenShift CI Gangway REST API, for `periodic-ci-redhat-developer-rhdh-*-nightly` and `periodic-ci-redhat-developer-rhdh-plugin-export-overlays-*-nightly` jobs, with optional image registry/repo/tag, catalog-index, Helm chart, Playwright, fork, and Slack-alert overrides. Use for "trigger the nightly", "run the e2e job now", "kick off the AKS operator job on 1.9", RC or GA image verification runs, or listing the available nightly jobs and quay.io tags. | `/rhdh-prow-trigger` | +| Bumps the Helm CLI baked into redhat-developer/rhdh-must-gather, mirrors helm-related files into gitlab.cee.redhat.com/rhidp/rhdh distgit, patches Konflux Tekton prefetch (CGW generic vs vendored gomod), and updates sync/upstream_SHA. Use when bumping must-gather's HELM_VERSION (upstream or downstream), switching between CGW binary and vendored Helm build paths, or diagnosing a Konflux rhdh-must-gather prefetch failure after a Helm release. | `/rhdh-must-gather-helm-bump` | | Bumps Yarn Berry across the RHDH repos — rhdh-plugins, rhdh midstream, rhdh-plugin-export-overlays, rhdh-cli, and GitLab CEE rhidp/rhdh and rhdh-plugin-catalog — with `yarn set version` plus install, and rewrites the pins Yarn cannot see: `packageManager`, `yarnPath`, `ENV YARN=`, and Containerfile lines. Use for "bump yarn to 4.17.1", "upgrade Yarn Berry across the repos", "which Yarn version is each repo pinned to", or scanning yarn pins. | `/rhdh-yarn-bump` | | Builds the "RHDH CVE Management - -plugins.csv" export by walking `rhdh-plugin-export-overlays` git history on a release branch since a revision, scoped to generally-available plugin workspaces, then enriching each CVE from Jira RHIDP, RHDHBUGS, and RHDHPLAN. Use for "generate the plugin overlay CVE CSV for 1.10.3", "what CVEs were fixed in plugins since 1.10.2", or "rebuild the plugin CVE list with the ProdSec CVSS scores". | `/rhdh-overlay-cve-export` | | Reports vendor support phases and end-of-life dates for RHDH and the platforms it runs on: OpenShift, ARO, OSD, ROSA, AKS, EKS, GKE, PostgreSQL, Red Hat build of Keycloak, and Quay. Use for "is OCP 4.16 still supported", "when does PostgreSQL 15 go EOL", "which Kubernetes version should AKS be on", "which OCP versions does RHDH 1.10 support", or an RHBK or Quay lifecycle lookup. Product lifecycle only — RHDH milestone dates such as Code Freeze belong to rhdh-release-schedule. | `/rhdh-platform-lifecycle` | diff --git a/skills/meta/setup-rhdh-skills/assets/catalog.json b/skills/meta/setup-rhdh-skills/assets/catalog.json index 61a1506..46aa239 100644 --- a/skills/meta/setup-rhdh-skills/assets/catalog.json +++ b/skills/meta/setup-rhdh-skills/assets/catalog.json @@ -243,6 +243,13 @@ "requiresSkills": [], "requiresExternalSkills": [] }, + { + "name": "rhdh-must-gather-helm-bump", + "category": "ci", + "invocation": "model", + "requiresSkills": [], + "requiresExternalSkills": [] + }, { "name": "rhdh-yarn-bump", "category": "ci", diff --git a/tests/unit/test_must_gather_helm_bump.py b/tests/unit/test_must_gather_helm_bump.py new file mode 100644 index 0000000..def4979 --- /dev/null +++ b/tests/unit/test_must_gather_helm_bump.py @@ -0,0 +1,540 @@ +"""Tests for rhdh-must-gather-helm-bump skill script.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SKILL_DIR = PROJECT_ROOT / "skills" / "ci" / "rhdh-must-gather-helm-bump" +MAIN_SCRIPT = SKILL_DIR / "scripts" / "bump-must-gather-helm.py" + +FIXTURE_HELM = "4.2.3" +TARGET_HELM = "4.3.0" + +HELM_STAGES_SNIPPET = textwrap.dedent( + """\ + # Stage 2a: Install helm from CGW + # Comment this out and uncomment Stage 2b below when no binary available. + # https://registry.access.redhat.com/ubi9-minimal + FROM registry.example/ubi9-minimal AS helm-builder + RUN echo cgw + + # Stage 2b: Build helm from vendored source + # Swap with Stage 2a: comment out Stage 2a, uncomment below. + # update via: make vendor-update VENDOR_NAME=helm VENDOR_VERSION=vX + # https://registry.access.redhat.com/ubi9/go-toolset + # FROM registry.example/go-toolset AS helm-builder + # COPY vendor/helm /opt/app-root/src/helm + # RUN echo vendor + + # Stage 3: Final image + FROM registry.example/ubi9-minimal + ARG RHDH_MUST_GATHER_VERSION="0.0.0-unknown" + ENV MIDSTREAM_REPO="https://example.invalid/mr/1" \\ + COMPNAME="must-gather" + """ +) + +HELM_STAGES_VENDOR = textwrap.dedent( + """\ + # Stage 2a: Install helm from CGW + # Comment this out and uncomment Stage 2b below when no binary available. + # https://registry.access.redhat.com/ubi9-minimal + # FROM registry.example/ubi9-minimal AS helm-builder + # RUN echo cgw + + # Stage 2b: Build helm from vendored source + # Swap with Stage 2a: comment out Stage 2a, uncomment below. + # update via: make vendor-update VENDOR_NAME=helm VENDOR_VERSION=vX + # https://registry.access.redhat.com/ubi9/go-toolset + FROM registry.example/go-toolset AS helm-builder + COPY vendor/helm /opt/app-root/src/helm + RUN echo vendor + + # Stage 3: Final image + FROM registry.example/ubi9-minimal + ARG RHDH_MUST_GATHER_VERSION="0.0.0-unknown" + ENV MIDSTREAM_REPO="https://example.invalid/mr/1" \\ + COMPNAME="must-gather" + """ +) + +MULTI_COMPONENT_YAML = textwrap.dedent( + """\ + hub: + path_context: "distgit/containers/rhdh-hub" + prefetch_input: '[{"type": "yarn", "path": "distgit/containers/rhdh-hub"}]' + + operator: + path_context: "distgit/containers/rhdh-operator" + prefetch_input: '{"type": "gomod"}' + + must-gather: + path_context: "distgit/containers/rhdh-must-gather" + prefetch_input: '[]' + + bootc: + path_context: "distgit/containers/rhdh-bootc" + prefetch_input: '{"type": "rpm"}' + """ +) + +HUB_PREFETCH = '[{"type": "yarn", "path": "distgit/containers/rhdh-hub"}]' +OPERATOR_PREFETCH = '{"type": "gomod"}' +BOOTC_PREFETCH = '{"type": "rpm"}' + +LOCKFILE_STUB = textwrap.dedent( + """\ + #!/usr/bin/env bash + set -euo pipefail + ver="${1#v}" + printf 'HELM_VERSION := %s\\n' "$ver" > Makefile + printf 'artifacts:\\n - download_url: https://mirror.example/cgw/helm/%s/helm.tgz\\n' "$ver" > artifacts.lock.yaml + """ +) + +VENDOR_STUB = textwrap.dedent( + """\ + #!/usr/bin/env bash + set -euo pipefail + ver="${2#v}" + printf 'HELM_VERSION := %s\\n' "$ver" > Makefile + """ +) + + +def _run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(MAIN_SCRIPT), *args], + capture_output=True, + text=True, + check=False, + ) + + +def _load_script(): + spec = importlib.util.spec_from_file_location("bump_must_gather_helm", MAIN_SCRIPT) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _git_init(repo: Path) -> None: + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "init"], + cwd=repo, + check=True, + capture_output=True, + ) + + +def _write_executable(path: Path, body: str = "#!/usr/bin/env bash\n") -> None: + path.write_text(body, encoding="utf-8") + path.chmod(0o755) + + +def _make_upstream( + parent: Path, + *, + cgw_exit: int = 0, + stages: str = HELM_STAGES_SNIPPET, + name: str = "1-must-gather", +) -> Path: + upstream = parent / name + upstream.mkdir(parents=True) + (upstream / "Makefile").write_text(f"HELM_VERSION := {FIXTURE_HELM}\n", encoding="utf-8") + (upstream / "collection-scripts").mkdir() + (upstream / "artifacts.lock.yaml").write_text( + f"artifacts:\n - download_url: https://mirror.example/cgw/helm/{FIXTURE_HELM}/helm.tgz\n", + encoding="utf-8", + ) + (upstream / "Containerfile").write_text(stages, encoding="utf-8") + rhdh_docker = upstream / ".rhdh" / "docker" + rhdh_docker.mkdir(parents=True) + (rhdh_docker / "Containerfile").write_text(stages, encoding="utf-8") + hack = upstream / "hack" + hack.mkdir() + _write_executable( + hack / "check-helm-binary-available.sh", + f"#!/usr/bin/env bash\nexit {cgw_exit}\n", + ) + _write_executable(hack / "update-helm-lockfile.sh", LOCKFILE_STUB) + _write_executable(hack / "update-vendor.sh", VENDOR_STUB) + for name_script in ( + "install-helm-binary.sh", + "install-helm-local.sh", + "verify-helm-tarball.sh", + "deploy-k8s.sh", + ): + _write_executable(hack / name_script) + vendor = upstream / "vendor" + (vendor / "websocat").mkdir(parents=True) + (vendor / "websocat" / "Cargo.toml").write_text( + "[package]\nname='websocat'\n", encoding="utf-8" + ) + (vendor / "helm").mkdir(parents=True) + (vendor / "helm" / "go.mod").write_text("module helm\n", encoding="utf-8") + return upstream + + +def _make_downstream( + parent: Path, + *, + name: str = "4-rhdh", + release: str = "1", + version: str = "2.0", +) -> Path: + downstream = parent / name + distgit = downstream / "distgit" / "containers" / "rhdh-must-gather" + distgit.mkdir(parents=True) + stale = distgit / "vendor" / "helm" + stale.mkdir(parents=True) + (stale / "README.md").write_text("stale\n", encoding="utf-8") + (distgit / "vendor" / "websocat").mkdir(parents=True) + (distgit / "vendor" / "websocat" / "old.txt").write_text("old\n", encoding="utf-8") + tag = f"{version}-{release}" + (distgit / "Containerfile").write_text( + HELM_STAGES_SNIPPET.replace( + 'ARG RHDH_MUST_GATHER_VERSION="0.0.0-unknown"', + 'ARG RHDH_MUST_GATHER_VERSION="-1"', + ) + + '\nENV SUMMARY="Red Hat Developer Hub must-gather" \\\n' + + ' MIDSTREAM_REPO="https://example.invalid/mr/1" \\\n' + + ' COMPNAME="must-gather"\n' + + 'LABEL summary="$SUMMARY" \\\n' + + f' version="{version}" \\\n' + + f' release="{release}" \\\n' + + f' konflux.additional-tags="next, {version}, {tag}" \\\n' + + ' distribution-scope="public"\n', + encoding="utf-8", + ) + tekton = downstream / ".tekton" + tekton.mkdir() + for plr in ("rhdh-must-gather-2-pull.yaml", "rhdh-must-gather-2-push.yaml"): + (tekton / plr).write_text( + " - name: prefetch-input\n value: '[]'\n", + encoding="utf-8", + ) + templates = downstream / ".tekton-templates" + templates.mkdir() + (templates / "components.yaml").write_text(MULTI_COMPONENT_YAML, encoding="utf-8") + return downstream + + +class TestBumpMustGatherHelmScript: + """Smoke and regression tests for bump-must-gather-helm.py.""" + + def test_script_exists(self) -> None: + assert MAIN_SCRIPT.is_file() + + @pytest.mark.parametrize("flag", ["--help", "-h"]) + def test_help_exits_zero(self, flag: str) -> None: + result = _run(flag) + assert result.returncode == 0 + combined = result.stdout + result.stderr + assert "usage:" in combined.lower() + assert "--to" in combined + assert "skills/ci/rhdh-must-gather-helm-bump" not in combined + + def test_missing_to_version_exits_nonzero(self) -> None: + result = _run() + assert result.returncode != 0 + assert "--to VERSION is required" in result.stderr + + def test_missing_flag_value_exits_with_message(self) -> None: + result = _run("--to") + assert result.returncode != 0 + assert "--to requires a value" in result.stderr + + def test_invalid_version_exits_nonzero(self, tmp_path: Path) -> None: + upstream = tmp_path / "upstream" + upstream.mkdir() + (upstream / "Makefile").write_text(f"HELM_VERSION := {FIXTURE_HELM}\n", encoding="utf-8") + (upstream / "collection-scripts").mkdir() + (upstream / "hack").mkdir() + (upstream / "hack" / "update-helm-lockfile.sh").write_text( + "#!/bin/bash\n", encoding="utf-8" + ) + + result = _run( + "--to", + "not-a-version", + "--upstream", + str(upstream), + "--downstream", + str(tmp_path / "downstream"), + ) + assert result.returncode != 0 + assert "Invalid Helm version" in result.stderr + + def test_check_mode_prints_mode(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + upstream = _make_upstream(parent, cgw_exit=0) + downstream = _make_downstream(parent) + + result = _run("--to", TARGET_HELM, "--check", "--parent-dir", str(parent)) + assert result.returncode == 0, result.stderr + assert "mode=cgw" in result.stdout + assert f"helm_version={TARGET_HELM}" in result.stdout + assert upstream.exists() and downstream.exists() + + def test_skip_upstream_check_probes_upstream_not_stale_vendor(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + _make_upstream(parent, cgw_exit=0) + downstream = _make_downstream(parent) + distgit = downstream / "distgit" / "containers" / "rhdh-must-gather" + (distgit / "artifacts.lock.yaml").write_text( + f"artifacts:\n - cgw/helm/{FIXTURE_HELM}/\n", + encoding="utf-8", + ) + assert (distgit / "vendor" / "helm").is_dir() + + result = _run( + "--to", + TARGET_HELM, + "--check", + "--skip-upstream", + "--parent-dir", + str(parent), + ) + assert result.returncode == 0, result.stderr + assert "mode=cgw" in result.stdout + + def test_cgw_sync_scopes_prefetch_omits_helm_keeps_websocat(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + upstream = _make_upstream(parent, cgw_exit=0) + downstream = _make_downstream(parent) + distgit = downstream / "distgit" / "containers" / "rhdh-must-gather" + stale_vendor = distgit / "vendor" / "helm" + + for repo in (upstream, downstream): + _git_init(repo) + + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/example/rhdh-must-gather"], + cwd=upstream, + check=True, + capture_output=True, + ) + + result = _run("--to", TARGET_HELM, "--parent-dir", str(parent)) + assert result.returncode == 0, result.stderr + assert not stale_vendor.exists() + assert (distgit / "vendor" / "websocat" / "Cargo.toml").is_file() + assert (distgit / "hack" / "verify-helm-tarball.sh").is_file() + assert (distgit / "hack" / "deploy-k8s.sh").is_file() + assert (upstream / "Makefile").read_text( + encoding="utf-8" + ) == f"HELM_VERSION := {TARGET_HELM}\n" + assert (distgit / "Makefile").read_text( + encoding="utf-8" + ) == f"HELM_VERSION := {TARGET_HELM}\n" + assert f"cgw/helm/{TARGET_HELM}/" in (distgit / "artifacts.lock.yaml").read_text( + encoding="utf-8" + ) + + components = (downstream / ".tekton-templates" / "components.yaml").read_text( + encoding="utf-8" + ) + assert HUB_PREFETCH in components + assert OPERATOR_PREFETCH in components + assert BOOTC_PREFETCH in components + assert '"type": "generic"' in components + assert "vendor/helm" not in components.split("must-gather:")[1].split("bootc:")[0] + + pull = (downstream / ".tekton" / "rhdh-must-gather-2-pull.yaml").read_text(encoding="utf-8") + push = (downstream / ".tekton" / "rhdh-must-gather-2-push.yaml").read_text(encoding="utf-8") + assert '"type": "generic"' in pull + assert '"type": "generic"' in push + + cf = (distgit / "Containerfile").read_text(encoding="utf-8") + assert "\nFROM registry.example/ubi9-minimal AS helm-builder\n" in cf + assert "\n# FROM registry.example/go-toolset AS helm-builder\n" in cf + assert 'ARG RHDH_MUST_GATHER_VERSION="-1"' in cf + assert 'ENV SUMMARY="Red Hat Developer Hub must-gather"' in cf + assert 'release="2"' in cf + assert 'konflux.additional-tags="next, 2.0, 2.0-2"' in cf + assert 'release="1"' not in cf + + sha_file = downstream / "sync" / "upstream_SHA_rhdh-must-gather" + assert sha_file.is_file() + assert " @ https://github.com/example/rhdh-must-gather" in sha_file.read_text( + encoding="utf-8" + ) + + def test_vendor_mode_flips_stages_and_keeps_helm(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + upstream = _make_upstream(parent, cgw_exit=1) + downstream = _make_downstream(parent) + distgit = downstream / "distgit" / "containers" / "rhdh-must-gather" + + for repo in (upstream, downstream): + _git_init(repo) + + result = _run("--to", TARGET_HELM, "--parent-dir", str(parent)) + assert result.returncode == 0, result.stderr + assert (distgit / "vendor" / "helm" / "go.mod").is_file() + assert (distgit / "vendor" / "websocat" / "Cargo.toml").is_file() + assert (upstream / "Makefile").read_text( + encoding="utf-8" + ) == f"HELM_VERSION := {TARGET_HELM}\n" + + upstream_cf = (upstream / "Containerfile").read_text(encoding="utf-8") + assert "\n# FROM registry.example/ubi9-minimal AS helm-builder\n" in upstream_cf + assert "\nFROM registry.example/go-toolset AS helm-builder\n" in upstream_cf + + components = (downstream / ".tekton-templates" / "components.yaml").read_text( + encoding="utf-8" + ) + assert HUB_PREFETCH in components + mg_block = components.split("must-gather:")[1].split("bootc:")[0] + assert '"type": "gomod"' in mg_block + assert "vendor/helm" in mg_block + + push = (downstream / ".tekton" / "rhdh-must-gather-2-push.yaml").read_text(encoding="utf-8") + assert '"type": "gomod"' in push + + def test_vendor_then_cgw_flips_2b_back_to_2a(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + upstream = _make_upstream(parent, cgw_exit=0, stages=HELM_STAGES_VENDOR) + downstream = _make_downstream(parent) + distgit = downstream / "distgit" / "containers" / "rhdh-must-gather" + + for repo in (upstream, downstream): + _git_init(repo) + + result = _run("--to", TARGET_HELM, "--parent-dir", str(parent)) + assert result.returncode == 0, result.stderr + + upstream_cf = (upstream / "Containerfile").read_text(encoding="utf-8") + assert "\nFROM registry.example/ubi9-minimal AS helm-builder\n" in upstream_cf + assert "\n# FROM registry.example/go-toolset AS helm-builder\n" in upstream_cf + + distgit_cf = (distgit / "Containerfile").read_text(encoding="utf-8") + assert "\nFROM registry.example/ubi9-minimal AS helm-builder\n" in distgit_cf + assert "\n# FROM registry.example/go-toolset AS helm-builder\n" in distgit_cf + + def test_dry_run_writes_nothing(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + upstream = _make_upstream(parent, cgw_exit=0) + downstream = _make_downstream(parent) + distgit = downstream / "distgit" / "containers" / "rhdh-must-gather" + makefile_before = (upstream / "Makefile").read_text(encoding="utf-8") + pull_before = (downstream / ".tekton" / "rhdh-must-gather-2-pull.yaml").read_text( + encoding="utf-8" + ) + push_before = (downstream / ".tekton" / "rhdh-must-gather-2-push.yaml").read_text( + encoding="utf-8" + ) + + for repo in (upstream, downstream): + _git_init(repo) + + result = _run("--to", TARGET_HELM, "--dry-run", "--parent-dir", str(parent)) + assert result.returncode == 0, result.stderr + assert "[DRY-RUN]" in result.stderr + assert (upstream / "Makefile").read_text(encoding="utf-8") == makefile_before + assert (distgit / "vendor" / "helm").is_dir() + assert not (downstream / "sync" / "upstream_SHA_rhdh-must-gather").exists() + assert (downstream / ".tekton" / "rhdh-must-gather-2-pull.yaml").read_text( + encoding="utf-8" + ) == pull_before + assert (downstream / ".tekton" / "rhdh-must-gather-2-push.yaml").read_text( + encoding="utf-8" + ) == push_before + + def test_release_10_becomes_11_not_20(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + upstream = _make_upstream(parent, cgw_exit=0) + downstream = _make_downstream(parent, release="10") + distgit = downstream / "distgit" / "containers" / "rhdh-must-gather" + + for repo in (upstream, downstream): + _git_init(repo) + + result = _run("--to", TARGET_HELM, "--parent-dir", str(parent)) + assert result.returncode == 0, result.stderr + cf = (distgit / "Containerfile").read_text(encoding="utf-8") + assert 'release="11"' in cf + assert "2.0-11" in cf + assert "2.0-20" not in cf + assert 'release="10"' not in cf + + def test_bump_footer_release_boundary(self) -> None: + footer = ( + 'LABEL summary="$SUMMARY" \\\n' + ' version="2.0" \\\n' + ' release="10" \\\n' + ' konflux.additional-tags="next, 2.0, 2.0-10" \\\n' + ) + out = _load_script().bump_footer_release(footer) + assert 'release="11"' in out + assert "2.0-11" in out + assert "2.0-20" not in out + assert 'release="10"' not in out + + def test_git_status_failure_dies(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + upstream = _make_upstream(parent, cgw_exit=0) + _make_downstream(parent) + _git_init(upstream) + + result = _run("--to", TARGET_HELM, "--parent-dir", str(parent)) + assert result.returncode != 0 + assert "is not a git repository or git status failed" in result.stderr + + def test_dirty_head_refuses_upstream_sha(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + upstream = _make_upstream(parent, cgw_exit=0) + downstream = _make_downstream(parent) + for repo in (upstream, downstream): + _git_init(repo) + (upstream / "unrelated.txt").write_text("dirt\n", encoding="utf-8") + + result = _run("--to", TARGET_HELM, "--allow-dirty", "--parent-dir", str(parent)) + assert result.returncode != 0 + assert "dirty HEAD" in result.stderr + assert not (downstream / "sync" / "upstream_SHA_rhdh-must-gather").exists() + + def test_parent_dir_discovers_rhdh_downstream(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + _make_upstream(parent, cgw_exit=0) + (parent / "rhdh").mkdir() + downstream = _make_downstream(parent, name="rhdh-downstream") + for repo in (parent / "1-must-gather", downstream): + _git_init(repo) + + result = _run("--to", TARGET_HELM, "--check", "--parent-dir", str(parent)) + assert result.returncode == 0, result.stderr + assert str(downstream.resolve()) in result.stdout + + def test_missing_check_helm_script_dies(self, tmp_path: Path) -> None: + parent = tmp_path / "RHDH" + upstream = _make_upstream(parent, cgw_exit=0) + _make_downstream(parent) + (upstream / "hack" / "check-helm-binary-available.sh").unlink() + + result = _run("--to", TARGET_HELM, "--check", "--parent-dir", str(parent)) + assert result.returncode != 0 + assert "check-helm-binary-available.sh" in result.stderr