From 737d02090446722e172f45ce6394a63d3b080098 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Sun, 1 Nov 2026 17:38:45 +0800 Subject: [PATCH] [node-maintenance-repair/8.3] Publish the validated cumulative report with an exact branch lease Publish the exact original prepared two-file candidate only after separate validation succeeds. Verify its native producer Check seal, immutable preparation artifact and cumulative history, then recheck canonical consent and push with an exact expected-head lease. Bound the post-push reread of an exact previous PR view without retrying the push; stop immediately on unexpected PR identity, branch or content changes. Retain original preparation locators in the native repair commit. Use only sealed cumulative package evidence for the PR report, preserve the existing LF/CRLF platform section, and bind new choices to the published head. Report uncertain pushes and push-success/body-edit-failure truthfully without retries or overwriting a changed PR. The writer runs no project scripts, package-manager commands or fresh audit and consumes no validation artifacts. Normal-audit preservation and full live repair acceptance remain slice9; no source merge, Hydra change or service deployment is included. Validation: 1,620 Python tests, Ruff lint/format, Pyrefly and Bash syntax passed. Real pnpm11.20.0/11.21.0 hook and separate-validation checks passed on unchanged preparation/validation paths. Fresh independent review and its reproduced report-boundary regression are clean. Complete production growth is357/600 lines without exclusions. Exact-head PR review, CI and hosted publication acceptance follow. --- .github/workflows/node-repair.yml | 32 +- docs/node-maintenance-operator.md | 45 ++- node/repair/action.yml | 32 +- node/runtime/repair.py | 293 ++++++++++++++- node/tests/test_selected_repairs.py | 536 +++++++++++++++++++++++++++- repair-app/pilot/README.md | 47 ++- 6 files changed, 945 insertions(+), 40 deletions(-) diff --git a/.github/workflows/node-repair.yml b/.github/workflows/node-repair.yml index 974a69f..76f5a37 100644 --- a/.github/workflows/node-repair.yml +++ b/.github/workflows/node-repair.yml @@ -19,7 +19,7 @@ name: Validate selected Node repairs required: true outputs: candidate-artifact-id: - description: Immutable preparation artifact ID in this run; validation never replaces it. No publication occurred. + description: Immutable preparation artifact ID in this run; validation and publication never replace it. value: ${{ jobs.prepare.outputs.candidate-artifact-id }} permissions: contents: read @@ -102,3 +102,33 @@ jobs: set -euo pipefail [[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ && "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ && "$CONTENT_SHA256" =~ ^[a-f0-9]{64}$ ]] printf '::notice title=Node repair evidence v1::v1:%s:%s:%s\n' "$ARTIFACT_ID" "$ARCHIVE_SHA256" "$CONTENT_SHA256" + publish: + needs: [prepare, validate] + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: node-dependency-proposal-${{ github.repository }} + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + actions: read + checks: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.expected-head }} + persist-credentials: false + fetch-depth: 0 + - name: Publish the validated sealed candidate + env: + TRUSTED_WORKFLOW_SHA: ${{ job.workflow_sha }} + uses: $/node/repair + with: + operation: publish + candidate-artifact-id: ${{ needs.prepare.outputs.candidate-artifact-id }} + request-id: ${{ inputs.request-id }} + pull-request: ${{ inputs.pull-request }} + expected-head: ${{ inputs.expected-head }} + expected-base: ${{ inputs.expected-base }} + selections: ${{ inputs.selections }} diff --git a/docs/node-maintenance-operator.md b/docs/node-maintenance-operator.md index ac5d671..64b0b2e 100644 --- a/docs/node-maintenance-operator.md +++ b/docs/node-maintenance-operator.md @@ -173,10 +173,9 @@ The separate `node-repair-validation-` artifact contains human evidence only: install/check logs, the fresh audit and a validation result. It never replaces the preparation artifact. Project code runs with the repository write maintainer as its trust principal; validation cannot attest -intentionally dishonest tests. A later publisher must use the original -preparation artifact and actual validation job success, never validation-local -patches, reports or logs. Publication and normal-audit retention remain later -slices, followed by the isolated live repair gate and general rollout. +intentionally dishonest tests. The publisher uses the original preparation artifact and actual validation job +success, never validation-local patches, reports or logs. Normal-audit retention +and the full isolated live repair gate remain required before general rollout. ## Check the receiver locally @@ -319,3 +318,41 @@ This checkpoint prepares cumulative evidence. It does not publish dependency changes or authorize merging. The later publisher must retain the validated repair marker and exact native `Node-Repair-*` locator trailers for another read-only attempt; producer job outputs alone do not persist across runs. + +## Publish the validated selected repair + +The reusable repair workflow now has a fresh publisher after successful +preparation and validation. Only that job receives Contents/Pull requests write +permission; its caller must grant those scopes plus Actions/Checks read. It +downloads the original three-member preparation artifact by its exact ID and +verifies the producer job, Check seal, immutable archive/content digests, source, +request, project and exact candidate dependency state. Missing cumulative history +or stale evidence stops before a branch push. Project scripts and package-manager +commands never run on this writer. + +Immediately before pushing, it rechecks the canonical PR, selected batch and +head/base. The native push uses an explicit expected-head lease. Its commit +contains `Node-Repair: validated-selected-pins-v1` plus the original preparation +run/job/Check/artifact IDs, archive/content digests and trusted workflow SHA. +These trailers locate the immutable cumulative report for the next attempt; +editable PR prose is never historical audit input. + +The PR body uses the sealed cumulative rows and verified candidate choices, +adds the actual successful-validation run link and binds choices to the new +head/base. It preserves the existing recognized platform section byte-for-byte, +including CRLF and per-release CVE links. A missing or ambiguous platform section +or an oversized body stops before push; inspect or replace an old proposal +through the normal audit flow rather than dropping evidence. + +The push and PR-body edit are separate operations. A successful push followed +by an edit failure leaves the repair commit published and reports the incomplete +body update with its commit ID. After push, the writer rereads only an exact +unchanged pre-push PR view up to four times, with waits of one, two and four +seconds. It never repeats the push. An unexpected head or changed base, body, +open state, branch or repository stops immediately; an old view at the limit also +leaves an explicit partial result. The report is edited only after the expected +published head and unchanged remaining PR state are observed. +An uncertain push reports the attempted commit +and requires branch inspection before another attempt. Nothing merges +automatically. Normal-audit preservation and full live repair acceptance remain +required before general rollout. diff --git a/node/repair/action.yml b/node/repair/action.yml index 7686664..73f09a8 100644 --- a/node/repair/action.yml +++ b/node/repair/action.yml @@ -1,8 +1,8 @@ name: Node repair candidate -description: Guard, prepare or validate a selected pnpm candidate without publication. +description: Guard, prepare, validate and publish the exact selected pnpm candidate. inputs: operation: - description: guard, prepare or validate. + description: guard, prepare, validate or publish. required: true request-id: required: true @@ -21,7 +21,7 @@ inputs: description: Canonical selected package/version triples. candidate-artifact-id: required: false - description: Single immutable preparation artifact ID from this run, required for validate. + description: Single immutable preparation artifact ID from this run, required for validate and publish. outputs: candidate-artifact-id: description: Immutable preparation artifact ID from this run. @@ -41,18 +41,18 @@ runs: OPERATION: ${{ inputs.operation }} run: | case "$OPERATION" in - guard|prepare|validate) ;; - *) echo '::error::Expected guard, prepare or validate'; exit 1 ;; + guard|prepare|validate|publish) ;; + *) echo '::error::Expected guard, prepare, validate or publish'; exit 1 ;; esac - name: Validate the artifact ID - if: ${{ inputs.operation == 'validate' }} + if: ${{ inputs.operation == 'validate' || inputs.operation == 'publish' }} shell: bash env: ARTIFACT_ID: ${{ inputs.candidate-artifact-id }} run: | [[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || { echo '::error::Expected one preparation artifact ID'; exit 1; } - name: Download only the preparation artifact from this run - if: ${{ inputs.operation == 'validate' }} + if: ${{ inputs.operation == 'validate' || inputs.operation == 'publish' }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 with: artifact-ids: ${{ inputs.candidate-artifact-id }} @@ -76,18 +76,18 @@ runs: REPAIR_SELECTIONS: ${{ inputs.selections }} run: python "$GITHUB_ACTION_PATH/../runtime/repair.py" guard - name: Resolve the declared preparation toolchain - if: ${{ inputs.operation != 'guard' }} + if: ${{ inputs.operation == 'prepare' || inputs.operation == 'validate' }} shell: bash run: | set -euo pipefail python "$GITHUB_ACTION_PATH/../runtime/project.py" --github-env "$GITHUB_ENV" --resolved-config "$RUNNER_TEMP/node-repair-config.json" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - if: ${{ inputs.operation != 'guard' }} + if: ${{ inputs.operation == 'prepare' || inputs.operation == 'validate' }} with: node-version: ${{ env.NODE_AUDIT_NODE_VERSION }} check-latest: true - name: Enable the declared package manager - if: ${{ inputs.operation != 'guard' }} + if: ${{ inputs.operation == 'prepare' || inputs.operation == 'validate' }} shell: bash run: corepack enable - name: Prepare and seal the selected candidate @@ -137,3 +137,15 @@ runs: name: node-repair-validation-${{ inputs.request-id }} if-no-files-found: warn path: ${{ runner.temp }}/node-repair-validation/ + - name: Publish only the original validated candidate + if: ${{ inputs.operation == 'publish' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPAIR_REQUEST: ${{ inputs.request-id }} + REPAIR_PR: ${{ inputs.pull-request }} + REPAIR_HEAD: ${{ inputs.expected-head }} + REPAIR_BASE: ${{ inputs.expected-base }} + REPAIR_SELECTIONS: ${{ inputs.selections }} + REPAIR_ARTIFACT_ID: ${{ inputs.candidate-artifact-id }} + run: python "$GITHUB_ACTION_PATH/../runtime/repair.py" publish --artifact "$RUNNER_TEMP/node-repair-candidate" diff --git a/node/runtime/repair.py b/node/runtime/repair.py index 7e74470..4dcc28b 100644 --- a/node/runtime/repair.py +++ b/node/runtime/repair.py @@ -1,4 +1,4 @@ -"""Prepare a sealed pnpm candidate and validate it without publication.""" +"""Prepare, validate and publish exactly one sealed selected pnpm candidate.""" import argparse import base64 @@ -73,8 +73,13 @@ def request(): ) -def gh(path): - return json.loads(subprocess.check_output(["gh", "api", path], text=True)) +def gh(path, *, body=None): + arguments = ["gh", "api", path] + options = {} + if body is not None: + arguments += ["--method", "PATCH", "--input", "-"] + options["input"] = json.dumps(body) + return json.loads(subprocess.check_output(arguments, text=True, **options)) def git(*args, **kwargs): @@ -141,7 +146,7 @@ def project_path(root, name): return relative.as_posix() -def project_context(): +def project_context(*, verify_runtime=True): root = Path.cwd().resolve() config_path = ( Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "node-repair-config.json" @@ -151,7 +156,8 @@ def project_context(): name = project_path(root, configuration["directory"]) if root / name != directory or selected["manager"] != "pnpm": raise ValueError("Repair attempts require a standalone pnpm project") - PROJECT["verify_runtime"](directory, selected) + if verify_runtime: + PROJECT["verify_runtime"](directory, selected) snapshot = ENGINE["valid_snapshot"](directory) if snapshot is None: raise ValueError("Invalid project snapshot") @@ -1129,6 +1135,271 @@ def cumulative_report( return report +def publication_locator(expected): + """Resolve the exact original preparation notice in this workflow attempt.""" + run, attempt, artifact = ( + os.environ[key] + for key in ("GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", "REPAIR_ARTIFACT_ID") + ) + if any( + not re.fullmatch(r"[1-9][0-9]*", value) for value in (run, attempt, artifact) + ): + raise ValueError("Invalid preparation run, attempt or artifact ID") + repo = expected["repository"] + data = ENGINE["history_api"](repo, f"actions/runs/{run}") + if str(data.get("run_attempt")) != attempt: + raise ValueError("Preparation workflow attempt changed") + names = producer_names( + data, + repo, + "hydra-ecosystem/github-actions/.github/workflows/node-repair.yml", + os.environ["TRUSTED_WORKFLOW_SHA"], + "Node repair preparation", + ) + jobs = ENGINE["history_api"]( + repo, f"actions/runs/{run}/attempts/{attempt}/jobs?per_page=100" + ) + matches = [j for j in jobs.get("jobs", []) if j.get("name") in names] + if jobs.get("total_count", 100) >= 100 or len(matches) != 1: + raise ValueError("Missing or ambiguous preparation producer") + job = matches[0] + check = job.get("check_run_url", "").removeprefix( + f"https://api.github.com/repos/{repo}/check-runs/" + ) + if not re.fullmatch(r"[1-9][0-9]*", check): + raise ValueError("Invalid preparation Check URL") + notices = [ + a + for a in ENGINE["history_api"]( + repo, f"check-runs/{check}/annotations?per_page=100" + ) + if a.get("title") == REPAIR_HISTORY_TITLE + ] + seal = ( + re.fullmatch( + r"v1:([1-9][0-9]*):([a-f0-9]{64}):([a-f0-9]{64})", + notices[0].get("message", ""), + ) + if len(notices) == 1 + else None + ) + if seal is None or seal[1] != artifact: + raise ValueError("Preparation notice does not bind the original artifact ID") + return dict( + run=int(run), + job=job["id"], + check=int(check), + artifact=int(artifact), + archive_sha256=seal[2], + content_sha256=seal[3], + ) + + +def publication_body(previous, report, expected, commit, envelope): + """Use sealed package evidence and retain the existing platform section.""" + spans = list( + re.finditer(r"(?ms)^## Platform security\r?\n.*?(?=^## |\Z)", previous) + ) + if ( + len(spans) != 1 + or len(re.findall(r"(?im)^##[^\r\n]*platform[^\r\n]*", previous)) != 1 + or not re.match(r"## Package security\r?\n", previous[spans[0].end() :]) + or "