From c42fa98da1d19bda1e9b37acba41ce7dcc88eb8a Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 14 Jul 2026 12:32:21 -0400 Subject: [PATCH] fix(sync): skip stale workflows missing at ref instead of failing dispatch Workflows returned from the Actions registry whose file has been renamed or deleted no longer exist at the target ref. The dispatch pre-check 404'd on the contents fetch, fell back to attempting the dispatch, and surfaced a 422 'Workflow does not have workflow_dispatch trigger' error. Treat a 404/Not Found on the contents fetch as not-dispatchable and skip it quietly ('skipped (not present on )'); other errors still fall back to attempting the dispatch. --- docs/features/django-fork.rst | 6 +- src/dbx_python_cli/commands/sync.py | 12 ++++ tests/test_repo.py | 101 ++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/docs/features/django-fork.rst b/docs/features/django-fork.rst index 9a05c16..21a740f 100644 --- a/docs/features/django-fork.rst +++ b/docs/features/django-fork.rst @@ -213,7 +213,10 @@ maps an ``owner/name`` GitHub repo to a target that is **either**: fork branch is exercised — e.g. the backend's ``main`` pins ``mongodb-6.0.x``. Only ``test-python*`` workflows that actually declare a ``workflow_dispatch`` trigger are dispatched; any that don't (push/schedule/pull_request only) are - reported as ``skipped (no workflow_dispatch trigger)`` instead of failing, or + reported as ``skipped (no workflow_dispatch trigger)`` instead of failing. + Stale registry entries — workflows whose file has been renamed or deleted and + no longer exists at the ref — are likewise reported as + ``skipped (not present on )`` rather than attempted, or - an **object** ``{pr = , evergreen = true}`` — re-runs PR ````'s workflow runs (exactly as the integer form) **and** re-triggers that PR's Evergreen patch by commenting ``evergreen retry`` on it. This is needed because @@ -239,6 +242,7 @@ maps an ``owner/name`` GitHub repo to a target that is **either**: test-python.yml ✓ queued test-python-geo.yml ✓ queued test-python-replica.yml — skipped (no workflow_dispatch trigger) + test-python1.yml — skipped (not present on main) ♻️ mongodb-6.1.x → re-running CI on mongodb/django-mongodb-backend#422... #422 ✓ queued (4 workflow run(s)) ♻️ mongodb-6.2.x → re-running CI on mongodb/django-mongodb-backend#535... diff --git a/src/dbx_python_cli/commands/sync.py b/src/dbx_python_cli/commands/sync.py index 2525120..95379a3 100644 --- a/src/dbx_python_cli/commands/sync.py +++ b/src/dbx_python_cli/commands/sync.py @@ -487,6 +487,18 @@ def _dispatch_workflows(target, ref, branch, verbose, _gh_json): # Avoid a YAML dependency: a workflow can only be dispatched on a ref # if its definition mentions the `workflow_dispatch` trigger at all. has_dispatch = "workflow_dispatch" in body + except subprocess.CalledProcessError as e: + stderr = e.stderr or "" + # A 404 means the workflow file no longer exists at `ref` — a stale + # entry in the Actions registry (renamed/deleted workflow). It can + # never be dispatched on this ref, so skip it quietly rather than + # attempting a dispatch that is guaranteed to 422. + if "404" in stderr or "Not Found" in stderr: + typer.echo(f" {name} — skipped (not present on {ref})") + continue + if verbose: + typer.echo(f" [verbose] could not inspect {name}: {e}", err=True) + has_dispatch = True # transient/other error: fall back to attempting except Exception as e: # noqa: BLE001 — best-effort; still try to dispatch if verbose: typer.echo(f" [verbose] could not inspect {name}: {e}", err=True) diff --git a/tests/test_repo.py b/tests/test_repo.py index dcf2e18..4a73bc0 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -2007,6 +2007,107 @@ def mock_run_side_effect(*args, **kwargs): ) +def test_repo_sync_dispatch_skips_stale_workflow_missing_at_ref( + tmp_path, temp_repos_dir +): + """A workflow whose file 404s at the ref (stale registry entry) is skipped, + not attempted and surfaced as a dispatch failure.""" + config_path = tmp_path / ".config" / "dbx-python-cli" / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + repos_dir_str = str(temp_repos_dir).replace("\\", "/") + config_content = f""" +[repo] +base_dir = "{repos_dir_str}" + +[repo.groups.django] +repos = [ + "git@github.com:mongodb-forks/django.git", +] + +[repo.groups.django.upstream_branch] +django = {{"mongodb-6.0.x" = "stable/6.0.x"}} + +[repo.groups.django.ci_rerun.django] +"mongodb-6.0.x" = {{"mongodb/django-mongodb-backend" = "main"}} +""" + config_path.write_text(config_content) + + repo_dir = temp_repos_dir / "django" / "django" + repo_dir.mkdir(parents=True) + (repo_dir / ".git").mkdir() + + state = {"current": "main"} + + with patch("dbx_python_cli.utils.repo.get_config_path") as mock_get_path: + with patch("shutil.which", return_value="/usr/bin/gh"): + with patch("dbx_python_cli.commands.sync.subprocess.run") as mock_run: + mock_get_path.return_value = config_path + + def mock_run_side_effect(*args, **kwargs): + cmd = args[0] + if cmd[0] == "gh": + if "api" in cmd and any( + "actions/workflows" in str(x) for x in cmd + ): + return subprocess.CompletedProcess( + cmd, + 0, + stdout='[".github/workflows/test-python.yml", ' + '".github/workflows/test-python1.yml"]', + stderr="", + ) + if "api" in cmd and any("contents/" in str(x) for x in cmd): + path = next(x for x in cmd if "contents/" in str(x)) + # test-python1.yml no longer exists at `ref` → 404. + if "test-python1" in path: + raise subprocess.CalledProcessError( + 1, + cmd, + output="", + stderr="gh: Not Found (HTTP 404)", + ) + yaml_bytes = b"on:\n workflow_dispatch:\n" + return subprocess.CompletedProcess( + cmd, + 0, + stdout=base64.b64encode(yaml_bytes).decode(), + stderr="", + ) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if "switch" in cmd: + state["current"] = cmd[-1] + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if "branch" in cmd and "--show-current" in cmd: + return subprocess.CompletedProcess( + cmd, 0, stdout=f"{state['current']}\n", stderr="" + ) + if "remote" in cmd and "add" not in cmd and "show" not in cmd: + return subprocess.CompletedProcess( + cmd, 0, stdout="origin\nupstream\n", stderr="" + ) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + mock_run.side_effect = mock_run_side_effect + + result = runner.invoke(app, ["sync", "django", "--all-branches"]) + assert result.exit_code == 0 + + gh_cmds = [ + c[0][0] for c in mock_run.call_args_list if c[0][0][0] == "gh" + ] + dispatches = [ + cmd for cmd in gh_cmds if "workflow" in cmd and "run" in cmd + ] + # The stale workflow was never attempted (no 422-inducing dispatch). + assert len(dispatches) == 1 + assert "test-python.yml" in dispatches[0] + + output = result.stdout + result.stderr + assert "test-python.yml ✓ queued" in output + assert "test-python1.yml — skipped (not present on main)" in output + assert "dispatch failed" not in output + + def test_repo_sync_all_branches_dry_run(tmp_path, temp_repos_dir): """--all-branches --dry-run previews every branch without switching or rebasing.""" config_path = tmp_path / ".config" / "dbx-python-cli" / "config.toml"