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/scripts/review-check.sh b/.gitea/scripts/review-check.sh index ef238e36..5bc00448 100755 --- a/.gitea/scripts/review-check.sh +++ b/.gitea/scripts/review-check.sh @@ -145,7 +145,7 @@ if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then fi # --- RFC#324 §N/A follow-up: check N/A declarations status --- -# sop-checklist-gate.py posts `sop-checklist / na-declarations (pull_request)` +# sop-checklist.py posts `sop-checklist / na-declarations (pull_request)` # status when a peer posts /sop-n/a . If our gate is declared N/A, # the requirement for a Gitea APPROVE review is waived. NA_STATUSES_TMP=$(mktemp) diff --git a/.gitea/scripts/sop-checklist-gate.py b/.gitea/scripts/sop-checklist.py similarity index 99% rename from .gitea/scripts/sop-checklist-gate.py rename to .gitea/scripts/sop-checklist.py index 995fbc7b..2b76911a 100644 --- a/.gitea/scripts/sop-checklist-gate.py +++ b/.gitea/scripts/sop-checklist.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -# sop-checklist-gate — evaluate whether a PR has peer-acked each +# sop-checklist — evaluate whether a PR has peer-acked each # SOP-checklist item. Posts a commit-status that branch protection # can require. # # RFC#351 Step 2 of 6 (implementation MVP). # -# Invoked by .gitea/workflows/sop-checklist-gate.yml on: +# Invoked by .gitea/workflows/sop-checklist.yml on: # - pull_request_target: [opened, edited, synchronize, reopened] # - issue_comment: [created, edited, deleted] # diff --git a/.gitea/scripts/tests/test_sop_checklist_gate.py b/.gitea/scripts/tests/test_sop_checklist.py similarity index 98% rename from .gitea/scripts/tests/test_sop_checklist_gate.py rename to .gitea/scripts/tests/test_sop_checklist.py index 47ae4f23..24fbc54c 100644 --- a/.gitea/scripts/tests/test_sop_checklist_gate.py +++ b/.gitea/scripts/tests/test_sop_checklist.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -# Unit tests for sop-checklist-gate.py +# Unit tests for sop-checklist.py # -# Run: python3 .gitea/scripts/tests/test_sop_checklist_gate.py -# or: pytest .gitea/scripts/tests/test_sop_checklist_gate.py +# Run: python3 .gitea/scripts/tests/test_sop_checklist.py +# or: pytest .gitea/scripts/tests/test_sop_checklist.py # # RFC#351 Step 2 of 6 — implementation MVP. Tests cover: # - slug normalization (the 4 example variants in the script header) @@ -33,7 +33,7 @@ sys.path.insert(0, PARENT) import importlib.util # noqa: E402 _spec = importlib.util.spec_from_file_location( - "sop_checklist_gate", os.path.join(PARENT, "sop-checklist-gate.py") + "sop_checklist", os.path.join(PARENT, "sop-checklist.py") ) sop = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(sop) # type: ignore[union-attr] diff --git a/.gitea/sop-checklist-config.yaml b/.gitea/sop-checklist-config.yaml index 3b61605d..346d231f 100644 --- a/.gitea/sop-checklist-config.yaml +++ b/.gitea/sop-checklist-config.yaml @@ -111,7 +111,7 @@ items: # N/A gate declarations (RFC#324 §N/A follow-up). # PRs where a gate genuinely does not apply (e.g., pure-infra with no # qa surface, or docs-only) can be declared N/A by a non-author peer -# who is in one of the gate's required_teams. The sop-checklist-gate +# who is in one of the gate's required_teams. The sop-checklist # posts a `sop-checklist / na-declarations (pull_request)` status that # review-check.sh reads to skip the Gitea-APPROVE requirement. # 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/.gitea/workflows/review-refire-comments.yml b/.gitea/workflows/review-refire-comments.yml index 97eb1371..c799c442 100644 --- a/.gitea/workflows/review-refire-comments.yml +++ b/.gitea/workflows/review-refire-comments.yml @@ -2,7 +2,7 @@ # # Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before # evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when -# qa-review, security-review, sop-checklist-gate, and sop-tier-refire all +# qa-review, security-review, sop-checklist, and sop-tier-refire all # listened to comments. This workflow is the single non-SOP comment subscriber: # ordinary comments no-op quickly; slash commands post the required status # contexts to the PR head SHA. diff --git a/.gitea/workflows/sop-checklist-gate.yml b/.gitea/workflows/sop-checklist.yml similarity index 91% rename from .gitea/workflows/sop-checklist-gate.yml rename to .gitea/workflows/sop-checklist.yml index 3fd3ba81..fe86219f 100644 --- a/.gitea/workflows/sop-checklist-gate.yml +++ b/.gitea/workflows/sop-checklist.yml @@ -1,4 +1,4 @@ -# sop-checklist-gate — peer-ack merge gate for SOP-checklist items. +# sop-checklist — peer-ack merge gate for SOP-checklist items. # # RFC#351 Step 2 of 6 (implementation MVP). # @@ -65,7 +65,15 @@ # membership, compute, post status). Re-running on any event is safe — # the new status overwrites the previous one for the same context. -name: sop-checklist-gate +name: sop-checklist + +# Cancel any in-progress runs for the same PR to prevent +# stale runs from overwriting newer status contexts. +concurrency: + group: ${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request) on: pull_request_target: @@ -83,7 +91,7 @@ permissions: statuses: write jobs: - gate: + all-items-acked: # Run on pull_request_target events always. On issue_comment events, # only when the comment is on a PR (issue_comment fires for issues # too) and the body contains one of the slash-commands. @@ -106,7 +114,7 @@ jobs: # qa-review.yml so the script source is always trusted. ref: ${{ github.event.repository.default_branch }} - - name: Run sop-checklist-gate + - name: Run sop-checklist env: GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} @@ -114,7 +122,7 @@ jobs: REPO_NAME: ${{ github.event.repository.name }} run: | set -euo pipefail - python3 .gitea/scripts/sop-checklist-gate.py \ + python3 .gitea/scripts/sop-checklist.py \ --owner "$OWNER" \ --repo "$REPO_NAME" \ --pr "$PR_NUMBER" \ 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.