mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [env-bran] EnvSyncEngine: orchestrator-side prod→dev cascade (default-off) - EnvSyncEngine mirrors CiWatchEngine: cascade ladder_pairs top-down via GitHub merges API; clean→auto-push lower rung, conflict→one sync PR + tracked MAIN_PM task + stop. Never pushes prod (lower rung is never prod by construction). - GitService.sync_env_branch (merges API) + open_sync_pr (idempotent) + _env_merge_status/_post_sync_pr helpers (constants for 201/204/409). - TaskService.ENV_SYNC_SOURCE + list_open_env_sync_tasks (per-repo dedup). - config env_sync_enabled/_interval_seconds(1800)/_max_open_tasks(3)/_max_per_cycle(1). - Orchestrator 4-touch registration + _load_env_sync_set (ladder+token opt-in). - Feature-flags card + settings FEATURE_FLAGS entry for ROBOCO_ENV_SYNC_ENABLED. * [env-bran] Panel: environment ladder editor + types + validation - EnvironmentRung type + environments on Project/ProjectCreate/ProjectUpdate. - EnvironmentLadderEditor (plain useState, add/remove/up-down reorder, head/ prod labels) reused by create + edit project dialogs. - validateLadder (non-empty name+branch, no duplicate branches) shared, toast.error on submit; empty editor => null => inherits default_branch shim. - default_branch input kept with override-hint; API client passthrough. - 6 unit tests for validateLadder. * [env-bran] Tests + gate green: env ladder, EnvSyncEngine, promotion chain - tests/unit/models/test_env_branches.py: shim, head/prod, ladder_pairs, promotion_chain, normalize (20 tests) - tests/integration/services/test_env_sync_engine.py: cascade clean/conflict/ missing_ref/tokenless/degenerate/caps/dedup/disabled (9 tests, DB) - tests/integration/test_migration_env_branches.py: 073 defaults null + round-trip - tests/unit/services/test_release_executor*.py: add env_chain=[] to _ReleaseContext constructions (promotion_chain field is now required) - tests/unit/runtime/test_orchestrator_shutdown_drain.py: register _env_sync_task in the stop()-drain fixture (new named background loop) - roboco/services/git.py: revert _project_head_branch rename back to _project_default_branch (modify-in-place per plan); the rename in the consumers commit broke ~15 unit-test mocks that bind the original name - roboco/services/env_sync_engine.py + models/env_branches.py: ruff format - roboco/api/schemas/project.py: trailing-newline format Backend gate green (13013 passed / 439 skipped), mypy clean, ruff clean. Panel gate green (typecheck/lint/522 tests). * [env-bran] fix: add env_chain to _ReleaseContext in e2e smoke (CI red) The release-executor promotion_chain change made _ReleaseContext.env_chain required. I fixed the three unit/release test files but missed the construction in tests/e2e_smoke/test_background_engines.py:98 — my local gate ran 'mypy roboco/' (excludes tests/) and I skipped 'make e2e-smoke', so CI's mypy-on-tests + the e2e runtime job caught it instead of me. Verified locally with the CI-equivalent gates: uv run mypy roboco/ tests/ -> 1170 files, clean ROBOCO_E2E_SMOKE=1 uv run pytest tests/e2e_smoke -> 50 passed, 1 skipped * [env-bran] fix: extract _ensure_prod_fetched to clear xenon rank C (CI red) _production_assess grew past xenon --max-absolute B (rank C) when the env-branches prod-tip fetch added an if/try/except branch. Extracted the fetch-with-fallback into _ensure_prod_fetched (degan+fetch paths), moved _run_git to the module-level import. Local make quality green (all gates incl xenon/vulture/deptry/import-linter/foundation-check). --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
565 lines
20 KiB
Python
565 lines
20 KiB
Python
"""ReleaseExecutor: fail-closed bump → gate → commit → CI → publish (post-approval).
|
|
|
|
The executor's correctness is its ORDERING + fail-closed aborts: a red gate
|
|
aborts before any commit, a red release-commit CI aborts before publish, and a
|
|
green path publishes exactly once. Tested against a fake ops that records the
|
|
call sequence; the production git/gh ops is exercised live (CEO-gated).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from roboco.config import settings
|
|
from roboco.services import release_executor as re
|
|
from roboco.services.release_executor import (
|
|
ReleaseExecutor,
|
|
ReleaseResult,
|
|
_GitReleaseOps,
|
|
_ReleaseContext,
|
|
_resolve_release_ci_workflow,
|
|
)
|
|
from roboco.services.release_readiness import ReleaseReadinessReport
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_PLAN = ["pyproject.toml", "roboco/__init__.py", "CHANGELOG.md"]
|
|
_VERSION = "0.13.0"
|
|
_ONE = 1
|
|
|
|
|
|
def _report() -> ReleaseReadinessReport:
|
|
return ReleaseReadinessReport(
|
|
proposed_version=_VERSION,
|
|
bump_kind="minor",
|
|
change_summary=["feat: a thing"],
|
|
drafted_changelog=(
|
|
f"## [{_VERSION}] - 2026-06-25\n\n### Added\n- a thing (#1)\n"
|
|
),
|
|
version_bump_plan=list(_PLAN),
|
|
gaps=[],
|
|
migration_notes=[],
|
|
gate_state="green",
|
|
)
|
|
|
|
|
|
class _FakeOps:
|
|
"""Records the call sequence; flags drive gate/CI/already-published outcomes."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
already: bool = False,
|
|
gate: bool = True,
|
|
ci: bool = True,
|
|
commit_raises: str | None = None,
|
|
publish_raises: str | None = None,
|
|
):
|
|
self._already = already
|
|
self._gate = gate
|
|
self._ci = ci
|
|
self._commit_raises = commit_raises
|
|
self._publish_raises = publish_raises
|
|
# Half-landed (publish_failed retry) detection: a prior
|
|
# ``chore(release): {version}`` commit already on the branch. Set on the
|
|
# instance (not via __init__ — keeps the constructor under the arg-count
|
|
# gate) by tests that exercise the retry path.
|
|
self._existing_sha: str | None = None
|
|
# env-chain promotion failure message; set on the instance (same arg-
|
|
# count-gate reason) by the promotion-failure test.
|
|
self._promote_raises: str | None = None
|
|
self.calls: list[str] = []
|
|
self.bumped_plan: list[str] | None = None
|
|
self.bumped_version: str | None = None
|
|
self.halflanded_check = False
|
|
|
|
async def is_already_published(self, _version: str) -> bool:
|
|
self.calls.append("check")
|
|
return self._already
|
|
|
|
async def promote_env_chain(self) -> None:
|
|
self.calls.append("promote")
|
|
if self._promote_raises is not None:
|
|
raise RuntimeError(self._promote_raises)
|
|
|
|
async def release_commit_sha(self, _version: str) -> str | None:
|
|
# Half-landed detection: a prior `chore(release): {version}` commit
|
|
# already on the branch means a publish_failed retry must NOT re-run the
|
|
# bump→changelog→gate→commit pipeline. Recorded via a flag (not calls)
|
|
# so the green-path call-sequence assertion is unaffected.
|
|
self.halflanded_check = True
|
|
return self._existing_sha
|
|
|
|
async def apply_version_bumps(self, plan: list[str], new_version: str) -> list[str]:
|
|
self.calls.append("bump")
|
|
self.bumped_plan = list(plan)
|
|
self.bumped_version = new_version
|
|
return list(plan)
|
|
|
|
async def write_changelog_entry(self, _entry: str) -> None:
|
|
self.calls.append("changelog")
|
|
|
|
async def run_gate(self) -> bool:
|
|
self.calls.append("gate")
|
|
return self._gate
|
|
|
|
async def commit_and_push(self, _version: str) -> str:
|
|
self.calls.append("commit")
|
|
if self._commit_raises is not None:
|
|
raise RuntimeError(self._commit_raises)
|
|
return "deadbeef"
|
|
|
|
async def wait_for_ci(self, _commit_sha: str) -> bool:
|
|
self.calls.append("ci")
|
|
return self._ci
|
|
|
|
async def publish_release(self, version: str, _notes: str) -> str:
|
|
self.calls.append("publish")
|
|
if self._publish_raises is not None:
|
|
raise RuntimeError(self._publish_raises)
|
|
return f"https://github.com/x/roboco/releases/tag/v{version}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_green_path_publishes_once() -> None:
|
|
ops = _FakeOps()
|
|
result = await ReleaseExecutor(ops).execute(_report())
|
|
assert result.status == "published"
|
|
assert result.release_url is not None
|
|
assert result.commit_sha == "deadbeef"
|
|
assert ops.calls.count("publish") == _ONE
|
|
assert ops.calls == [
|
|
"check",
|
|
"promote",
|
|
"bump",
|
|
"changelog",
|
|
"gate",
|
|
"commit",
|
|
"ci",
|
|
"publish",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bump_targets_the_canonical_set() -> None:
|
|
ops = _FakeOps()
|
|
result = await ReleaseExecutor(ops).execute(_report())
|
|
assert ops.bumped_plan == _PLAN
|
|
assert ops.bumped_version == _VERSION
|
|
assert result.files_changed == _PLAN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_red_gate_aborts_before_commit() -> None:
|
|
ops = _FakeOps(gate=False)
|
|
result = await ReleaseExecutor(ops).execute(_report())
|
|
assert result.status == "gate_failed"
|
|
assert "commit" not in ops.calls
|
|
assert "publish" not in ops.calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_red_ci_aborts_before_publish() -> None:
|
|
ops = _FakeOps(ci=False)
|
|
result = await ReleaseExecutor(ops).execute(_report())
|
|
assert result.status == "ci_failed"
|
|
assert "commit" in ops.calls
|
|
assert "publish" not in ops.calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_already_published_is_a_noop() -> None:
|
|
ops = _FakeOps(already=True)
|
|
result = await ReleaseExecutor(ops).execute(_report())
|
|
assert result.status == "already_published"
|
|
assert "bump" not in ops.calls
|
|
assert "commit" not in ops.calls
|
|
assert "publish" not in ops.calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_push_failure_returns_structured_commit_failed() -> None:
|
|
"""#88: a RuntimeError from commit_and_push (gpgsign/pre-commit/non-ff
|
|
push) becomes a structured ``commit_failed`` result — not a 500 bubbling
|
|
out of ``approve``. Fail-closed: publish never runs."""
|
|
ops = _FakeOps(commit_raises="release push failed: non-fast-forward")
|
|
result = await ReleaseExecutor(ops).execute(_report())
|
|
assert result.status == "commit_failed"
|
|
assert result.commit_sha is None
|
|
assert result.release_url is None
|
|
assert "commit_failed" in result.detail or "push failed" in result.detail
|
|
assert "publish" not in ops.calls
|
|
assert "ci" not in ops.calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_failure_returns_structured_publish_failed() -> None:
|
|
"""#88: a RuntimeError from the GitHub release POST (auth/quota/network) becomes
|
|
a structured ``publish_failed`` result. The commit is already pushed and CI
|
|
is green, so the release is half-landed — the CEO can retry the publish
|
|
create`` for the same version (the executor is idempotent on the commit
|
|
side). No 500."""
|
|
ops = _FakeOps(publish_raises="release publish failed: HTTP 403: forbidden")
|
|
result = await ReleaseExecutor(ops).execute(_report())
|
|
assert result.status == "publish_failed"
|
|
assert result.commit_sha == "deadbeef"
|
|
assert result.release_url is None
|
|
assert "release publish failed" in result.detail
|
|
assert ops.calls.count("publish") == _ONE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_promotion_failure_aborts_before_bump() -> None:
|
|
"""A RuntimeError from promote_env_chain (a merge conflict in the
|
|
head->...->prod chain) becomes a structured ``promotion_failed`` result —
|
|
fail-closed: the bump/changelog/gate/commit/publish pipeline never runs."""
|
|
ops = _FakeOps()
|
|
ops._promote_raises = "env-chain promotion failed: non-fast-forward"
|
|
result = await ReleaseExecutor(ops).execute(_report())
|
|
assert result.status == "promotion_failed"
|
|
assert result.commit_sha is None
|
|
assert result.release_url is None
|
|
assert "bump" not in ops.calls
|
|
assert "commit" not in ops.calls
|
|
assert "publish" not in ops.calls
|
|
|
|
|
|
def test_release_result_carries_outcome_fields() -> None:
|
|
result = ReleaseResult(
|
|
status="published",
|
|
version=_VERSION,
|
|
files_changed=list(_PLAN),
|
|
commit_sha="abc",
|
|
release_url="https://example/releases/v0.13.0",
|
|
detail="ok",
|
|
)
|
|
assert result.version == _VERSION
|
|
assert result.files_changed == _PLAN
|
|
assert result.release_url is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_half_landed_retry_skips_bump_and_republishes_only() -> None:
|
|
"""#87: a publish_failed retry (commit pushed + CI green, no tag yet) must
|
|
NOT re-run bump/changelog/gate/commit — that would re-insert the changelog
|
|
entry above the already-present ``## [X.Y.Z]`` heading (duplicate) and land a
|
|
second ``chore(release): X.Y.Z`` commit. The executor detects the
|
|
half-landed state via ``release_commit_sha`` (a prior release commit already
|
|
on the branch) and jumps straight to wait_for_ci + publish."""
|
|
ops = _FakeOps()
|
|
ops._existing_sha = "existingbeef"
|
|
result = await ReleaseExecutor(ops).execute(_report())
|
|
assert result.status == "published"
|
|
assert result.commit_sha == "existingbeef"
|
|
assert result.release_url is not None
|
|
assert ops.halflanded_check is True
|
|
assert "bump" not in ops.calls
|
|
assert "changelog" not in ops.calls
|
|
assert "gate" not in ops.calls
|
|
assert "commit" not in ops.calls
|
|
assert ops.calls == ["check", "ci", "publish"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_ci_scoped_to_release_commit_not_branch_latest(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""#318: a later commit landing on master during the ~40min wait must not
|
|
mask the release commit's green CI. ``wait_for_ci`` scopes the GitHub query
|
|
to the release commit_sha (``head_sha=``), so the branch-latest run (a later
|
|
sha) can't make the gate poll forever and false-fail as ci_failed."""
|
|
commit_sha = "release_commit_abc"
|
|
later_sha = "later_landed_def"
|
|
|
|
async def _fake_get_ci(_slug: str, **_kwargs: object) -> dict[str, str]:
|
|
# Mimic GitHub's head_sha filter: a run for the release sha only when
|
|
# asked for it (head_sha=commit_sha); the branch-latest (later commit)
|
|
# run otherwise. The release gate MUST scope to commit_sha to see green.
|
|
if _kwargs.get("head_sha") == commit_sha:
|
|
return {
|
|
"head_sha": commit_sha,
|
|
"conclusion": "success",
|
|
"run_url": "u",
|
|
"run_name": "n",
|
|
"branch": "master",
|
|
"completed_at": "t",
|
|
}
|
|
return {
|
|
"head_sha": later_sha,
|
|
"conclusion": "success",
|
|
"run_url": "u2",
|
|
"run_name": "n2",
|
|
"branch": "master",
|
|
"completed_at": "t2",
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"roboco.services.git.get_git_service",
|
|
lambda _session: SimpleNamespace(get_latest_ci_conclusion=_fake_get_ci),
|
|
)
|
|
monkeypatch.setattr(re, "_CI_MAX_POLLS", 2)
|
|
|
|
async def _no_sleep(_secs: float) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(re.asyncio, "sleep", _no_sleep)
|
|
|
|
ctx = _ReleaseContext(
|
|
slug="roboco-api",
|
|
default_branch="master",
|
|
root=tmp_path,
|
|
git_url="x",
|
|
git_prefix=[],
|
|
ci_workflow="ci.yml",
|
|
env_chain=[],
|
|
)
|
|
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
|
|
ok = await ops.wait_for_ci(commit_sha)
|
|
assert ok is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_ci_polls_through_rerun(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A completed non-success conclusion on the release sha must not abort the
|
|
poll — a failed first attempt while a GitHub re-run is still in_progress
|
|
(excluded from the status=completed filter) can still flip the same
|
|
head_sha to success. Only ``conclusion == "success"`` returns True; loop
|
|
exhaustion returns False."""
|
|
commit_sha = "release_commit_abc"
|
|
seq = ["failure", "failure", "success"]
|
|
expected_polls = len(seq)
|
|
calls = {"n": 0}
|
|
|
|
async def _fake_get_ci(_slug: str, **kwargs: object) -> dict[str, object]:
|
|
i = min(calls["n"], len(seq) - 1)
|
|
calls["n"] += 1
|
|
return {
|
|
"head_sha": kwargs.get("head_sha", commit_sha),
|
|
"conclusion": seq[i],
|
|
"run_url": "u",
|
|
"run_name": "n",
|
|
"branch": "master",
|
|
"completed_at": "t",
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"roboco.services.git.get_git_service",
|
|
lambda _session: SimpleNamespace(get_latest_ci_conclusion=_fake_get_ci),
|
|
)
|
|
monkeypatch.setattr(re, "_CI_MAX_POLLS", 5)
|
|
|
|
async def _no_sleep(_secs: float) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(re.asyncio, "sleep", _no_sleep)
|
|
|
|
ctx = _ReleaseContext(
|
|
slug="roboco-api",
|
|
default_branch="master",
|
|
root=tmp_path,
|
|
git_url="x",
|
|
git_prefix=[],
|
|
ci_workflow="ci.yml",
|
|
env_chain=[],
|
|
)
|
|
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
|
|
ok = await ops.wait_for_ci(commit_sha)
|
|
assert ok is True
|
|
assert calls["n"] == expected_polls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_ci_exhausts_window_on_persistent_failure(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A definitive failure that never re-runs waits the full window then
|
|
returns False — keeps polling, never early-returns on non-success."""
|
|
commit_sha = "release_commit_abc"
|
|
max_polls = 3
|
|
calls = {"n": 0}
|
|
|
|
async def _fake_get_ci(_slug: str, **kwargs: object) -> dict[str, object]:
|
|
calls["n"] += 1
|
|
return {
|
|
"head_sha": kwargs.get("head_sha", commit_sha),
|
|
"conclusion": "failure",
|
|
"run_url": "u",
|
|
"run_name": "n",
|
|
"branch": "master",
|
|
"completed_at": "t",
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"roboco.services.git.get_git_service",
|
|
lambda _session: SimpleNamespace(get_latest_ci_conclusion=_fake_get_ci),
|
|
)
|
|
monkeypatch.setattr(re, "_CI_MAX_POLLS", max_polls)
|
|
|
|
async def _no_sleep(_secs: float) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(re.asyncio, "sleep", _no_sleep)
|
|
|
|
ctx = _ReleaseContext(
|
|
slug="roboco-api",
|
|
default_branch="master",
|
|
root=tmp_path,
|
|
git_url="x",
|
|
git_prefix=[],
|
|
ci_workflow="ci.yml",
|
|
env_chain=[],
|
|
)
|
|
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
|
|
ok = await ops.wait_for_ci(commit_sha)
|
|
assert ok is False
|
|
assert calls["n"] == max_polls
|
|
|
|
|
|
def test_release_ci_workflow_decoupled_from_self_heal_setting(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""#402: the release CI gate must not inherit ``self_heal_ci_workflow``'s
|
|
empty-string tuning (documented valid for single-workflow repos), which would
|
|
degrade the fail-closed gate to the all-workflows mode git.py itself flags as
|
|
unreliable. The release gate always resolves a named workflow (default
|
|
``ci.yml``), never None."""
|
|
# The dangerous tuning an operator might apply for self-heal on a
|
|
# single-workflow repo — must NOT leak into the release gate.
|
|
monkeypatch.setattr(settings, "self_heal_ci_workflow", "")
|
|
monkeypatch.setattr(settings, "release_ci_workflow", "ci.yml")
|
|
assert _resolve_release_ci_workflow() == "ci.yml"
|
|
|
|
monkeypatch.setattr(settings, "release_ci_workflow", "release.yml")
|
|
assert _resolve_release_ci_workflow() == "release.yml"
|
|
|
|
# An empty release setting never falls through to None — always the default.
|
|
monkeypatch.setattr(settings, "release_ci_workflow", "")
|
|
assert _resolve_release_ci_workflow() == "ci.yml"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# H11: the PAT must never appear in a git subprocess argv. The release clone
|
|
# and the release push carry the token via ``-c http.extraheader=Authorization:
|
|
# Basic <base64(x-access-token:TOKEN)>`` and a bare URL — never URL-embedded.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _basic_auth(token: str) -> str:
|
|
return base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
|
|
|
|
|
class _DoneProc:
|
|
"""A subprocess that completes immediately with a fixed rc + stdout."""
|
|
|
|
def __init__(self, out: bytes = b"", returncode: int = 0) -> None:
|
|
self.returncode = returncode
|
|
self._out = out
|
|
|
|
async def communicate(self) -> tuple[bytes, bytes]:
|
|
return (self._out, b"")
|
|
|
|
def kill(self) -> None:
|
|
return None
|
|
|
|
async def wait(self) -> int:
|
|
return self.returncode
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_release_clone_argv_uses_extraheader_not_url_token(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""H11: the release-clone argv carries the PAT via ``-c http.extraheader``,
|
|
never URL-embedded (``/proc/<pid>/cmdline`` would expose a URL token)."""
|
|
token = "ghp_SECRETCLONE"
|
|
git_url = "https://github.com/org/roboco.git"
|
|
expected_basic = _basic_auth(token)
|
|
git_prefix = ["-c", f"http.extraheader=Authorization: Basic {expected_basic}"]
|
|
captured: list[list[str]] = []
|
|
|
|
async def _exec(*args: str, **_kwargs: object) -> _DoneProc:
|
|
captured.append(list(args))
|
|
return _DoneProc()
|
|
|
|
monkeypatch.setattr(re.asyncio, "create_subprocess_exec", _exec)
|
|
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path))
|
|
|
|
await re._prepare_release_clone("roboco-api", git_url, git_prefix, "master")
|
|
|
|
clone_argv = next(a for a in captured if "clone" in a)
|
|
assert f"https://{token}@" not in " ".join(clone_argv), (
|
|
f"raw token leaked into clone argv URL: {clone_argv}"
|
|
)
|
|
assert token not in clone_argv, f"raw token in clone argv: {clone_argv}"
|
|
assert git_url in clone_argv, f"bare git_url missing from clone argv: {clone_argv}"
|
|
assert "-c" in clone_argv
|
|
c_idx = clone_argv.index("-c")
|
|
assert (
|
|
clone_argv[c_idx + 1]
|
|
== f"http.extraheader=Authorization: Basic {expected_basic}"
|
|
)
|
|
assert "clone" in clone_argv[c_idx + 2 :]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_release_push_argv_uses_extraheader_not_url_token(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""H11: the release push argv carries the PAT via ``-c http.extraheader``
|
|
and pushes to the bare URL — never ``https://TOKEN@host/...``."""
|
|
token = "ghp_SECRETPUSH"
|
|
git_url = "https://github.com/org/roboco.git"
|
|
expected_basic = _basic_auth(token)
|
|
git_prefix = ["-c", f"http.extraheader=Authorization: Basic {expected_basic}"]
|
|
captured: list[list[str]] = []
|
|
|
|
# commit_and_push issues: add -A, commit -S -m, rev-parse HEAD, push.
|
|
responses = iter(
|
|
[
|
|
_DoneProc(b""), # add -A
|
|
_DoneProc(b""), # commit
|
|
_DoneProc(b"deadbeef\n"), # rev-parse HEAD
|
|
_DoneProc(b"ok"), # push
|
|
]
|
|
)
|
|
|
|
async def _exec(*args: str, **_kwargs: object) -> _DoneProc:
|
|
captured.append(list(args))
|
|
return next(responses)
|
|
|
|
monkeypatch.setattr(re.asyncio, "create_subprocess_exec", _exec)
|
|
|
|
ctx = _ReleaseContext(
|
|
slug="roboco-api",
|
|
default_branch="master",
|
|
root=tmp_path,
|
|
git_url=git_url,
|
|
git_prefix=git_prefix,
|
|
ci_workflow=None,
|
|
env_chain=[],
|
|
)
|
|
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
|
|
sha = await ops.commit_and_push("0.13.0")
|
|
assert sha == "deadbeef"
|
|
|
|
push_argv = next(a for a in captured if "push" in a)
|
|
assert f"https://{token}@" not in " ".join(push_argv), (
|
|
f"raw token leaked into push argv URL: {push_argv}"
|
|
)
|
|
assert token not in push_argv, f"raw token in push argv: {push_argv}"
|
|
assert git_url in push_argv, f"bare git_url missing from push argv: {push_argv}"
|
|
assert "-c" in push_argv
|
|
c_idx = push_argv.index("-c")
|
|
assert (
|
|
push_argv[c_idx + 1]
|
|
== f"http.extraheader=Authorization: Basic {expected_basic}"
|
|
)
|
|
assert "push" in push_argv[c_idx + 2 :]
|