diff --git a/.github/workflows/maint-68-sync-consumer-repos.yml b/.github/workflows/maint-68-sync-consumer-repos.yml index f3625e046..d7e05b432 100644 --- a/.github/workflows/maint-68-sync-consumer-repos.yml +++ b/.github/workflows/maint-68-sync-consumer-repos.yml @@ -116,36 +116,57 @@ jobs: base_sha="$INPUT_BASE_SHA" source_commit="$INPUT_HEAD_SHA" - if [ "$scope" = "auto" ]; then - scope="full" - if [ "$REQUESTED_PHASE" = "promote" ] && [ -n "$CANARY_EVIDENCE_JSON" ]; then - evidence_rows=$(jq -ce ' - if type == "array" then . - elif (.results | type) == "array" then .results - else error("promotion evidence must be an array or contain results") - end - ' <<<"$CANARY_EVIDENCE_JSON") - scope=$(jq -er ' - [.[].plan_scope // "full"] | unique | - if length == 1 then .[0] - else error("mixed plan scopes in canary evidence") + if [ "$REQUESTED_PHASE" = "promote" ]; then + [ -n "$CANARY_EVIDENCE_JSON" ] || { + echo "::error::Promotion requires canary evidence" + exit 1 + } + evidence_rows=$(jq -ce ' + if type == "array" then . + elif (.results | type) == "array" then .results + else error("promotion evidence must be an array or contain results") + end + ' <<<"$CANARY_EVIDENCE_JSON") + evidence_scope=$(jq -er ' + [.[].plan_scope // "full"] | unique | + if length == 1 then .[0] + else error("mixed plan scopes in canary evidence") + end + ' <<<"$evidence_rows") + if [ "$scope" = "auto" ]; then + scope="$evidence_scope" + elif [ "$scope" != "$evidence_scope" ]; then + echo "::error::Requested scope $scope does not match promotion evidence scope $evidence_scope" + exit 1 + fi + + # Promotion evidence is authoritative for every plan scope. Falling + # back to the dispatch workflow's GITHUB_SHA can mix a later main + # commit into an already-authorized full-plan delivery. + source_commit=$(jq -er ' + [.[].source_commit // ""] | unique | + if length == 1 and .[0] != "" then .[0] + else error("missing or mixed promotion source commit") + end + ' <<<"$evidence_rows") + + if [ "$scope" = "source-delta" ]; then + base_sha=$(jq -er ' + [.[].scope_base_sha // ""] | unique | + if length == 1 and .[0] != "" then .[0] + else error("missing or mixed source-delta base SHA") end ' <<<"$evidence_rows") - if [ "$scope" = "source-delta" ]; then - base_sha=$(jq -er ' - [.[].scope_base_sha // ""] | unique | - if length == 1 and .[0] != "" then .[0] - else error("missing or mixed source-delta base SHA") - end - ' <<<"$evidence_rows") - source_commit=$(jq -er ' - [.[].source_commit // ""] | unique | - if length == 1 and .[0] != "" then .[0] - else error("missing or mixed source-delta head SHA") - end - ' <<<"$evidence_rows") - fi + else + jq -e 'all(.[]; (.scope_base_sha // "") == "")' \ + <<<"$evidence_rows" >/dev/null || { + echo "::error::Full-plan promotion evidence must not contain a source-range base" + exit 1 + } + base_sha="" fi + elif [ "$scope" = "auto" ]; then + scope="full" fi case "$scope" in @@ -177,8 +198,8 @@ jobs: exit 1 } fi - source_commit="${source_commit,,}" - base_sha="${base_sha,,}" + source_commit=$(printf '%s' "$source_commit" | tr '[:upper:]' '[:lower:]') + base_sha=$(printf '%s' "$base_sha" | tr '[:upper:]' '[:lower:]') { echo "plan_scope=$scope" echo "scope_base_sha=$base_sha" diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 2b2466552..1d7bd132f 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -59,7 +59,7 @@ _Inline Gate helper_ - **`maint-62-integration-consumer.yml`** — Nightly + release-triggered integration tests that reuse `reusable-10-ci-python.yml` across multiple matrices and file/resolve the `integration-test` issue via the load-balanced API client (no extra app mint). - **`maint-65-sync-label-docs.yml`** — Syncs `docs/LABELS.md` into every registered consumer repo (plus the integration tests repo) when the source doc changes or on demand, using the shared registered-repo helper and PAT gating for cross-repo pushes. - **`maint-66-monthly-audit.yml`** — First-of-month workflow that gathers workflow-run stats, runs the API wrapper guard, and files/updates the monthly audit issue; relies on the shared API client so no extra npm installs or App-token mints are needed. -- **`maint-68-sync-consumer-repos.yml`** — Daily/manual manifest-driven consumer sync that validates template/scripts, hashes the template set, records a prospective per-repo matrix, and opens stable sync PRs. Scheduled reconciliation uses the full manifest; bounded source repairs may instead select only manifest entries changed across an exact base/head range, including transitive `requires` targets declared by the typed manifest. Promotion reconstructs that immutable source-delta scope from Maint 71 evidence so later `main` drift cannot join the delivery. Empty source deltas stop before consumer fan-out, while manifest changes fail closed to full scope. Normal runs are fail-closed to the configured canaries, and explicit repo filters may only narrow that canary set. Candidate corrections refresh `sync/workflows-candidate`; a later `promote` run requires same-plan, green, review-clear Maint 71 evidence and refreshes `sync/workflows-delivery` in each non-canary. Every successful write wave dispatches the matching Maint 71 candidate or campaign selector, so the generated lane does not depend on a human handoff. Before an actual head mutation, Maint 68 disables auto-merge, restores draft state, and applies the staging hold; an unchanged base/tree preserves the existing review lifecycle. Each consumer job mints a repository-scoped Workflows App token and creates the exact staged Git tree through GitHub's Git database API without custom author/committer fields, so GitHub signs the commit. Tree or signature mismatches fail before the delivery branch is published. Release publication is not a second sync trigger. +- **`maint-68-sync-consumer-repos.yml`** — Daily/manual manifest-driven consumer sync that validates template/scripts, hashes the template set, records a prospective per-repo matrix, and opens stable sync PRs. Scheduled reconciliation uses the full manifest; bounded source repairs may instead select only manifest entries changed across an exact base/head range, including transitive `requires` targets declared by the typed manifest. Promotion reconstructs the exact source commit for every plan scope, plus the immutable base for source-delta plans, from Maint 71 evidence so later `main` drift cannot join the delivery. Empty source deltas stop before consumer fan-out, while manifest changes fail closed to full scope. Normal runs are fail-closed to the configured canaries, and explicit repo filters may only narrow that canary set. Candidate corrections refresh `sync/workflows-candidate`; a later `promote` run requires same-plan, green, review-clear Maint 71 evidence and refreshes `sync/workflows-delivery` in each non-canary. Every successful write wave dispatches the matching Maint 71 candidate or campaign selector, so the generated lane does not depend on a human handoff. Before an actual head mutation, Maint 68 disables auto-merge, restores draft state, and applies the staging hold; an unchanged base/tree preserves the existing review lifecycle. Each consumer job mints a repository-scoped Workflows App token and creates the exact staged Git tree through GitHub's Git database API without custom author/committer fields, so GitHub signs the commit. Tree or signature mismatches fail before the delivery branch is published. Release publication is not a second sync trigger. - **`maint-69-sync-integration-repo.yml`** — Keeps Workflows-Integration-Tests aligned with `templates/integration-repo/`, regenerates `requirements.lock`, and pushes updates using PATs; no GitHub App token mint is required because the workflow stays inside the two repos. - **`maint-69-sync-labels.yml`** — Propagates the canonical `.github/labels-core.yml` set to every registered consumer repo (or a provided subset), reusing the registered-repo helper + load-balanced API client without any additional App-token minting. - **`maint-70-fix-integration-formatting.yml`** — Manual formatter for Workflows-Integration-Tests that resolves the repo default branch, applies `black`+`ruff` fixes, and pushes via PAT only when a token is available; runs read-only otherwise. diff --git a/docs/ci/WORKFLOWS.md b/docs/ci/WORKFLOWS.md index f585e7944..c1d8b0b0b 100644 --- a/docs/ci/WORKFLOWS.md +++ b/docs/ci/WORKFLOWS.md @@ -202,7 +202,7 @@ Scheduled health jobs keep the automation ecosystem aligned: * [`health-78-backplane-contract.yml`](../../.github/workflows/health-78-backplane-contract.yml) Workflows-internal gate that runs on PRs touching the run-contract/v1 contract set (schemas, registry, validator, fixtures): asserts the three schemas load as valid draft 2020-12 JSON Schema, `config/backplane_participants.json` keeps the required shape, and the bundled valid/invalid fixtures behave (the validator self-smoke). * [`health-83-dependency-sync-efficiency.yml`](../../.github/workflows/health-83-dependency-sync-efficiency.yml) publishes a weekly, fixture-backed advisory report for dependency-bot, consumer-sync, and dev-tool-sync maintenance and also runs once for each completed immutable sync plan. It completely paginates the trailing reporting window and measures stable-delivery force pushes, draft/ready cycles, reviewer events, and review-to-seal convergence; all-time history remains explicitly incomplete. The dedicated efficiency tracker (`#2897`) changes only when the material-evidence fingerprint changes. * [`health-84-langsmith-observability.yml`](../../.github/workflows/health-84-langsmith-observability.yml) independently monitors LangSmith dashboard/conformance cadence, cloud trace freshness, and intentional pause review dates. It upserts one durable health issue and adds `needs-human` plus `agent:needs-attention` while degraded (daily schedule, manual dispatch). -* [`maint-68-sync-consumer-repos.yml`](../../.github/workflows/maint-68-sync-consumer-repos.yml) coalesces workflow-template updates into stable `sync/workflows-candidate` and `sync/workflows-delivery` PRs. Scheduled reconciliation uses the full typed manifest; a bounded source repair may use an exact base/head source-delta plan, whose immutable scope and transitive manifest-declared `requires` targets are carried through Maint 71 canary evidence into promotion. Manifest edits require full scope. Explicit repo filters cannot broaden the canary phase; non-canaries are written only by a plan-bound `promote` run carrying green, review-clear Maint 71 evidence. A successful candidate write wave dispatches the Maint 71 candidate selector; promotion dispatches the fleet campaign selector. Actual head changes first restore draft/staging holds, while exact base/tree no-ops preserve the current review lifecycle. Mutating jobs use a repository-scoped Workflows App token to create GitHub-verified commits and fail before publication if the API result is unsigned or its tree differs from the staged tree. +* [`maint-68-sync-consumer-repos.yml`](../../.github/workflows/maint-68-sync-consumer-repos.yml) coalesces workflow-template updates into stable `sync/workflows-candidate` and `sync/workflows-delivery` PRs. Scheduled reconciliation uses the full typed manifest; a bounded source repair may use an exact base/head source-delta plan, whose immutable scope and transitive manifest-declared `requires` targets are carried through Maint 71 canary evidence into promotion. Every promotion reconstructs its exact source commit from that evidence; source-delta promotion also reconstructs the exact base, so a later `main` commit cannot join either plan scope. Manifest edits require full scope. Explicit repo filters cannot broaden the canary phase; non-canaries are written only by a plan-bound `promote` run carrying green, review-clear Maint 71 evidence. A successful candidate write wave dispatches the Maint 71 candidate selector; promotion dispatches the fleet campaign selector. Actual head changes first restore draft/staging holds, while exact base/tree no-ops preserve the current review lifecycle. Mutating jobs use a repository-scoped Workflows App token to create GitHub-verified commits and fail before publication if the API result is unsigned or its tree differs from the staged tree. * [`maint-69-sync-integration-repo.yml`](../../.github/workflows/maint-69-sync-integration-repo.yml) syncs integration-repo templates to Workflows-Integration-Tests repository (template push, manual dispatch with dry-run support). * [`maint-69-sync-labels.yml`](../../.github/workflows/maint-69-sync-labels.yml) syncs core functional labels from labels-core.yml to consumer repos (push to labels-core.yml, manual dispatch with dry-run support). * [`maint-70-fix-integration-formatting.yml`](../../.github/workflows/maint-70-fix-integration-formatting.yml) applies Black and Ruff formatting fixes to Integration-Tests repository files (manual dispatch for CI formatting failures). diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index 143043e26..395704a84 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -320,11 +320,13 @@ gh workflow run maint-68-sync-consumer-repos.yml \ -f scope_head_sha= ``` -Maint 71 writes that same scope and immutable range into every canary evidence -row. `phase=promote` with `delivery_scope=auto` recovers the exact range from -the evidence, checks out that historical source head, and recompiles the same -scoped plan. A later commit on `main` therefore cannot silently join an -authorized delivery. Do not substitute a moving branch name for either SHA. +Maint 71 writes that same scope, source commit, and immutable range into every +canary evidence row. Every `phase=promote` run recovers the exact source commit +from the evidence; a source-delta promotion also recovers its exact base. The +workflow checks out that historical source head and recompiles the same plan. +A later commit on `main` therefore cannot silently join an authorized delivery, +including a full-plan promotion. Do not substitute a moving branch name for +either source-delta SHA. Before any checked-out source script runs, Maint 68 requires the resolved source commit to be an ancestor of the workflow dispatch ref and the scope base to be an ancestor of that source. New source-delta delivery commits also bind the full diff --git a/tests/workflows/test_sync_manifest_delivery.py b/tests/workflows/test_sync_manifest_delivery.py index 4cdc85681..6937f81c9 100644 --- a/tests/workflows/test_sync_manifest_delivery.py +++ b/tests/workflows/test_sync_manifest_delivery.py @@ -29,7 +29,10 @@ from __future__ import annotations import json +import os +import subprocess from pathlib import Path +from typing import Any import yaml @@ -38,6 +41,11 @@ SYNC_WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "maint-68-sync-consumer-repos.yml" REUSABLE_AUTOFIX_PATH = REPO_ROOT / ".github" / "workflows" / "reusable-18-autofix.yml" DRIFT_CHECK_PATH = REPO_ROOT / "scripts" / "check_consumer_sync_drift.py" +CANARY_REPOS = ( + "stranske/Travel-Plan-Permission", + "stranske/trip-planner", + "stranske/Portable-Alpha-Extension-Model", +) # Manifest sections whose entries are physically copied into consumer repos by # maint-68-sync-consumer-repos.yml. Kept in lockstep with that workflow's @@ -90,6 +98,68 @@ def _runtime_sources(manifest: dict) -> set[str]: return sources +def _run_scope_resolution( + tmp_path: Path, + *, + evidence: list[dict[str, Any]] | None, + requested_scope: str = "auto", + input_base_sha: str = "", + input_head_sha: str = "", + github_sha: str = "b" * 40, +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + workflow = yaml.safe_load(SYNC_WORKFLOW_PATH.read_text(encoding="utf-8")) + step = next( + item + for item in workflow["jobs"]["prepare"]["steps"] + if item.get("name") == "Resolve immutable plan scope" + ) + output_path = tmp_path / "github-output" + env = os.environ.copy() + env.update( + { + "REQUESTED_SCOPE": requested_scope, + "INPUT_BASE_SHA": input_base_sha, + "INPUT_HEAD_SHA": input_head_sha, + "REQUESTED_PHASE": "promote", + "CANARY_EVIDENCE_JSON": "" if evidence is None else json.dumps(evidence), + "GITHUB_SHA": github_sha, + "GITHUB_OUTPUT": str(output_path), + } + ) + completed = subprocess.run( + ["bash", "-c", step["run"]], + check=False, + capture_output=True, + text=True, + env=env, + ) + outputs: dict[str, str] = {} + if output_path.exists(): + outputs = dict( + line.split("=", 1) for line in output_path.read_text(encoding="utf-8").splitlines() + ) + return completed, outputs + + +def _promotion_evidence( + *, + plan_scope: str = "full", + source_commits: tuple[str, str, str] | None = None, + base_shas: tuple[str, str, str] | None = None, +) -> list[dict[str, str]]: + source_commits = source_commits or ("a" * 40,) * 3 + base_shas = base_shas or ("",) * 3 + return [ + { + "repo": repo, + "plan_scope": plan_scope, + "scope_base_sha": base_shas[index], + "source_commit": source_commits[index], + } + for index, repo in enumerate(CANARY_REPOS) + ] + + def test_no_entry_is_both_runtime_and_copy_synced() -> None: """The crux invariant: a runtime-delivered entry must not also be copy-synced.""" manifest = _load_manifest() @@ -286,6 +356,114 @@ def test_sync_fanout_is_canary_gated_and_promotion_is_plan_bound() -> None: assert 2 <= len(config["canaries"]) <= 3 +def test_full_plan_promotion_preserves_evidence_source_commit(tmp_path: Path) -> None: + """A later workflow ref must not replace the source authorized by canaries.""" + canary_source = "a" * 40 + later_main = "b" * 40 + completed, outputs = _run_scope_resolution( + tmp_path, + evidence=_promotion_evidence(), + github_sha=later_main, + ) + + assert completed.returncode == 0, completed.stderr + assert outputs == { + "plan_scope": "full", + "scope_base_sha": "", + "source_commit": canary_source, + } + + +def test_source_delta_promotion_preserves_evidence_range(tmp_path: Path) -> None: + source_commit = "a" * 40 + base_sha = "c" * 40 + completed, outputs = _run_scope_resolution( + tmp_path, + evidence=_promotion_evidence( + plan_scope="source-delta", + source_commits=(source_commit,) * 3, + base_shas=(base_sha,) * 3, + ), + ) + + assert completed.returncode == 0, completed.stderr + assert outputs == { + "plan_scope": "source-delta", + "scope_base_sha": base_sha, + "source_commit": source_commit, + } + + +def test_promotion_without_canary_evidence_fails_closed(tmp_path: Path) -> None: + completed, outputs = _run_scope_resolution(tmp_path, evidence=None) + + assert completed.returncode != 0 + assert "Promotion requires canary evidence" in completed.stdout + assert outputs == {} + + +def test_promotion_rejects_explicit_scope_conflict(tmp_path: Path) -> None: + completed, outputs = _run_scope_resolution( + tmp_path, + evidence=_promotion_evidence(), + requested_scope="source-delta", + ) + + assert completed.returncode != 0 + assert "does not match promotion evidence scope" in completed.stdout + assert outputs == {} + + +def test_promotion_rejects_missing_or_mixed_source_commits(tmp_path: Path) -> None: + missing, missing_outputs = _run_scope_resolution( + tmp_path / "missing", + evidence=_promotion_evidence(source_commits=("",) * 3), + ) + mixed, mixed_outputs = _run_scope_resolution( + tmp_path / "mixed", + evidence=_promotion_evidence(source_commits=("a" * 40, "d" * 40, "a" * 40)), + ) + + assert missing.returncode != 0 + assert "missing or mixed promotion source commit" in missing.stderr + assert missing_outputs == {} + assert mixed.returncode != 0 + assert "missing or mixed promotion source commit" in mixed.stderr + assert mixed_outputs == {} + + +def test_source_delta_promotion_rejects_missing_or_mixed_bases(tmp_path: Path) -> None: + missing, missing_outputs = _run_scope_resolution( + tmp_path / "missing", + evidence=_promotion_evidence(plan_scope="source-delta"), + ) + mixed, mixed_outputs = _run_scope_resolution( + tmp_path / "mixed", + evidence=_promotion_evidence( + plan_scope="source-delta", + base_shas=("c" * 40, "e" * 40, "c" * 40), + ), + ) + + assert missing.returncode != 0 + assert "missing or mixed source-delta base SHA" in missing.stderr + assert missing_outputs == {} + assert mixed.returncode != 0 + assert "missing or mixed source-delta base SHA" in mixed.stderr + assert mixed_outputs == {} + + +def test_full_plan_promotion_rejects_source_range_base(tmp_path: Path) -> None: + completed, outputs = _run_scope_resolution( + tmp_path, + evidence=_promotion_evidence(base_shas=("c" * 40,) * 3), + ) + + assert completed.returncode != 0 + assert "Full-plan promotion evidence must not contain a source-range base" in completed.stdout + assert outputs == {} + + def test_maint_71_emits_canary_evidence_with_review_debt() -> None: # Canary evidence fields live in the externalized Maint 71 executor JS; # the workflow only uploads the artifact path.