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 "