diff --git a/.github/workflows/node-repair.yml b/.github/workflows/node-repair.yml new file mode 100644 index 0000000..b700746 --- /dev/null +++ b/.github/workflows/node-repair.yml @@ -0,0 +1,70 @@ +name: Prepare selected Node repairs +'on': + workflow_call: + inputs: + request-id: + type: string + required: true + pull-request: + type: string + required: true + expected-head: + type: string + required: true + expected-base: + type: string + required: true + selections: + type: string + required: true + outputs: + candidate-artifact-id: + description: Immutable preparation artifact ID in this run; no validation or publication occurred. + value: ${{ jobs.prepare.outputs.candidate-artifact-id }} +permissions: + contents: read +jobs: + guard: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + steps: + - name: Recheck canonical PR eligibility and selections + uses: $/node/repair + with: + operation: guard + request-id: ${{ inputs.request-id }} + pull-request: ${{ inputs.pull-request }} + expected-head: ${{ inputs.expected-head }} + expected-base: ${{ inputs.expected-base }} + selections: ${{ inputs.selections }} + prepare: + needs: guard + runs-on: ubuntu-latest + timeout-minutes: 50 + permissions: + contents: read + env: + npm_config_ignore_scripts: 'true' + pnpm_config_ignore_scripts: 'true' + npm_config_ignore_pnpmfile: 'true' + pnpm_config_ignore_pnpmfile: 'true' + outputs: + candidate-artifact-id: ${{ steps.prepare.outputs.candidate-artifact-id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.expected-head }} + persist-credentials: false + - name: Prepare the selected candidate using trusted workflow code + id: prepare + uses: $/node/repair + with: + operation: prepare + 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 f6f9f64..3a8525b 100644 --- a/docs/node-maintenance-operator.md +++ b/docs/node-maintenance-operator.md @@ -126,6 +126,41 @@ This change is local and unreleased. Before testing the reviewed revision live: No live permission, database, deployment or repository change is performed by these instructions. The local/native tests cannot replace that acceptance. +## Candidate preparation boundary + +The reusable `.github/workflows/node-repair.yml` currently enables only `guard` +and `prepare`. It accepts the existing five request inputs and returns +`candidate-artifact-id` for the immutable artifact created in that run. +It does not install a caller or enable repairs in an existing repository. + +The read-only guard fetches the canonical repository and managed PR again. +It checks bot authorship, the same repository, maintenance branch, default +branch, exact head/base, revision marker and selected versions. It does not +require the old idle Check identity after the App has admitted the request. + +Preparation checks out only the expected head without persisted credentials. +It uses the configured toolchain, existing audit engine, release cooldown and +parent/direct constraint evidence. Lifecycle and local/global pnpmfile hooks +are disabled during metadata reads, audit and lockfile-only resolution. +Unselected versions, unrelated lockfile metadata and changed peer suffixes +fail preparation. No project validation script runs in this job. + +The preparation artifact contains exactly three regular files: + +- `candidate.patch`: only the selected project's `pnpm-workspace.yaml` and + `pnpm-lock.yaml` changes, exported with external diff and text conversion off. +- `audit.json`: a version-1 envelope containing the exact request, normalized + project directory, structured before audit and candidate audit. +- `result.md`: the shared package report. It states that project validation and + dependency publication have not run. + +The envelope rejects missing/extra fields, mismatched request or project, +unsupported versions, symlinked project parents, unconfirmed selected fixes and +new findings. Later consumers must download by the returned artifact ID from +the same run. A preparation success is not proof of a validated or published +repair. Separate validation, publication and normal-audit retention remain +later slices, followed by the isolated live repair gate and general rollout. + ## Check the receiver locally Use Node 24 and run these commands from `repair-app/`: diff --git a/node/repair/action.yml b/node/repair/action.yml new file mode 100644 index 0000000..a424012 --- /dev/null +++ b/node/repair/action.yml @@ -0,0 +1,90 @@ +name: Node repair candidate +description: Guard or prepare a selected pnpm candidate without project scripts or publication. +inputs: + operation: + description: guard or prepare. + required: true + request-id: + required: true + description: App request UUID. + pull-request: + required: true + description: Managed pull request number. + expected-head: + required: true + description: Selected PR head SHA. + expected-base: + required: true + description: Selected PR base SHA. + selections: + required: true + description: Canonical selected package/version triples. +outputs: + candidate-artifact-id: + description: Immutable preparation artifact ID from this run. + value: ${{ steps.candidate.outputs.artifact-id }} +runs: + using: composite + steps: + - name: Validate the requested operation + shell: bash + env: + OPERATION: ${{ inputs.operation }} + run: | + case "$OPERATION" in + guard|prepare) ;; + *) echo '::error::Expected guard or prepare'; exit 1 ;; + esac + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.13' + - name: Install configuration and audit parser + shell: bash + run: python -I -m pip install PyYAML==6.0.3 + - name: Recheck the selected PR + if: ${{ inputs.operation == 'guard' }} + 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 }} + run: python "$GITHUB_ACTION_PATH/../runtime/repair.py" guard + - name: Resolve the declared preparation toolchain + if: ${{ inputs.operation == 'prepare' }} + 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 == 'prepare' }} + with: + node-version: ${{ env.NODE_AUDIT_NODE_VERSION }} + check-latest: true + - name: Enable the declared package manager + if: ${{ inputs.operation == 'prepare' }} + shell: bash + run: corepack enable + - name: Prepare and seal the selected candidate + if: ${{ inputs.operation == 'prepare' }} + shell: bash + env: + REPAIR_REQUEST: ${{ inputs.request-id }} + REPAIR_PR: ${{ inputs.pull-request }} + REPAIR_HEAD: ${{ inputs.expected-head }} + REPAIR_BASE: ${{ inputs.expected-base }} + REPAIR_SELECTIONS: ${{ inputs.selections }} + run: python "$GITHUB_ACTION_PATH/../runtime/repair.py" prepare --artifact "$RUNNER_TEMP/node-repair-candidate" + - name: Seal the preparation artifact + id: candidate + if: ${{ inputs.operation == 'prepare' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: node-repair-candidate-${{ inputs.request-id }} + if-no-files-found: error + path: | + ${{ runner.temp }}/node-repair-candidate/candidate.patch + ${{ runner.temp }}/node-repair-candidate/audit.json + ${{ runner.temp }}/node-repair-candidate/result.md diff --git a/node/runtime/repair.py b/node/runtime/repair.py new file mode 100644 index 0000000..2bd35e6 --- /dev/null +++ b/node/runtime/repair.py @@ -0,0 +1,588 @@ +"""Guard and seal one selected pnpm repair candidate; run no project scripts.""" + +import argparse +import copy +import json +import os +import re +import runpy +import subprocess +import time +from collections import Counter +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import yaml + +ROOT = Path(__file__).parent +CHOICES = runpy.run_path(str(ROOT / "repair_choices.py")) +ENGINE = runpy.run_path(str(ROOT / "templates/dependency_audit.py")) +PROJECT = runpy.run_path(str(ROOT / "project.py")) +CANDIDATE_INSTALL = ( + "install", + "--lockfile-only", + "--ignore-scripts", + "--ignore-pnpmfile", +) +HOOK_ENV = { + f"{namespace}_config_{setting}": "true" + for namespace in ("npm", "pnpm") + for setting in ("ignore_scripts", "ignore_pnpmfile") +} + + +def json_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate JSON field") + result[key] = value + return result + + +def request(): + number = os.environ["REPAIR_PR"] + if not re.fullmatch(r"[1-9][0-9]*", number): + raise ValueError("Invalid PR number") + items = CHOICES["validate"]( + json.loads(os.environ["REPAIR_SELECTIONS"], object_pairs_hook=json_object) + ) + CHOICES["fingerprint"]( + int(number), os.environ["REPAIR_HEAD"], os.environ["REPAIR_BASE"], items + ) + identifier = os.environ["REPAIR_REQUEST"] + repository = os.environ["GITHUB_REPOSITORY"] + if not re.fullmatch(r"[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}", identifier): + raise ValueError("Invalid request identifier") + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) or not items: + raise ValueError("A repository and a nonempty selected batch are required") + return dict( + id=identifier, + repository=repository, + head=os.environ["REPAIR_HEAD"], + base=os.environ["REPAIR_BASE"], + pr=int(number), + selections=items, + ) + + +def gh(path): + return json.loads(subprocess.check_output(["gh", "api", path], text=True)) + + +def git(*args): + return subprocess.check_output(["git", *args], text=True) + + +def guard(expected): + repo = expected["repository"] + repository = gh(f"repos/{repo}") + pr = gh(f"repos/{repo}/pulls/{expected['pr']}") + body = pr.get("body") or "" + markers = [ + line + for line in re.split(r"\r?\n", body) + if "