diff --git a/.github/workflows/control-plane.yml b/.github/workflows/control-plane.yml index f62d871..ffba497 100644 --- a/.github/workflows/control-plane.yml +++ b/.github/workflows/control-plane.yml @@ -307,7 +307,7 @@ jobs: ref: ${{ inputs.sha }} path: app-repo fetch-depth: 0 - submodules: ${{ steps.control-check-policy.outputs.checkout_submodules == 'true' && 'recursive' || 'false' }} + submodules: ${{ steps.control-check-policy.outputs.checkout_submodules == 'true' && inputs.event != 'pull_request' && 'recursive' || 'false' }} token: ${{ steps.app-token.outputs.token }} persist-credentials: false @@ -494,7 +494,7 @@ jobs: ref: ${{ matrix.target.sha }} path: app-repo fetch-depth: 0 - submodules: ${{ matrix.target.checkout_submodules == true && 'recursive' || 'false' }} + submodules: ${{ matrix.target.checkout_submodules == true && matrix.target.event != 'pull_request' && 'recursive' || 'false' }} token: ${{ steps.app-token.outputs.token }} persist-credentials: false diff --git a/src/aio_fleet/poll.py b/src/aio_fleet/poll.py index a603db9..d1f8523 100644 --- a/src/aio_fleet/poll.py +++ b/src/aio_fleet/poll.py @@ -41,9 +41,7 @@ def poll_targets( sha=sha, event="pull_request", source=f"pr:{number}", - checkout_submodules=bool( - repo.raw.get("checkout_submodules") - ), + checkout_submodules=False, publish=False, ) ) diff --git a/src/aio_fleet/registry.py b/src/aio_fleet/registry.py index 9feb2a3..c329cdc 100644 --- a/src/aio_fleet/registry.py +++ b/src/aio_fleet/registry.py @@ -234,10 +234,20 @@ def _release_tag_sha_allowed( subjects = git( repo.path, "log", "--format=%s", f"{release_target_commit}..{sha}" ) + changed_files = git( + repo.path, "diff", "--name-only", f"{release_target_commit}..{sha}" + ) except (Exception, SystemExit): return False - return bool(subjects.strip()) and all( - _RELEASE_FORMAT_SUBJECT.match(subject.strip()) - for subject in subjects.splitlines() - if subject.strip() + + subject_lines = [ + subject.strip() for subject in subjects.splitlines() if subject.strip() + ] + changed_paths = [ + path.strip() for path in changed_files.splitlines() if path.strip() + ] + return ( + bool(subject_lines) + and all(_RELEASE_FORMAT_SUBJECT.match(subject) for subject in subject_lines) + and changed_paths == ["CHANGELOG.md"] ) diff --git a/src/aio_fleet/release.py b/src/aio_fleet/release.py index 81fc067..6526180 100644 --- a/src/aio_fleet/release.py +++ b/src/aio_fleet/release.py @@ -350,10 +350,17 @@ def find_release_publish_target_commit(repo_path: Path, version: str) -> str: if not git_is_ancestor(repo_path, release_target, head): return release_target subjects = git(repo_path, "log", "--format=%s", f"{release_target}..{head}") - if subjects.strip() and all( - RELEASE_FORMAT_SUBJECT.match(subject.strip()) - for subject in subjects.splitlines() - if subject.strip() + changed_files = git(repo_path, "diff", "--name-only", f"{release_target}..{head}") + subject_lines = [ + subject.strip() for subject in subjects.splitlines() if subject.strip() + ] + changed_paths = [ + path.strip() for path in changed_files.splitlines() if path.strip() + ] + if ( + subject_lines + and all(RELEASE_FORMAT_SUBJECT.match(subject) for subject in subject_lines) + and changed_paths == ["CHANGELOG.md"] ): return head return release_target diff --git a/src/aio_fleet/safety.py b/src/aio_fleet/safety.py index 13a8fe9..714bc17 100644 --- a/src/aio_fleet/safety.py +++ b/src/aio_fleet/safety.py @@ -31,6 +31,16 @@ "redis", "volume", } +GITHUB_CLI_TOKEN_KEYS = ( + "AIO_FLEET_DASHBOARD_TOKEN", + "AIO_FLEET_UPSTREAM_TOKEN", + "AIO_FLEET_ISSUE_TOKEN", + "AIO_FLEET_WORKFLOW_TOKEN", + "AIO_FLEET_CHECK_TOKEN", + "APP_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", +) @dataclass(frozen=True) @@ -646,9 +656,13 @@ def _gh_json(args: list[str], *, check: bool = True) -> Any: def _gh_env() -> dict[str, str]: env = dict(os.environ) - if not env.get("GH_TOKEN"): - for key in ("AIO_FLEET_WORKFLOW_TOKEN", "AIO_FLEET_CHECK_TOKEN", "APP_TOKEN"): - if env.get(key): - env["GH_TOKEN"] = env[key] - break + token = "" + for key in GITHUB_CLI_TOKEN_KEYS: + token = env.get(key, "").strip() + if token: + break + if token: + for key in GITHUB_CLI_TOKEN_KEYS: + env.pop(key, None) + env["GH_TOKEN"] = token return env diff --git a/tests/test_control_plane_workflow.py b/tests/test_control_plane_workflow.py index b825736..178f78b 100644 --- a/tests/test_control_plane_workflow.py +++ b/tests/test_control_plane_workflow.py @@ -95,11 +95,11 @@ def test_app_code_checkouts_gate_submodule_checkout() -> None: assert "checkout_submodules" in manual["with"]["submodules"] # nosec B101 assert ( - "inputs.event != 'pull_request'" not in manual["with"]["submodules"] + "inputs.event != 'pull_request'" in manual["with"]["submodules"] ) # nosec B101 assert "checkout_submodules" in poll["with"]["submodules"] # nosec B101 assert ( - "matrix.target.event != 'pull_request'" not in poll["with"]["submodules"] + "matrix.target.event != 'pull_request'" in poll["with"]["submodules"] ) # nosec B101 diff --git a/tests/test_poll.py b/tests/test_poll.py index 27923e3..c8c8c8a 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -69,7 +69,7 @@ def test_poll_targets_require_same_repository_pr_identity( ] -def test_poll_targets_emit_checkout_submodules_for_same_repo_pr_and_main( +def test_poll_targets_emit_checkout_submodules_only_for_main( tmp_path: Path, monkeypatch ) -> None: manifest_path = _write_manifest(tmp_path, checkout_submodules=True) @@ -94,7 +94,7 @@ def test_poll_targets_emit_checkout_submodules_for_same_repo_pr_and_main( "pull_request", "push", ] - assert targets[0].checkout_submodules is True # nosec B101 + assert targets[0].checkout_submodules is False # nosec B101 assert targets[1].checkout_submodules is True # nosec B101 diff --git a/tests/test_registry.py b/tests/test_registry.py index ab85936..cf961c8 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -97,11 +97,23 @@ def test_release_tag_allows_changelog_format_followup(monkeypatch) -> None: registry, "find_release_target_commit", lambda *_args, **_kwargs: release_sha ) monkeypatch.setattr(registry, "git_is_ancestor", lambda *_args: True) - monkeypatch.setattr( - registry, - "git", - lambda *_args: "chore(release): format sure changelog", - ) + + def fake_git(_path: Path, command: str, *args: str) -> str: + if (command, *args[:2]) == ( + "log", + "--format=%s", + f"{release_sha}..{publish_sha}", + ): + return "chore(release): format sure changelog" + if (command, *args[:2]) == ( + "diff", + "--name-only", + f"{release_sha}..{publish_sha}", + ): + return "CHANGELOG.md" + raise AssertionError(f"unexpected git call: {(command, *args)}") + + monkeypatch.setattr(registry, "git", fake_git) tags = registry.compute_registry_tags(repo, sha=publish_sha) @@ -134,6 +146,48 @@ def test_release_tag_rejects_arbitrary_post_release_commit(monkeypatch) -> None: assert "ghcr.io/jsonbored/sure-aio:0.7.0-aio.2" not in tags.ghcr # nosec B101 +def test_release_tag_rejects_changelog_format_subject_with_runtime_changes( + monkeypatch, +) -> None: + repo = load_manifest(ROOT / "fleet.yml").repo("sure-aio") + release_sha = "c" * 40 + publish_sha = "d" * 40 + + monkeypatch.setattr( + registry, "_read_component_upstream_version", lambda *_: "0.7.0" + ) + monkeypatch.setattr( + registry, "latest_changelog_version", lambda *_args, **_kwargs: "0.7.0-aio.2" + ) + monkeypatch.setattr( + registry, "find_release_target_commit", lambda *_args, **_kwargs: release_sha + ) + monkeypatch.setattr(registry, "git_is_ancestor", lambda *_args: True) + + def fake_git(_path: Path, command: str, *args: str) -> str: + if (command, *args[:2]) == ( + "log", + "--format=%s", + f"{release_sha}..{publish_sha}", + ): + return "chore(release): format sure changelog" + if (command, *args[:2]) == ( + "diff", + "--name-only", + f"{release_sha}..{publish_sha}", + ): + return "CHANGELOG.md\nDockerfile" + raise AssertionError(f"unexpected git call: {(command, *args)}") + + monkeypatch.setattr(registry, "git", fake_git) + + tags = registry.compute_registry_tags(repo, sha=publish_sha) + + assert tags.release_package_tag == "" # nosec B101 + assert "jsonbored/sure-aio:0.7.0-aio.2" not in tags.dockerhub # nosec B101 + assert "ghcr.io/jsonbored/sure-aio:0.7.0-aio.2" not in tags.ghcr # nosec B101 + + def test_signoz_agent_publish_command_uses_component_context(monkeypatch) -> None: repo = load_manifest(ROOT / "fleet.yml").repo("signoz-aio") diff --git a/tests/test_release.py b/tests/test_release.py index cd2dc45..4ae47c4 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -121,6 +121,28 @@ def test_release_publish_target_rejects_arbitrary_post_release_commit( ) +def test_release_publish_target_rejects_changelog_subject_with_runtime_changes( + tmp_path: Path, +) -> None: + _init_repo(tmp_path) + (tmp_path / "CHANGELOG.md").write_text("## Unreleased\n\n- initial\n") + _commit(tmp_path, "feat(test): initial") + (tmp_path / "CHANGELOG.md").write_text( + "## v1.0.0-aio.1 - 2026-05-10\n\n- initial\n" + ) + _commit(tmp_path, "chore(release): v1.0.0-aio.1") + release_commit = _git_output(tmp_path, "rev-parse", "HEAD") + (tmp_path / "CHANGELOG.md").write_text( + "## v1.0.0-aio.1 - 2026-05-10\n\n- initial\n\n" + ) + (tmp_path / "Dockerfile").write_text("FROM scratch\n") + _commit(tmp_path, "chore(release): format test changelog") + + assert ( # nosec B101 + find_release_publish_target_commit(tmp_path, "v1.0.0-aio.1") == release_commit + ) + + def test_release_cli_supports_component_suffix(tmp_path: Path, capsys) -> None: _init_repo(tmp_path) (tmp_path / "components").mkdir() diff --git a/tests/test_safety.py b/tests/test_safety.py index 407e5b4..86e76d3 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -1,5 +1,6 @@ from __future__ import annotations +import subprocess from pathlib import Path from aio_fleet import safety @@ -79,6 +80,33 @@ def test_runtime_smoke_failure_blocks(tmp_path: Path, monkeypatch) -> None: ) +def test_safety_gh_reads_use_dashboard_token(monkeypatch) -> None: + captured_env: dict[str, str] = {} + + def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + nonlocal captured_env + captured_env = dict(kwargs.get("env") or {}) + return subprocess.CompletedProcess( + args=args, + returncode=0, + stdout="[]", + stderr="", + ) + + monkeypatch.setenv("AIO_FLEET_DASHBOARD_TOKEN", "dashboard-token") + monkeypatch.setenv("AIO_FLEET_UPSTREAM_TOKEN", "upstream-token") + monkeypatch.setenv("AIO_FLEET_CHECK_TOKEN", "check-token") + monkeypatch.setenv("GH_TOKEN", "lower-priority-token") + monkeypatch.setenv("GITHUB_TOKEN", "repo-token") + monkeypatch.setattr(safety.subprocess, "run", fake_run) + + assert safety._gh_json(["api", "repos/JSONbored/private"]) == [] # nosec B101 + assert captured_env["GH_TOKEN"] == "dashboard-token" # nosec B101 + assert "AIO_FLEET_DASHBOARD_TOKEN" not in captured_env # nosec B101 + assert "AIO_FLEET_CHECK_TOKEN" not in captured_env # nosec B101 + assert "GITHUB_TOKEN" not in captured_env # nosec B101 + + def test_missing_required_check_blocks(tmp_path: Path, monkeypatch) -> None: repo = load_manifest(_manifest(tmp_path)).repo("example-aio") _write_xml(tmp_path / "example-aio.xml", targets=["8080"])