| """GitHub push step builder. |
| |
| Returns an idempotent list of `Step`s for bootstrapping a git repo, creating |
| the GitHub remote (via `gh`), and pushing the working tree. Pure builder — |
| no subprocesses are spawned here. The orchestrator in `_orchestrator.py` |
| runs the steps via `mindxtrain.operator.runs.spawn_subprocess_streaming` so |
| each command's stdout streams back over the existing SSE pipeline. |
| |
| Auth: `GITHUB_TOKEN` is forwarded into the subprocess env as `GH_TOKEN` (the |
| name `gh` looks for) and as `GITHUB_TOKEN` (for `git push` via the credential |
| helper). The token never lands in argv or `.git/config`. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import shutil |
| from dataclasses import dataclass, field |
| from pathlib import Path |
|
|
|
|
| @dataclass(frozen=True) |
| class Step: |
| """A single shell-out step in a deploy pipeline.""" |
|
|
| label: str |
| cmd: list[str] |
| env: dict[str, str] = field(default_factory=dict) |
| |
| |
| |
| predicate_step: str | None = None |
| predicate_rc_in: tuple[int, ...] = (0,) |
| |
| |
| capture_stdout: bool = False |
| |
| allow_failure: bool = False |
|
|
|
|
| |
| |
| |
| _GIT_CRED_HELPER = ( |
| "credential.helper=" |
| "!f() { echo username=x-access-token; echo \"password=$GH_TOKEN\"; }; f" |
| ) |
|
|
|
|
| @dataclass(frozen=True) |
| class GithubConfig: |
| token: str |
| repo: str |
| branch: str = "main" |
| author_name: str = "mindXtrain bot" |
| author_email: str = "noreply@pythai.net" |
|
|
|
|
| def _step_env(token: str) -> dict[str, str]: |
| """Subprocess env: pass GH_TOKEN + GITHUB_TOKEN, scrub nothing else.""" |
| base = dict(os.environ) |
| base["GH_TOKEN"] = token |
| base["GITHUB_TOKEN"] = token |
| return base |
|
|
|
|
| def required_env() -> tuple[str, ...]: |
| """Names of env vars the operator must populate for /api/github/push.""" |
| return ("GITHUB_TOKEN", "GITHUB_REPO") |
|
|
|
|
| def missing_env(env: dict[str, str] | None = None) -> list[str]: |
| src = env if env is not None else os.environ |
| return [k for k in required_env() if not src.get(k)] |
|
|
|
|
| def _which_missing() -> list[str]: |
| """Binaries the push pipeline depends on, but only if they're absent.""" |
| out = [] |
| for binary in ("git", "gh"): |
| if shutil.which(binary) is None: |
| out.append(binary) |
| return out |
|
|
|
|
| def status_target(env: dict[str, str] | None = None) -> str: |
| src = env if env is not None else os.environ |
| return src.get("GITHUB_REPO", "") |
|
|
|
|
| def status_missing(env: dict[str, str] | None = None) -> list[str]: |
| """Combine env-missing + binary-missing for the /status endpoint.""" |
| return missing_env(env) + _which_missing() |
|
|
|
|
| def bootstrap_steps( |
| cfg: GithubConfig, |
| *, |
| commit_message: str = "mindXtrain initial push", |
| force: bool = False, |
| ) -> list[Step]: |
| """Idempotent step list. The orchestrator skips conditional steps by |
| inspecting earlier steps' rcs.""" |
| env = _step_env(cfg.token) |
| repo_url = f"https://github.com/{cfg.repo}.git" |
| push_cmd = [ |
| "git", |
| "-c", _GIT_CRED_HELPER, |
| "push", "-u", "origin", f"HEAD:{cfg.branch}", |
| ] |
| if force: |
| push_cmd.append("--force-with-lease") |
|
|
| return [ |
| Step( |
| label="probe-git", |
| cmd=["git", "rev-parse", "--git-dir"], |
| env=env, |
| allow_failure=True, |
| ), |
| Step( |
| label="git-init", |
| cmd=["git", "init", "-b", cfg.branch], |
| env=env, |
| predicate_step="probe-git", |
| predicate_rc_in=(1, 128), |
| ), |
| Step( |
| label="probe-repo", |
| cmd=["gh", "repo", "view", cfg.repo], |
| env=env, |
| allow_failure=True, |
| ), |
| Step( |
| label="gh-create", |
| cmd=["gh", "repo", "create", cfg.repo, "--public", |
| "--source=.", "--remote=origin"], |
| env=env, |
| predicate_step="probe-repo", |
| predicate_rc_in=(1,), |
| ), |
| Step( |
| label="probe-remote", |
| cmd=["git", "remote", "get-url", "origin"], |
| env=env, |
| allow_failure=True, |
| capture_stdout=True, |
| ), |
| Step( |
| |
| |
| |
| label="git-remote-add", |
| cmd=["git", "remote", "add", "origin", repo_url], |
| env=env, |
| predicate_step="probe-remote", |
| predicate_rc_in=(1, 2, 128), |
| ), |
| Step(label="git-add", cmd=["git", "add", "-A"], env=env), |
| Step( |
| label="probe-stage", |
| cmd=["git", "diff", "--cached", "--quiet"], |
| env=env, |
| allow_failure=True, |
| ), |
| Step( |
| label="git-commit", |
| cmd=[ |
| "git", |
| "-c", f"user.email={cfg.author_email}", |
| "-c", f"user.name={cfg.author_name}", |
| "commit", "-m", commit_message, |
| ], |
| env=env, |
| predicate_step="probe-stage", |
| predicate_rc_in=(1,), |
| ), |
| Step(label="git-push", cmd=push_cmd, env=env), |
| Step( |
| label="head-sha", |
| cmd=["git", "rev-parse", "HEAD"], |
| env=env, |
| capture_stdout=True, |
| ), |
| ] |
|
|
|
|
| def remote_url(repo: str) -> str: |
| return f"https://github.com/{repo}.git" |
|
|
|
|
| def write_sha_file(out_dir: Path, sha: str) -> Path: |
| """Persist the captured HEAD SHA so emit_receipt callers can pin |
| `manifest.git_sha` without re-shelling git.""" |
| out_dir.mkdir(parents=True, exist_ok=True) |
| target = out_dir / "git_sha.txt" |
| target.write_text(sha.strip() + "\n") |
| return target |
|
|
|
|
| __all__ = [ |
| "GithubConfig", |
| "Step", |
| "bootstrap_steps", |
| "missing_env", |
| "remote_url", |
| "required_env", |
| "status_missing", |
| "status_target", |
| "write_sha_file", |
| ] |
|
|