From 7250ebbed8f7b83610554f587ac8d00ab499b2a5 Mon Sep 17 00:00:00 2001 From: hongming-codex-laptop Date: Wed, 13 May 2026 21:00:52 -0700 Subject: [PATCH] ci: fix publish docker healthcheck pipefail --- .gitea/scripts/lint-workflow-yaml.py | 50 +++++++++++++++ .../publish-workspace-server-image.yml | 4 +- tests/test_lint_workflow_yaml.py | 64 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/.gitea/scripts/lint-workflow-yaml.py b/.gitea/scripts/lint-workflow-yaml.py index 34cfefe6..bd254c90 100755 --- a/.gitea/scripts/lint-workflow-yaml.py +++ b/.gitea/scripts/lint-workflow-yaml.py @@ -36,6 +36,9 @@ Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn): raw `.error` fields into CI logs/summaries. 9. Production deploy/redeploy workflows must expose an operational control: kill switch for auto deploys or rollback tag for manual deploys. + 10. Docker health checks must not run `docker info | head` under pipefail. + `head` closes the pipe early, `docker info` can exit nonzero from + SIGPIPE, and the step can falsely report Docker daemon failure. Per `feedback_smoke_test_vendor_truth_not_shape_match`: fixtures used to validate this lint must mirror real Gitea 1.22.6 YAML semantics, not @@ -225,6 +228,24 @@ def _iter_uses(doc: Any) -> Iterable[str]: yield step["uses"] +def _iter_run_blocks(doc: Any) -> Iterable[str]: + """Yield every shell `run:` block from job steps in a workflow document.""" + if not isinstance(doc, dict): + return + jobs = doc.get("jobs") + if not isinstance(jobs, dict): + return + for job in jobs.values(): + if not isinstance(job, dict): + continue + steps = job.get("steps") + if not isinstance(steps, list): + continue + for step in steps: + if isinstance(step, dict) and isinstance(step.get("run"), str): + yield step["run"] + + def check_cross_repo_uses(filename: str, doc: Any) -> list[str]: """Return per-violation error lines for cross-repo `uses:` references.""" errors: list[str] = [] @@ -264,6 +285,10 @@ GITHUB_API_REF_RE = re.compile( PROD_CP_URL_RE = re.compile(r"https://api\.moleculesai\.app\b") REDEPLOY_FLEET_RE = re.compile(r"\b/cp/admin/tenants/redeploy-fleet\b") +RUN_SETS_PIPEFAIL_RE = re.compile(r"(?m)^\s*set\s+-[^\n]*o\s+pipefail\b") +DOCKER_INFO_HEAD_PIPE_RE = re.compile( + r"(?m)^\s*docker\s+info\b[^\n|]*\|\s*head\b" +) RAW_CP_RESPONSE_RE = re.compile( r"""(?x) (?:\bjq\s+\.\s+["']?\$HTTP_RESPONSE["']?) @@ -383,6 +408,30 @@ def check_production_operational_control(filename: str, raw: str) -> list[str]: return errors +# --------------------------------------------------------------------------- +# Rule 10 — docker info piped to head under pipefail +# --------------------------------------------------------------------------- + +def check_docker_info_head_pipefail(filename: str, doc: Any) -> list[str]: + errors: list[str] = [] + for run_block in _iter_run_blocks(doc): + if not ( + RUN_SETS_PIPEFAIL_RE.search(run_block) + and DOCKER_INFO_HEAD_PIPE_RE.search(run_block) + ): + continue + errors.append( + f"::error file={filename}::Rule 10 (FATAL): workflow runs " + f"`docker info | head` after enabling `pipefail`. `head` can " + f"close the pipe early, making `docker info` exit nonzero and " + f"falsely fail the Docker daemon health check. Capture " + f"`docker_info=\"$(docker info 2>&1)\"` first, then print a " + f"bounded preview with `printf ... | sed -n '1,5p'`." + ) + break + return errors + + # --------------------------------------------------------------------------- # Driver # --------------------------------------------------------------------------- @@ -436,6 +485,7 @@ def main(argv: list[str] | None = None) -> int: fatal_errors.extend(check_production_concurrency(rel, doc, raw)) fatal_errors.extend(check_production_raw_response_logging(rel, raw)) fatal_errors.extend(check_production_operational_control(rel, raw)) + fatal_errors.extend(check_docker_info_head_pipefail(rel, doc)) warnings.extend(check_github_server_url_missing(rel, doc, raw)) # Cross-file checks diff --git a/.gitea/workflows/publish-workspace-server-image.yml b/.gitea/workflows/publish-workspace-server-image.yml index 25012dcf..02a42962 100644 --- a/.gitea/workflows/publish-workspace-server-image.yml +++ b/.gitea/workflows/publish-workspace-server-image.yml @@ -68,12 +68,14 @@ jobs: set -euo pipefail echo "::group::Docker daemon health check" echo "Runner: ${HOSTNAME:-unknown}" - docker info 2>&1 | head -5 || { + docker_info="$(docker info 2>&1)" || { echo "::error::Docker daemon is not accessible at /var/run/docker.sock" echo "::error::Runner: ${HOSTNAME:-unknown}" + printf '%s\n' "${docker_info}" echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+" exit 1 } + printf '%s\n' "${docker_info}" | sed -n '1,5p' echo "Docker daemon OK" echo "::endgroup::" diff --git a/tests/test_lint_workflow_yaml.py b/tests/test_lint_workflow_yaml.py index 4cd4b151..18b67060 100644 --- a/tests/test_lint_workflow_yaml.py +++ b/tests/test_lint_workflow_yaml.py @@ -545,6 +545,70 @@ def test_rule9_prod_manual_deploy_allows_rollback_control(tmp_path): assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" +# --------------------------------------------------------------------------- +# Rule 10 — docker info piped to head under pipefail +# --------------------------------------------------------------------------- + +DOCKER_INFO_HEAD_BAD = """ + name: docker-info-head-bad + on: [push] + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + set -euo pipefail + docker info 2>&1 | head -5 || exit 1 +""" + +DOCKER_INFO_CAPTURE_OK = """ + name: docker-info-capture-ok + on: [push] + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + set -euo pipefail + docker_info="$(docker info 2>&1)" || exit 1 + printf '%s\\n' "${docker_info}" | sed -n '1,5p' +""" + +DOCKER_INFO_SEPARATE_STEP_OK = """ + name: docker-info-separate-step-ok + on: [push] + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + set -euo pipefail + echo setup + - run: | + docker info 2>&1 | head -5 || true +""" + + +def test_rule10_docker_info_head_under_pipefail_detects_violation(tmp_path): + _write(tmp_path, "bad.yml", DOCKER_INFO_HEAD_BAD) + r = _run_lint(tmp_path) + assert r.returncode == 1 + assert "docker info" in r.stdout.lower() + assert "pipefail" in r.stdout.lower() + + +def test_rule10_docker_info_capture_passes(tmp_path): + _write(tmp_path, "ok.yml", DOCKER_INFO_CAPTURE_OK) + r = _run_lint(tmp_path) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + + +def test_rule10_docker_info_head_in_separate_step_without_pipefail_passes(tmp_path): + _write(tmp_path, "ok.yml", DOCKER_INFO_SEPARATE_STEP_OK) + r = _run_lint(tmp_path) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + + # --------------------------------------------------------------------------- # CI change detector fanout — workflow-only PRs keep required contexts without # running Go/Canvas/Python/shellcheck heavy steps.