diff --git a/.github/actions/backport-fix-imports/README.md b/.github/actions/backport-fix-imports/README.md new file mode 100644 index 00000000..506d3c8f --- /dev/null +++ b/.github/actions/backport-fix-imports/README.md @@ -0,0 +1,102 @@ +# Backport fix imports + +Repairs Go module paths on a backport branch when the target release line is +on an older module major than the source. The case it was built for: +`loft-enterprise` moved `main` to `github.com/loft-sh/loft/v5` and +`github.com/loft-sh/api/v5`, while every `release-4.x` branch stays on v4. A +cherry-pick from `main` then carries v5 imports into a v4 branch, and conflicts +wherever both sides edited the same import block. + +## What it does + +Run inside a full-history checkout of the repository, on a backport branch that +already exists on the remote: + +1. Reads the root `go.mod` on the source and target branches and maps the + modules the repository owns: the `module` line, and `replace` directives + that point into the repository (`=> ./...`). Third-party modules are never + rewritten, so a dependency major bump on `main` is left alone. +2. Moves files the backport added under a source-only local directory (for + example `staging/src/github.com/loft-sh/api/v5/...`) into the target's + directory. A file whose target path already exists is not moved and is + left for review. +3. Resolves conflict hunks made only of import lines inside an import block. + The backported side wins, rewritten to the target paths. An import only the + target side had stays while the code uses it, ignoring comments and strings. + The package name is read from the source, never guessed from the path + (`github.com/json-iterator/go` is package `jsoniter`): the standard library + by its last element, the repository's own modules from their directory, and + everything else from `vendor/`. The hunk is left for review instead when + the name cannot be read, when both sides import the same path differently + (another name, another comment such as `//nolint`), or when a comment line + only the target side has would be lost. +4. Rewrites the mapped paths in the `.go` files the backport changed, only at + the start of a string literal and on a path boundary. +5. Runs `gofmt` on files whose import blocks it resolved, then commits as + `github-actions[bot]` and pushes one commit: + `fix(backport): use module paths`. + +Conflict hunks in real code, undecidable import hunks and blocked moves are +left as they are, with a warning per file, and the `reason` says how many files +are left for review. A second run finds nothing to +rewrite and reports `unchanged`, so re-runs are safe. + +The action never fails the job: every failure path ends with `conclusion=failed`, +a one-line `reason` and exit 0, and nothing is pushed. + +## Usage + +```yaml +- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + +- id: fix + uses: loft-sh/github-actions/.github/actions/backport-fix-imports@backport-fix-imports/v1 + with: + target-branch: release-4.12 + backport-branch: backport/release-4.12/pr-8606 + github-token: ${{ secrets.GH_ACCESS_TOKEN }} + +- env: + CONCLUSION: ${{ steps.fix.outputs.conclusion }} + REASON: ${{ steps.fix.outputs.reason }} + run: echo "$CONCLUSION: $REASON" +``` + +The runner needs `python3` and `git`; `gofmt` is optional and only formats the +import blocks it resolved (it ships on GitHub-hosted Ubuntu runners). Pushing +with a PAT, rather than `GITHUB_TOKEN`, is what makes the fix commit trigger +the backport PR's CI. + + + +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|-----------------|--------|----------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| backport-branch | string | true | | Head branch of the backport PR,
e.g. backport/release-4.12/pr-8606. Checked out, fixed and
pushed back. | +| github-token | string | true | | Token with contents write on the
repository, used to fetch and push
the backport branch. Supplied to git
through the environment, never argv or
.git/config. | +| push | string | false | `"true"` | false commits the fix locally without
pushing (dry run). | +| source-branch | string | false | `"main"` | Branch the original change merged into.
Its root go.mod gives the source
module paths. | +| target-branch | string | true | | Release branch the backport PR targets,
e.g. release-4.12. Its root go.mod gives
the target module paths. | + + + + + +| OUTPUT | TYPE | DESCRIPTION | +|------------|--------|-------------------------------------------------------------------------------------------------------------------------------------| +| conclusion | string | fixed (a commit was made), unchanged (nothing to rewrite), skipped (same module paths, no go.mod, or missing input)
or failed. | +| reason | string | One-line explanation of the conclusion. | + + + +## Testing + +```bash +make test-backport-fix-imports +``` + +Runs the pytest suite in `test/`: the `go.mod` mapping, the import-hunk +resolver, and full runs against throwaway git repos pushed to a local bare +remote. No network and no tokens. diff --git a/.github/actions/backport-fix-imports/action.yml b/.github/actions/backport-fix-imports/action.yml new file mode 100644 index 00000000..d3c00c86 --- /dev/null +++ b/.github/actions/backport-fix-imports/action.yml @@ -0,0 +1,54 @@ +name: Backport fix imports +description: | + Repair Go module paths on a backport branch whose target release line is on + an older module major than its source (for example a v5 main backported to a + v4 release branch). Rewrites the import paths of modules the repository owns, + moves files added under a source-only staging directory, resolves conflict + hunks made only of import lines, then commits and pushes one commit. Conflicts + in real code are left and counted. Advisory: every failure path warns and + exits 0, so it never fails the backport that calls it. +inputs: + target-branch: + description: 'Release branch the backport PR targets, e.g. release-4.12. Its root go.mod gives the target module paths.' + required: true + backport-branch: + description: 'Head branch of the backport PR, e.g. backport/release-4.12/pr-8606. Checked out, fixed and pushed back.' + required: true + source-branch: + description: 'Branch the original change merged into. Its root go.mod gives the source module paths.' + required: false + default: 'main' + github-token: + description: 'Token with contents write on the repository, used to fetch and push the backport branch. Supplied to git through the environment, never argv or .git/config.' + required: true + push: + description: 'false commits the fix locally without pushing (dry run).' + required: false + default: 'true' + +outputs: + conclusion: + description: 'fixed (a commit was made), unchanged (nothing to rewrite), skipped (same module paths, no go.mod, or missing input) or failed.' + value: ${{ steps.fix.outputs.conclusion || 'failed' }} + reason: + description: 'One-line explanation of the conclusion.' + value: ${{ steps.fix.outputs.reason || 'the fix step did not run; see its log' }} + +runs: + using: composite + steps: + # The script exits 0 on every path it controls; continue-on-error covers + # the rest (a missing interpreter), so a backport is never failed by it. + # When the script never ran it wrote no outputs, so conclusion and reason + # fall back to failed above. + - name: Fix module paths + id: fix + continue-on-error: true + shell: bash + env: + TARGET_BRANCH: ${{ inputs.target-branch }} + BACKPORT_BRANCH: ${{ inputs.backport-branch }} + SOURCE_BRANCH: ${{ inputs.source-branch }} + PUSH: ${{ inputs.push }} + GH_TOKEN: ${{ inputs.github-token }} + run: python3 "${{ github.action_path }}/src/fix.py" diff --git a/.github/actions/backport-fix-imports/src/fix.py b/.github/actions/backport-fix-imports/src/fix.py new file mode 100644 index 00000000..9ebc6262 --- /dev/null +++ b/.github/actions/backport-fix-imports/src/fix.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +"""backport-fix-imports: repair Go module paths on a backport branch whose +target release line is on an older module major than its source. + +A cherry-pick from a v5 main onto a v4 release branch carries v5 import +paths with it, and conflicts wherever both sides edited an import block. +This script, run inside a full-history checkout of the repository: + + 1. derives the path mapping from go.mod on both branches (only modules the + repository owns: the module line and replaces that point into the repo) + 2. moves files the backport added under a source-only staging directory + into the target's directory + 3. resolves conflict hunks that contain nothing but import lines + 4. rewrites the mapped import paths in the .go files the backport changed + 5. formats the files it resolved, commits as one commit and pushes + +Conflicts in real code are left as they are and counted, for a person or a +later AI pass. A second run finds nothing to do, so re-runs are no-ops. + +The action is advisory: every path, including an unexpected exception, ends +with exit 0 and a conclusion of fixed, unchanged, skipped or failed. + +Env: + TARGET_BRANCH release branch the backport PR targets (required) + SOURCE_BRANCH branch the original change merged into (default main) + BACKPORT_BRANCH head branch of the backport PR (required) + REMOTE git remote to fetch from and push to (default origin) + PUSH "false" commits locally without pushing (default true) + GH_TOKEN token used for fetch and push; never logged + GITHUB_OUTPUT standard Actions output file +""" +from __future__ import annotations + +import base64 +import functools +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Callable + +MARK_OURS = "<<<<<<< " +MARK_BASE = "||||||| " +MARK_SEP = "=======" +MARK_THEIRS = ">>>>>>> " + +GOMOD_MODULE = re.compile(r"^module\s+(\S+)", re.M) +# Single-line replaces and the lines of a replace ( ... ) block; only a local +# right-hand side ("./" or "../") names a module this repository owns. +GOMOD_REPLACE = re.compile(r"^[ \t]*(?:replace[ \t]+)?(\S+)(?:[ \t]+\S+)?[ \t]+=>[ \t]+(\.\.?/\S+)[ \t]*$", re.M) +MAJOR_SUFFIX = re.compile(r"/v\d+$") + +# One import spec per line: optional name, quoted path, optional comment. +IMPORT_SPEC = re.compile(r'^\s*(?:([A-Za-z_][A-Za-z0-9_]*|\.)\s+)?"([^"\s]+)"\s*(?://.*)?$') +COMMENT = re.compile(r"^\s*//") +PACKAGE_CLAUSE = re.compile(r"^package\s+([A-Za-z_][A-Za-z0-9_]*)", re.M) +# Comments and string, raw string and rune literals: a name inside them is not a use. +GO_NOISE = re.compile(r"""//[^\n]*|/\*.*?\*/|"(?:\\.|[^"\\\n])*"|`[^`]*`|'(?:\\.|[^'\\\n])*'""", re.S) + +BOT_NAME = "github-actions[bot]" +BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com" + + +class Skip(Exception): + """An expected condition: nothing to fix here.""" + + +def emit(key: str, value: str) -> None: + with open(os.environ.get("GITHUB_OUTPUT", os.devnull), "a", encoding="utf-8") as f: + f.write(f"{key}={value}\n") + + +def git(*args: str, check: bool = True) -> str: + env = os.environ.copy() + token = os.environ.get("GH_TOKEN", "") + if token: + # The PAT goes in through the environment, never argv or .git/config. + auth = base64.b64encode(f"x-access-token:{token}".encode()).decode() + env.update(GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="http.extraheader", + GIT_CONFIG_VALUE_0=f"AUTHORIZATION: basic {auth}") + res = subprocess.run(["git", *args], capture_output=True, text=True, env=env) + if check and res.returncode != 0: + sub = next(a for i, a in enumerate(args) if not a.startswith("-") and (i == 0 or args[i - 1] != "-c")) + raise RuntimeError(f"git {sub} failed: {res.stderr.strip()}") + return res.stdout + + +def strip_major(path: str) -> str: + return MAJOR_SUFFIX.sub("", path) + + +def owned_modules(gomod: str) -> dict[str, tuple[str, str]]: + """Map each owned module, keyed by its path without the major suffix, to + (full path, local directory or "").""" + owned: dict[str, tuple[str, str]] = {} + m = GOMOD_MODULE.search(gomod) + if m: + owned[strip_major(m.group(1))] = (m.group(1), "") + for path, local in GOMOD_REPLACE.findall(gomod): + owned[strip_major(path)] = (path, local.removeprefix("./").rstrip("/")) + return owned + + +def module_mapping(source_gomod: str, target_gomod: str) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: + """Return (import path pairs, directory pairs) that differ between source and target.""" + source, target = owned_modules(source_gomod), owned_modules(target_gomod) + paths, dirs = [], [] + for base, (src_path, src_dir) in source.items(): + if base not in target: + continue + tgt_path, tgt_dir = target[base] + if src_path != tgt_path: + paths.append((src_path, tgt_path)) + if src_dir and tgt_dir and src_dir != tgt_dir: + dirs.append((src_dir, tgt_dir)) + # Longest first, so a nested module is rewritten before its parent. + paths.sort(key=lambda p: len(p[0]), reverse=True) + return paths, dirs + + +def rewrite_paths(text: str, paths: list[tuple[str, str]]) -> str: + # Only at the start of a string literal and on a path boundary, so + # loft/v5 never matches loft/v50 or a mention in prose. + for src, tgt in paths: + text = re.sub(r'(?<=")' + re.escape(src) + r'(?=["/])', tgt, text) + return text + + +def package_clause(directory: Path) -> str | None: + """The package name the non-test .go files in a directory declare, or + None when there are none or they disagree.""" + names = set() + if directory.is_dir(): + for f in directory.glob("*.go"): + if not f.name.endswith("_test.go"): + m = PACKAGE_CLAUSE.search(GO_NOISE.sub(" ", f.read_text(encoding="utf-8", errors="replace"))) + if m: + names.add(m.group(1)) + return names.pop() if len(names) == 1 else None + + +def package_namer(owned: dict[str, tuple[str, str]], root: Path = Path(".")) -> Callable[[str], str | None]: + """Resolve an import path to the package name its source declares, since + the path does not say (github.com/json-iterator/go is package jsoniter). + The standard library goes by its last element, the repository's own + modules by their directory, everything else by vendor/. None when the + source is not in the checkout.""" + modules = sorted(owned.values(), key=lambda m: len(m[0]), reverse=True) + + @functools.cache + def name(path: str) -> str | None: + if "." not in path.split("/")[0]: + return [p for p in path.split("/") if not re.fullmatch(r"v\d+", p)][-1] + for mod_path, local in modules: + if path == mod_path or path.startswith(mod_path + "/"): + return package_clause(root / local / path[len(mod_path) + 1:]) + return package_clause(root / "vendor" / path) + + return name + + +def is_import_only(lines: list[str]) -> bool: + return all(not l.strip() or COMMENT.match(l) or IMPORT_SPEC.match(l) for l in lines) + + +def in_import_block(lines: list[str], index: int) -> bool: + for line in reversed(lines[:index]): + stripped = line.strip() + if stripped == "import (": + return True + if stripped == ")" or stripped.startswith(("func ", "type ", "var ", "const ")): + return False + return False + + +def block_end(lines: list[str], index: int) -> int: + for i in range(index, len(lines)): + if lines[i].strip() == ")": + return i + return len(lines) + + +def merge_imports(ours: list[str], theirs: list[str], body: str, + package_name: Callable[[str], str | None]) -> list[str] | None: + """Theirs (the backported change) wins. An import only the target side + has stays while the code uses it. Returns None whenever dropping or + keeping a target line cannot be proven safe, so the hunk is left for + review: an unused import fails the build as surely as a missing one.""" + merged = list(theirs) + seen = {m.group(2): l.strip() for l in theirs if (m := IMPORT_SPEC.match(l))} + theirs_lines = {l.strip() for l in theirs} + code = GO_NOISE.sub(" ", body) + for line in ours: + if not line.strip(): + continue + m = IMPORT_SPEC.match(line) + if not m: + # A comment only the target side has would be lost. + if line.strip() not in theirs_lines: + return None + continue + alias, path = m.groups() + if path in seen: + # Same path under another name or comment (a //nolint, say): the + # code or a linter may rely on the target's line. + if seen[path] != line.strip(): + return None + continue + if alias == ".": + return None + if alias != "_": + name = alias or package_name(path) + if name is None: + return None + # Not after a dot: obj.config.Field is a field, not package config. + if not re.search(r"(? tuple[str, int]: + """Resolve conflict hunks made only of import lines inside an import + block. Returns the new text and how many hunks were resolved.""" + lines = text.split("\n") + out: list[str] = [] + resolved = 0 + i = 0 + while i < len(lines): + if not lines[i].startswith(MARK_OURS): + out.append(lines[i]) + i += 1 + continue + try: + sep = next(j for j in range(i + 1, len(lines)) if lines[j] == MARK_SEP) + end = next(j for j in range(sep + 1, len(lines)) if lines[j].startswith(MARK_THEIRS)) + except StopIteration: + out.extend(lines[i:]) + break + base = next((j for j in range(i + 1, sep) if lines[j].startswith(MARK_BASE)), sep) + ours = [rewrite_paths(l, paths) for l in lines[i + 1:base]] + theirs = [rewrite_paths(l, paths) for l in lines[sep + 1:end]] + merged = None + if is_import_only(ours) and is_import_only(theirs) and in_import_block(lines, i): + merged = merge_imports(ours, theirs, "\n".join(lines[block_end(lines, end + 1) + 1:]), package_name) + if merged is not None: + out.extend(merged) + resolved += 1 + else: + out.extend(lines[i:end + 1]) + i = end + 1 + return "\n".join(out), resolved + + +def has_markers(text: str) -> bool: + return any(l.startswith(MARK_OURS) for l in text.split("\n")) + + +def changed_files(base: str) -> list[tuple[str, str]]: + """(status letter, current path) for each file the backport added, modified or renamed.""" + files = [] + for line in git("diff", "--name-status", "--diff-filter=AMR", base, "HEAD").splitlines(): + fields = line.split("\t") + files.append((fields[0][0], fields[-1])) + return files + + +def move_files(changed: list[tuple[str, str]], dirs: list[tuple[str, str]]) -> tuple[list[str], list[str]]: + """Move files the backport added under a source-only directory into the + target's. Returns the resulting paths and the files that could not move + because their target path exists; those are left for review.""" + result, blocked = [], [] + for status, path in changed: + new = path + for src_dir, tgt_dir in dirs: + if status == "A" and path.startswith(src_dir + "/"): + candidate = tgt_dir + path[len(src_dir):] + if Path(candidate).exists(): + print(f"::warning::{path}: {candidate} already exists, so it was not moved; merge the two by hand") + blocked.append(path) + else: + Path(candidate).parent.mkdir(parents=True, exist_ok=True) + git("mv", path, candidate) + print(f"moved {path} -> {candidate}") + new = candidate + break + result.append(new) + return result, blocked + + +def gofmt(files: list[str]) -> None: + if not files: + return + if not shutil.which("gofmt"): + print("::warning::gofmt not on PATH; resolved import blocks are left unformatted") + return + res = subprocess.run(["gofmt", "-w", *files], capture_output=True, text=True) + if res.returncode != 0: + raise RuntimeError(f"gofmt rejected the result, nothing committed: {res.stderr.strip()}") + + +def run() -> dict[str, str]: + target = os.environ.get("TARGET_BRANCH", "") + branch = os.environ.get("BACKPORT_BRANCH", "") + source = os.environ.get("SOURCE_BRANCH") or "main" + remote = os.environ.get("REMOTE") or "origin" + push = os.environ.get("PUSH", "true") != "false" + if not target or not branch: + raise Skip("target-branch and backport-branch are required; pass both from the backport job") + + git("fetch", "-q", "--no-tags", remote, + *(f"+refs/heads/{b}:refs/remotes/{remote}/{b}" for b in (target, source, branch))) + gomods = [] + for b in (source, target): + content = git("show", f"{remote}/{b}:go.mod", check=False) + if not content: + raise Skip(f"no go.mod at the root of {b}; nothing to map") + gomods.append(content) + paths, dirs = module_mapping(*gomods) + if not paths and not dirs: + raise Skip(f"{source} and {target} use the same module paths") + + git("checkout", "-q", "-B", branch, f"{remote}/{branch}") + base = git("merge-base", f"{remote}/{target}", "HEAD").strip() + changed, blocked = move_files(changed_files(base), dirs) + package_name = package_namer(owned_modules(gomods[1])) + moved = git("diff", "--cached", "--name-only").strip() != "" + + rewritten, to_format, hunks = [], [], 0 + for path in changed: + if path in blocked or not path.endswith(".go") or not Path(path).is_file(): + continue + before = Path(path).read_text(encoding="utf-8") + after, resolved = resolve_import_hunks(before, paths, package_name) + after = rewrite_paths(after, paths) + if after != before: + Path(path).write_text(after, encoding="utf-8") + rewritten.append(path) + hunks += resolved + if resolved and not has_markers(after): + to_format.append(path) + gofmt(to_format) + + marked = [p for p in changed if Path(p).is_file() and has_markers(Path(p).read_text(encoding="utf-8", errors="replace"))] + for p in marked: + print(f"::warning::{p}: conflict markers remain; resolve them by hand") + left = len(set(marked) | set(blocked)) + left_note = f"; {left} file(s) left for review" if left else "" + + if not rewritten and not moved: + return {"conclusion": "unchanged", "reason": f"no module paths to rewrite{left_note}"} + + if rewritten: + git("add", "--", *rewritten) + mapping = ", ".join(f"{s} -> {t}" for s, t in paths) + git("-c", f"user.name={BOT_NAME}", "-c", f"user.email={BOT_EMAIL}", "commit", "-q", + "-m", f"fix(backport): use {target} module paths", "-m", f"Rewrote {mapping}.") + if push: + git("push", "-q", remote, f"HEAD:refs/heads/{branch}") + reason = f"rewrote {len(rewritten)} file(s), resolved {hunks} import conflict(s){left_note}" + return {"conclusion": "fixed", "reason": reason} + + +def main() -> int: + try: + result = run() + except Skip as e: + result = {"conclusion": "skipped", "reason": str(e)} + print(f"::notice::{e}") + except Exception as e: # noqa: BLE001 -- advisory: never fail the backport + result = {"conclusion": "failed", "reason": str(e).splitlines()[0] if str(e) else type(e).__name__} + print(f"::warning::backport-fix-imports failed: {result['reason']}; the backport PR is unchanged, fix it by hand") + for key, value in result.items(): + emit(key, value) + print(f"conclusion={result['conclusion']} reason={result['reason']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/actions/backport-fix-imports/test/test_fix.py b/.github/actions/backport-fix-imports/test/test_fix.py new file mode 100644 index 00000000..f4283626 --- /dev/null +++ b/.github/actions/backport-fix-imports/test/test_fix.py @@ -0,0 +1,489 @@ +"""fix.py: the go.mod mapping, the import-hunk resolver, and full runs against +real throwaway git repos with a local bare remote. No network, no tokens.""" +from __future__ import annotations + +import importlib.util +import shutil +import subprocess +from pathlib import Path + +import pytest + +SRC = Path(__file__).resolve().parent.parent / "src" / "fix.py" + + +def load(): + spec = importlib.util.spec_from_file_location("backport_fix_imports", SRC) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +fix = load() + +V4_GOMOD = """module example.com/acme/app/v4 + +go 1.26 + +require example.com/acme/api/v4 v4.0.0 + +replace example.com/acme/api/v4 => ./staging/src/example.com/acme/api/v4 +""" +V5_GOMOD = V4_GOMOD.replace("/v4", "/v5") +API_V4 = "staging/src/example.com/acme/api/v4" +API_V5 = "staging/src/example.com/acme/api/v5" + + +def git(repo: Path, *args: str) -> str: + return subprocess.run(["git", "-C", str(repo), *args], check=True, + capture_output=True, text=True).stdout.strip() + + +def write(repo: Path, path: str, text: str) -> None: + (repo / path).parent.mkdir(parents=True, exist_ok=True) + (repo / path).write_text(text) + + +def commit_all(repo: Path, subject: str) -> None: + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", subject) + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + """A clone with main on v5 and release-4.12 on v4, pushed to a bare remote. + Returns the clone; tests build the backport branch on top of release-4.12.""" + for key, value in {"GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "test@example.com", + "GIT_CONFIG_GLOBAL": str(tmp_path / "gitconfig")}.items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("GH_TOKEN", raising=False) + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "-q", "--bare", str(remote)], check=True) + work = tmp_path / "work" + subprocess.run(["git", "init", "-q", "-b", "release-4.12", str(work)], check=True) + write(work, "go.mod", V4_GOMOD) + write(work, f"{API_V4}/types/types.go", "package types\n") + write(work, "pkg/util/util.go", "package util\n\nvar X = 1\n") + write(work, "pkg/peer/peer.go", 'package peer\n\nimport (\n\t"fmt"\n\n\t"example.com/acme/app/v4/pkg/util"\n)\n\nfunc A() { fmt.Println(util.X) }\n') + commit_all(work, "release line") + git(work, "checkout", "-q", "-b", "main") + write(work, "go.mod", V5_GOMOD) + git(work, "mv", API_V4, API_V5) + commit_all(work, "refactor: bump go modules to v5") + git(work, "remote", "add", "origin", str(remote)) + git(work, "push", "-q", "origin", "main", "release-4.12") + git(work, "checkout", "-q", "-b", "backport/release-4.12/pr-1", "release-4.12") + monkeypatch.chdir(work) + monkeypatch.setenv("TARGET_BRANCH", "release-4.12") + monkeypatch.setenv("BACKPORT_BRANCH", "backport/release-4.12/pr-1") + monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output")) + return work + + +def push_backport(repo: Path) -> None: + commit_all(repo, "backport commit") + git(repo, "push", "-q", "origin", "backport/release-4.12/pr-1") + + +def outputs(repo: Path) -> dict[str, str]: + lines = (repo.parent / "output").read_text().splitlines() + return dict(l.split("=", 1) for l in lines) + + +def remote_file(repo: Path, path: str) -> str: + git(repo, "fetch", "-q", "origin") + return git(repo, "show", f"origin/backport/release-4.12/pr-1:{path}") + + +# -- mapping --------------------------------------------------------------- + +def test_mapping_covers_module_line_and_local_replaces(): + paths, dirs = fix.module_mapping(V5_GOMOD, V4_GOMOD) + assert ("example.com/acme/app/v5", "example.com/acme/app/v4") in paths + assert ("example.com/acme/api/v5", "example.com/acme/api/v4") in paths + assert dirs == [(API_V5, API_V4)] + + +def test_mapping_ignores_third_party_majors(): + src = V5_GOMOD + "require example.com/other/lib/v3 v3.0.0\n" + tgt = V4_GOMOD + "require example.com/other/lib/v2 v2.0.0\n" + paths, _ = fix.module_mapping(src, tgt) + assert all("other" not in s for s, _ in paths) + + +def test_mapping_reads_replace_blocks(): + gomod = "module example.com/acme/app/v5\n\nreplace (\n\texample.com/acme/api/v5 => ./staging/api/v5\n\texample.com/x => example.com/y v1.0.0\n)\n" + owned = fix.owned_modules(gomod) + assert owned["example.com/acme/api"] == ("example.com/acme/api/v5", "staging/api/v5") + assert "example.com/x" not in owned + + +def test_same_module_paths_map_to_nothing(): + assert fix.module_mapping(V4_GOMOD, V4_GOMOD) == ([], []) + + +# -- rewriting ------------------------------------------------------------- + +PATHS = [("example.com/acme/app/v5", "example.com/acme/app/v4")] + + +@pytest.mark.parametrize("text, expected", [ + ('"example.com/acme/app/v5/pkg/a"', '"example.com/acme/app/v4/pkg/a"'), + ('"example.com/acme/app/v5"', '"example.com/acme/app/v4"'), + ('"example.com/acme/app/v50/pkg"', '"example.com/acme/app/v50/pkg"'), + ("// moved from example.com/acme/app/v5/pkg", "// moved from example.com/acme/app/v5/pkg"), +]) +def test_rewrite_only_at_string_start_and_path_boundary(text, expected): + assert fix.rewrite_paths(text, PATHS) == expected + + +# -- package names --------------------------------------------------------- + +def go_file(root: Path, path: str, package: str) -> None: + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_text(f"// Package {package} does things.\npackage {package}\n") + + +@pytest.fixture +def namer(tmp_path): + go_file(tmp_path, "pkg/util/util.go", "util") + go_file(tmp_path, f"{API_V4}/pkg/x/x.go", "xapi") + go_file(tmp_path, "vendor/github.com/json-iterator/go/a.go", "jsoniter") + go_file(tmp_path, "vendor/k8s.io/api/core/v1/types.go", "v1") + go_file(tmp_path, "vendor/k8s.io/klog/v2/klog.go", "klog") + go_file(tmp_path, "vendor/example.com/mixed/a.go", "a") + go_file(tmp_path, "vendor/example.com/mixed/b.go", "b") + go_file(tmp_path, "vendor/example.com/tested/a_test.go", "tested_test") + go_file(tmp_path, "vendor/example.com/tested/a.go", "tested") + return fix.package_namer(fix.owned_modules(V4_GOMOD), tmp_path) + + +@pytest.mark.parametrize("path, name", [ + ("fmt", "fmt"), + ("net/http", "http"), + ("math/rand/v2", "rand"), + ("example.com/acme/app/v4/pkg/util", "util"), + ("example.com/acme/api/v4/pkg/x", "xapi"), + ("github.com/json-iterator/go", "jsoniter"), + ("k8s.io/api/core/v1", "v1"), + ("k8s.io/klog/v2", "klog"), + ("example.com/tested", "tested"), + # Not in the checkout, or files that disagree: no safe answer. + ("example.com/acme/app/v4/pkg/missing", None), + ("github.com/not/vendored", None), + ("example.com/mixed", None), +]) +def test_package_name_comes_from_the_source(namer, path, name): + assert namer(path) == name + + +# -- resolving import hunks ------------------------------------------------ + +def by_last_element(path: str) -> str: + return path.rsplit("/", 1)[-1] + + +def conflicted(ours: list[str], theirs: list[str], body: str = "func F() {}\n") -> str: + return "\n".join(["package p", "", "import (", '\t"fmt"', "", + "<<<<<<< HEAD", *ours, "=======", *theirs, ">>>>>>> abc123 (feat: x (#1))", + ")", "", body]) + + +def test_import_only_hunk_takes_the_backported_side_rewritten(): + text = conflicted( + ['\t"example.com/acme/app/v4/pkg/apply"'], + ['\t"example.com/acme/app/v5/pkg/apply"', '\t"example.com/acme/app/v5/pkg/backup"'], + "func F() { apply.X(); backup.Y() }\n") + out, resolved = fix.resolve_import_hunks(text, PATHS) + assert resolved == 1 + assert not fix.has_markers(out) + assert '"example.com/acme/app/v4/pkg/backup"' in out + assert out.count('"example.com/acme/app/v4/pkg/apply"') == 1 + assert "v5" not in out + + +def test_target_only_import_kept_while_used_dropped_when_not(): + text = conflicted( + ['\t"example.com/acme/app/v4/pkg/legacy"', '\t"example.com/acme/app/v4/pkg/gone"'], + ['\t"example.com/acme/app/v5/pkg/apply"'], + "func F() { legacy.Run(); apply.X() }\n") + out, _ = fix.resolve_import_hunks(text, PATHS, by_last_element) + assert '"example.com/acme/app/v4/pkg/legacy"' in out + assert "pkg/gone" not in out + + +def test_field_access_is_not_a_package_use(): + text = conflicted(['\t"example.com/acme/app/v4/pkg/config"'], ['\t"example.com/acme/app/v5/pkg/apply"'], + "func F() { x := obj.config.Field; apply.X(x) }\n") + out, _ = fix.resolve_import_hunks(text, PATHS, by_last_element) + assert "pkg/config" not in out + + +@pytest.mark.parametrize("use", ["config.Load()", "(config.X)", "*config.T", "[]config.T{}"]) +def test_package_use_forms_keep_the_import(use): + text = conflicted(['\t"example.com/acme/app/v4/pkg/config"'], ['\t"example.com/acme/app/v5/pkg/apply"'], + f"func F() {{ _ = {use}; apply.X() }}\n") + out, _ = fix.resolve_import_hunks(text, PATHS, by_last_element) + assert '"example.com/acme/app/v4/pkg/config"' in out + + +def test_blank_lines_between_import_groups_are_kept(): + theirs = ['\t"strings"', "", '\t"example.com/acme/app/v5/pkg/apply"'] + out, _ = fix.resolve_import_hunks(conflicted([], theirs, "func F() { strings.X(); apply.X() }\n"), PATHS) + assert '\t"strings"\n\n\t"example.com/acme/app/v4/pkg/apply"' in out + + +@pytest.mark.parametrize("line", [ + '\t"github.com/json-iterator/go"', + '\t"k8s.io/api/core/v1"', + '\t. "example.com/dot"', +]) +def test_target_only_import_with_unknown_name_leaves_the_hunk(line): + # No source to read the package name from: neither keeping nor dropping is safe. + text = conflicted([line], ['\t"example.com/acme/app/v5/pkg/apply"'], "func F() { jsoniter.X(); apply.X() }\n") + out, resolved = fix.resolve_import_hunks(text, PATHS) + assert resolved == 0 + assert out == text + + +@pytest.mark.parametrize("ours, theirs", [ + ('\toldname "example.com/p"', '\tnewname "example.com/p"'), + ('\toldname "example.com/p"', '\t"example.com/p"'), + ('\t_ "example.com/p"', '\t"example.com/p"'), + ('\t. "example.com/p"', '\t"example.com/p"'), +]) +def test_same_path_under_another_name_leaves_the_hunk(ours, theirs): + text = conflicted([ours], [theirs], "func F() { oldname.X(); newname.Y(); p.Z() }\n") + out, resolved = fix.resolve_import_hunks(text, PATHS) + assert resolved == 0 + assert out == text + + +def test_same_path_same_name_is_deduplicated(): + text = conflicted(['\tcfg "example.com/acme/app/v4/pkg/config"'], ['\tcfg "example.com/acme/app/v5/pkg/config"'], + "func F() { cfg.X() }\n") + out, resolved = fix.resolve_import_hunks(text, PATHS) + assert resolved == 1 + assert out.count('cfg "example.com/acme/app/v4/pkg/config"') == 1 + + +def test_package_name_from_source_decides_the_import(namer): + # github.com/json-iterator/go is package jsoniter, not go. + text = conflicted(['\t"github.com/json-iterator/go"', '\t"k8s.io/klog/v2"'], + ['\t"example.com/acme/app/v5/pkg/util"'], "func F() { jsoniter.X(); util.X() }\n") + out, resolved = fix.resolve_import_hunks(text, PATHS, namer) + assert resolved == 1 + assert '"github.com/json-iterator/go"' in out + assert "klog" not in out + + +@pytest.mark.parametrize("ours, theirs", [ + ('\t"example.com/acme/app/v4/legacy" //nolint:staticcheck', '\t"example.com/acme/app/v5/legacy"'), + ('\t"example.com/acme/app/v4/legacy"', '\t"example.com/acme/app/v5/legacy" // new note'), +]) +def test_same_path_with_another_comment_leaves_the_hunk(ours, theirs): + text = conflicted([ours], [theirs], "func F() { legacy.X() }\n") + out, resolved = fix.resolve_import_hunks(text, PATHS, by_last_element) + assert resolved == 0 + assert out == text + + +def test_comment_line_only_the_target_has_leaves_the_hunk(): + text = conflicted(["\t// keep first: registers the scheme", '\t"example.com/acme/app/v4/pkg/a"'], + ['\t"example.com/acme/app/v5/pkg/a"'], "func F() { a.X() }\n") + _, resolved = fix.resolve_import_hunks(text, PATHS, by_last_element) + assert resolved == 0 + + +def test_comment_line_both_sides_have_is_fine(): + text = conflicted(["\t// shared note", '\t"example.com/acme/app/v4/pkg/a"'], + ["\t// shared note", '\t"example.com/acme/app/v5/pkg/a"'], "func F() { a.X() }\n") + out, resolved = fix.resolve_import_hunks(text, PATHS, by_last_element) + assert resolved == 1 + assert out.count("// shared note") == 1 + + +def test_target_only_blank_import_is_kept(): + text = conflicted(['\t_ "embed"'], ['\t"example.com/acme/app/v5/pkg/apply"'], "func F() { apply.X() }\n") + out, resolved = fix.resolve_import_hunks(text, PATHS) + assert resolved == 1 + assert '_ "embed"' in out + + +@pytest.mark.parametrize("body", [ + "// config.Load() is called by the caller\nfunc F() { apply.X() }\n", + "/* config.Load() */\nfunc F() { apply.X() }\n", + 'var s = "config.Load()"\nfunc F() { apply.X() }\n', + "var s = `config.Load()`\nfunc F() { apply.X() }\n", +]) +def test_use_in_comment_or_string_does_not_keep_the_import(body): + text = conflicted(['\t"example.com/acme/app/v4/pkg/config"'], ['\t"example.com/acme/app/v5/pkg/apply"'], body) + out, resolved = fix.resolve_import_hunks(text, PATHS, by_last_element) + assert resolved == 1 + assert "pkg/config" not in out + + +def test_diff3_base_section_is_ignored(): + text = conflicted( + ['\t"example.com/acme/app/v4/pkg/a"', "||||||| base", '\t"example.com/acme/app/v4/pkg/old"'], + ['\t"example.com/acme/app/v5/pkg/a"'], "func F() { a.X() }\n") + out, resolved = fix.resolve_import_hunks(text, PATHS) + assert resolved == 1 + assert "pkg/old" not in out and "|||||||" not in out + + +def test_code_hunk_is_left_alone(): + text = "\n".join(["package p", "", "func F() {", "<<<<<<< HEAD", "\ta()", "=======", "\tb()", + ">>>>>>> abc123", "}", ""]) + out, resolved = fix.resolve_import_hunks(text, PATHS) + assert resolved == 0 and out == text + + +def test_import_lines_outside_an_import_block_are_left_alone(): + text = "\n".join(["package p", "", "var s = []string{", "<<<<<<< HEAD", '\t"a"', "=======", '\t"b"', + ">>>>>>> abc123", "}", ""]) + _, resolved = fix.resolve_import_hunks(text, PATHS) + assert resolved == 0 + + +def test_unterminated_hunk_is_left_alone(): + text = "package p\n<<<<<<< HEAD\n\"a\"\n" + assert fix.resolve_import_hunks(text, PATHS) == (text, 0) + + +# -- full runs ------------------------------------------------------------- + +def test_clean_pick_with_source_imports_is_rewritten_and_pushed(repo): + write(repo, "pkg/peer/peer_test.go", 'package peer\n\nimport (\n\t"testing"\n\n\tstoragev1 "example.com/acme/api/v5/pkg/apis/storage/v1"\n\t"example.com/acme/app/v5/pkg/peer/networkpeer"\n)\n\nfunc TestX(t *testing.T) { _ = storagev1.X; networkpeer.Y() }\n') + push_backport(repo) + assert fix.main() == 0 + out = outputs(repo) + assert out["conclusion"] == "fixed" + content = remote_file(repo, "pkg/peer/peer_test.go") + assert '"example.com/acme/api/v4/pkg/apis/storage/v1"' in content + assert '"example.com/acme/app/v4/pkg/peer/networkpeer"' in content + assert git(repo, "log", "-1", "--format=%s", "origin/backport/release-4.12/pr-1") == "fix(backport): use release-4.12 module paths" + + +def test_untouched_files_are_not_rewritten(repo): + # A v5 path in a file the backport did not change is not ours to touch. + write(repo, "pkg/other/other.go", 'package other\n\nimport _ "example.com/acme/app/v5/pkg/x"\n') + commit_all(repo, "pre-existing") + git(repo, "push", "-q", "origin", "HEAD:release-4.12") + write(repo, "pkg/peer/new.go", 'package peer\n\nimport "example.com/acme/app/v5/pkg/util"\n\nvar _ = util.X\n') + push_backport(repo) + fix.main() + assert '"example.com/acme/app/v5/pkg/x"' in remote_file(repo, "pkg/other/other.go") + assert '"example.com/acme/app/v4/pkg/util"' in remote_file(repo, "pkg/peer/new.go") + + +def test_existing_file_under_source_dir_is_not_moved(repo): + # Only files the backport added move; one the target already had stays put. + write(repo, f"{API_V5}/types/kept.go", "package types\n") + commit_all(repo, "pre-existing") + git(repo, "push", "-q", "origin", "HEAD:release-4.12") + write(repo, f"{API_V5}/types/kept.go", "package types\n\nvar X = 1\n") + write(repo, "pkg/peer/new.go", 'package peer\n\nimport "example.com/acme/app/v5/pkg/util"\n\nvar _ = util.X\n') + push_backport(repo) + fix.main() + tree = git(repo, "ls-tree", "-r", "--name-only", "origin/backport/release-4.12/pr-1") + assert f"{API_V5}/types/kept.go" in tree + assert f"{API_V4}/types/kept.go" not in tree + + +def test_move_blocked_by_existing_target_is_left_for_review(repo): + write(repo, f"{API_V5}/types/types.go", 'package types\n\nimport "example.com/acme/api/v5/pkg/x"\n\nvar _ = x.Y\n') + push_backport(repo) + fix.main() + out = outputs(repo) + assert out["conclusion"] == "unchanged" + assert "1 file(s) left for review" in out["reason"] + assert '"example.com/acme/api/v5/pkg/x"' in remote_file(repo, f"{API_V5}/types/types.go") + + +def test_file_added_under_source_staging_dir_is_moved(repo): + write(repo, f"{API_V5}/types/extra.go", 'package types\n\nimport "example.com/acme/api/v5/pkg/x"\n\nvar _ = x.Y\n') + push_backport(repo) + fix.main() + assert outputs(repo)["conclusion"] == "fixed" + tree = git(repo, "ls-tree", "-r", "--name-only", "origin/backport/release-4.12/pr-1") + assert f"{API_V4}/types/extra.go" in tree + assert API_V5 not in tree + assert '"example.com/acme/api/v4/pkg/x"' in remote_file(repo, f"{API_V4}/types/extra.go") + + +def test_import_conflict_resolved_code_conflict_counted(repo): + write(repo, "pkg/peer/peer.go", conflicted( + ['\t"example.com/acme/app/v4/pkg/util"'], + ['\t"example.com/acme/app/v5/pkg/util"', '\t"example.com/acme/app/v5/pkg/backup"'], + "func A() { fmt.Println(util.X); backup.Y() }\n").replace("package p", "package peer")) + write(repo, "pkg/peer/code.go", "package peer\n\nfunc B() {\n<<<<<<< HEAD\n\ta()\n=======\n\tb()\n>>>>>>> abc123\n}\n") + push_backport(repo) + fix.main() + out = outputs(repo) + assert out["conclusion"] == "fixed" + assert "resolved 1 import conflict(s); 1 file(s) left for review" in out["reason"] + assert not fix.has_markers(remote_file(repo, "pkg/peer/peer.go")) + assert fix.has_markers(remote_file(repo, "pkg/peer/code.go")) + + +@pytest.mark.skipif(not shutil.which("gofmt"), reason="gofmt not installed") +def test_resolved_import_block_is_gofmt_sorted(repo): + write(repo, "pkg/peer/peer.go", conflicted( + ['\t"example.com/acme/app/v4/pkg/util"'], + ['\t"example.com/acme/app/v5/pkg/zeta"', '\t"example.com/acme/app/v5/pkg/alpha"'], + "func A() { fmt.Println(util.X, zeta.X, alpha.X) }\n").replace("package p", "package peer")) + push_backport(repo) + fix.main() + content = remote_file(repo, "pkg/peer/peer.go") + assert content.index("pkg/alpha") < content.index("pkg/util") < content.index("pkg/zeta") + + +def test_second_run_is_a_no_op(repo): + write(repo, "pkg/peer/new.go", 'package peer\n\nimport "example.com/acme/app/v5/pkg/util"\n\nvar _ = util.X\n') + push_backport(repo) + fix.main() + head = git(repo, "rev-parse", "origin/backport/release-4.12/pr-1") + (repo.parent / "output").unlink() + fix.main() + assert outputs(repo)["conclusion"] == "unchanged" + git(repo, "fetch", "-q", "origin") + assert git(repo, "rev-parse", "origin/backport/release-4.12/pr-1") == head + + +def test_same_module_paths_skip(repo, monkeypatch): + git(repo, "push", "-q", "origin", "release-4.12:release-4.13") + monkeypatch.setenv("SOURCE_BRANCH", "release-4.13") + write(repo, "pkg/peer/new.go", "package peer\n") + push_backport(repo) + fix.main() + assert outputs(repo)["conclusion"] == "skipped" + + +def test_push_false_leaves_the_remote_alone(repo, monkeypatch): + monkeypatch.setenv("PUSH", "false") + write(repo, "pkg/peer/new.go", 'package peer\n\nimport "example.com/acme/app/v5/pkg/util"\n\nvar _ = util.X\n') + push_backport(repo) + before = git(repo, "rev-parse", "origin/backport/release-4.12/pr-1") + fix.main() + out = outputs(repo) + assert out["conclusion"] == "fixed" + assert git(repo, "rev-parse", "HEAD") != before + git(repo, "fetch", "-q", "origin") + assert git(repo, "rev-parse", "origin/backport/release-4.12/pr-1") == before + + +def test_missing_branch_fails_soft(repo, monkeypatch): + monkeypatch.setenv("BACKPORT_BRANCH", "backport/release-4.12/pr-404") + assert fix.main() == 0 + out = outputs(repo) + assert out["conclusion"] == "failed" + assert "git fetch failed" in out["reason"] + + +def test_missing_inputs_skip(repo, monkeypatch): + monkeypatch.delenv("TARGET_BRANCH") + assert fix.main() == 0 + out = outputs(repo) + assert out["conclusion"] == "skipped" diff --git a/.github/workflows/test-backport-fix-imports.yaml b/.github/workflows/test-backport-fix-imports.yaml new file mode 100644 index 00000000..47acec7f --- /dev/null +++ b/.github/workflows/test-backport-fix-imports.yaml @@ -0,0 +1,58 @@ +name: Test backport-fix-imports + +on: + push: + branches: [main] + paths: + - '.github/actions/backport-fix-imports/**' + - '.github/workflows/test-backport-fix-imports.yaml' + pull_request: + paths: + - '.github/actions/backport-fix-imports/**' + - '.github/workflows/test-backport-fix-imports.yaml' + +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Run backport-fix-imports pytest + run: uv run --with pytest==8.3.3 pytest .github/actions/backport-fix-imports/test/ -q + + # The composite's own contract: when the script cannot start it writes no + # outputs, and the action must still report conclusion=failed and stay green. + smoke-no-interpreter: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Shadow python3 with one that cannot start + run: | + mkdir -p "$RUNNER_TEMP/broken" + printf '#!/bin/sh\nexit 127\n' > "$RUNNER_TEMP/broken/python3" + chmod +x "$RUNNER_TEMP/broken/python3" + echo "$RUNNER_TEMP/broken" >> "$GITHUB_PATH" + - name: Run backport-fix-imports + id: fix + uses: ./.github/actions/backport-fix-imports + with: + target-branch: release-0.0 + backport-branch: backport/release-0.0/pr-0 + github-token: ${{ github.token }} + push: 'false' + - name: Verify it reported failed + env: + CONCLUSION: ${{ steps.fix.outputs.conclusion }} + REASON: ${{ steps.fix.outputs.reason }} + run: | + echo "conclusion=$CONCLUSION reason=$REASON" + [ "$CONCLUSION" = failed ] + [ -n "$REASON" ] diff --git a/Makefile b/Makefile index 72b7ffed..ba15eca4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test test-secret-broker-request test-secret-broker-response test-semver-validation test-setup-semstat test-linear-pr-commenter test-link-backport-prs test-select-backport-source-prs test-release-notification test-linear-release-sync test-linear-cve-summary test-aws-test-infra test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-release-summary test-publish-helm-chart test-govulncheck test-go-licenses test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze test-prerelease-setup test-vcluster-release test-platform-release test-release-lib test-subtree-mirror test-oss-commit-sync test-oss-mirror-staleness test-backport-legacy-allowlist test-backport-legacy-split test-promote-release test-wait-for-release test-cve-scan test-commitlint test-resolve-github-release test-comment-triggered-check build-linear-release-sync lint check-bats-jobs install-auto-doc generate-docs check-docs help +.PHONY: test test-secret-broker-request test-secret-broker-response test-semver-validation test-setup-semstat test-linear-pr-commenter test-link-backport-prs test-select-backport-source-prs test-release-notification test-linear-release-sync test-linear-cve-summary test-aws-test-infra test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-release-summary test-publish-helm-chart test-govulncheck test-go-licenses test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze test-prerelease-setup test-vcluster-release test-platform-release test-release-lib test-subtree-mirror test-oss-commit-sync test-oss-mirror-staleness test-backport-legacy-allowlist test-backport-legacy-split test-backport-fix-imports test-promote-release test-wait-for-release test-cve-scan test-commitlint test-resolve-github-release test-comment-triggered-check build-linear-release-sync lint check-bats-jobs install-auto-doc generate-docs check-docs help ACTIONS_DIR := .github/actions WORKFLOWS_DIR := .github/workflows @@ -93,7 +93,7 @@ check-docs: generate-docs ## verify docs are up to date (fails if drift detected help: ## show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-30s %s\n", $$1, $$2}' -test: test-secret-broker-request test-secret-broker-response test-semver-validation test-setup-semstat test-linear-pr-commenter test-link-backport-prs test-select-backport-source-prs test-release-notification test-linear-release-sync test-linear-cve-summary test-aws-test-infra test-cleanup-head-charts test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-release-summary test-ci-test-notify test-go-licenses test-publish-helm-chart test-govulncheck test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze test-prerelease-setup test-vcluster-release test-platform-release test-release-lib test-subtree-mirror test-oss-commit-sync test-oss-mirror-staleness test-backport-legacy-allowlist test-backport-legacy-split test-promote-release test-wait-for-release test-cve-scan test-commitlint test-resolve-github-release test-comment-triggered-check ## run all action tests +test: test-secret-broker-request test-secret-broker-response test-semver-validation test-setup-semstat test-linear-pr-commenter test-link-backport-prs test-select-backport-source-prs test-release-notification test-linear-release-sync test-linear-cve-summary test-aws-test-infra test-cleanup-head-charts test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-release-summary test-ci-test-notify test-go-licenses test-publish-helm-chart test-govulncheck test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze test-prerelease-setup test-vcluster-release test-platform-release test-release-lib test-subtree-mirror test-oss-commit-sync test-oss-mirror-staleness test-backport-legacy-allowlist test-backport-legacy-split test-backport-fix-imports test-promote-release test-wait-for-release test-cve-scan test-commitlint test-resolve-github-release test-comment-triggered-check ## run all action tests test-secret-broker-request: ## run secret-broker-request pytest tests uv run --with pytest==8.3.3 pytest $(ACTIONS_DIR)/secret-broker-request/test/ -q @@ -211,6 +211,9 @@ test-backport-legacy-allowlist: ## run backport-legacy-allowlist bats tests test-backport-legacy-split: ## run backport-legacy-split bats tests bats $(ACTIONS_DIR)/backport-legacy-split/test/run.bats +test-backport-fix-imports: ## run backport-fix-imports pytest tests + uv run --with pytest==8.3.3 pytest $(ACTIONS_DIR)/backport-fix-imports/test/ -q + test-wait-for-release: ## run wait-for-release bats tests bats $(ACTIONS_DIR)/wait-for-release/test/*.bats diff --git a/README.md b/README.md index dd7f27da..693e2aab 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,14 @@ backport link sweep and performs no mutation. See [select-backport-source-prs README](./.github/actions/select-backport-source-prs/README.md) for inputs, outputs, and tests. +### Backport Fix Imports Action + +Repairs Go module paths on a backport branch whose target release line is on an older module major than its source, for example a v5 `main` backported to a v4 release branch. Rewrites the import paths of modules the repository owns, moves files added under a source-only staging directory, resolves conflict hunks made only of import lines, and pushes one fix commit. Conflicts in real code are left and counted; advisory, never fails the job. + +**Location:** `.github/actions/backport-fix-imports` + +See [backport-fix-imports README](./.github/actions/backport-fix-imports/README.md) for inputs, outputs, and tests. + ### Run Ginkgo Tests Runs Ginkgo tests with directory or label-based filtering and generates a