Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions .github/workflows/node-repair.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Prepare selected Node repairs
name: Validate selected Node repairs
'on':
workflow_call:
inputs:
Expand All @@ -19,7 +19,7 @@ name: Prepare selected Node repairs
required: true
outputs:
candidate-artifact-id:
description: Immutable preparation artifact ID in this run; no validation or publication occurred.
description: Immutable preparation artifact ID in this run; validation never replaces it. No publication occurred.
value: ${{ jobs.prepare.outputs.candidate-artifact-id }}
permissions:
contents: read
Expand All @@ -40,6 +40,27 @@ jobs:
expected-head: ${{ inputs.expected-head }}
expected-base: ${{ inputs.expected-base }}
selections: ${{ inputs.selections }}
validate:
needs: prepare
runs-on: ubuntu-latest
timeout-minutes: 50
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ inputs.expected-head }}
persist-credentials: false
- name: Validate the sealed candidate using trusted workflow code
uses: $/node/repair
with:
operation: validate
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 }}
prepare:
needs: guard
runs-on: ubuntu-latest
Expand Down
25 changes: 21 additions & 4 deletions docs/node-maintenance-operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ 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
The reusable `.github/workflows/node-repair.yml` enables `guard`, `prepare`
and `validate`. 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.

Expand Down Expand Up @@ -158,8 +158,25 @@ 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.
repair.

Validation checks out the same expected head on a separate read-only runner,
without persisted Git credentials or App credentials. It downloads only the
preparation artifact ID from the current run. It rejects extra members and
unapproved patch paths or file modes before applying the candidate. Frozen
installation and the configured `validationScript` run against that candidate;
include any project-specific local-patch tests in this script. Changes to
dependency files or configuration fail validation. A fresh audit must confirm
all selected advisories are removed and no new finding is present.

The separate `node-repair-validation-<request-id>` 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.

## Check the receiver locally

Expand Down
48 changes: 41 additions & 7 deletions node/repair/action.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
name: Node repair candidate
description: Guard or prepare a selected pnpm candidate without project scripts or publication.
description: Guard, prepare or validate a selected pnpm candidate without publication.
inputs:
operation:
description: guard or prepare.
description: guard, prepare or validate.
required: true
request-id:
required: true
Expand All @@ -19,6 +19,9 @@ inputs:
selections:
required: true
description: Canonical selected package/version triples.
candidate-artifact-id:
required: false
description: Single immutable preparation artifact ID from this run, required for validate.
outputs:
candidate-artifact-id:
description: Immutable preparation artifact ID from this run.
Expand All @@ -32,9 +35,23 @@ runs:
OPERATION: ${{ inputs.operation }}
run: |
case "$OPERATION" in
guard|prepare) ;;
*) echo '::error::Expected guard or prepare'; exit 1 ;;
guard|prepare|validate) ;;
*) echo '::error::Expected guard, prepare or validate'; exit 1 ;;
esac
- name: Validate the artifact ID
if: ${{ inputs.operation == 'validate' }}
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' }}
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
artifact-ids: ${{ inputs.candidate-artifact-id }}
path: ${{ runner.temp }}/node-repair-candidate
digest-mismatch: error
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: '3.13'
Expand All @@ -53,18 +70,18 @@ runs:
REPAIR_SELECTIONS: ${{ inputs.selections }}
run: python "$GITHUB_ACTION_PATH/../runtime/repair.py" guard
- name: Resolve the declared preparation toolchain
if: ${{ inputs.operation == 'prepare' }}
if: ${{ inputs.operation != 'guard' }}
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' }}
if: ${{ inputs.operation != 'guard' }}
with:
node-version: ${{ env.NODE_AUDIT_NODE_VERSION }}
check-latest: true
- name: Enable the declared package manager
if: ${{ inputs.operation == 'prepare' }}
if: ${{ inputs.operation != 'guard' }}
shell: bash
run: corepack enable
- name: Prepare and seal the selected candidate
Expand All @@ -88,3 +105,20 @@ runs:
${{ runner.temp }}/node-repair-candidate/candidate.patch
${{ runner.temp }}/node-repair-candidate/audit.json
${{ runner.temp }}/node-repair-candidate/result.md
- name: Validate the selected candidate
if: ${{ inputs.operation == 'validate' }}
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" validate --artifact "$RUNNER_TEMP/node-repair-candidate" --evidence "$RUNNER_TEMP/node-repair-validation"
- name: Retain human validation evidence only
if: ${{ always() && inputs.operation == 'validate' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: node-repair-validation-${{ inputs.request-id }}
if-no-files-found: warn
path: ${{ runner.temp }}/node-repair-validation/
168 changes: 165 additions & 3 deletions node/runtime/repair.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Guard and seal one selected pnpm repair candidate; run no project scripts."""
"""Prepare a sealed pnpm candidate and validate it without publication."""

import argparse
import copy
Expand All @@ -7,6 +7,7 @@
import re
import runpy
import subprocess
import tempfile
import time
from collections import Counter
from contextlib import contextmanager
Expand Down Expand Up @@ -484,6 +485,162 @@ def export_patch(paths):
)


def apply_candidate(expected, artifact, directory, snapshot):
"""Consume the preparation copy; never produce a new candidate artifact."""
root = Path.cwd().resolve()
if (
artifact.is_symlink()
or set(p.name for p in artifact.iterdir())
!= {"candidate.patch", "audit.json", "result.md"}
or any(p.is_symlink() or not p.is_file() for p in artifact.iterdir())
):
raise ValueError("Invalid sealed artifact members")
envelope = read_envelope(
artifact / "audit.json", expected, directory.relative_to(root).as_posix()
)
paths = {
(directory / name).relative_to(root).as_posix()
for name in ("pnpm-workspace.yaml", "pnpm-lock.yaml")
}
# Let Git parse quoted names, binary diffs and path safety. A temporary index
# proves the complete patch's paths and modes before it touches the worktree.
with tempfile.TemporaryDirectory(prefix="node-repair-index-") as temporary:
environment = dict(os.environ, GIT_INDEX_FILE=str(Path(temporary) / "index"))

def indexed(*args):
return subprocess.check_output(["git", *args], env=environment, text=True)

indexed("read-tree", "HEAD")
indexed(
"apply",
"--cached",
"--whitespace=nowarn",
str(artifact / "candidate.patch"),
)
records = indexed(
"diff",
"--no-ext-diff",
"--no-textconv",
"--cached",
"--raw",
"--no-abbrev",
"-z",
"HEAD",
).split("\0")
if not records[0] or indexed(
"diff", "--no-ext-diff", "--no-textconv", "--cached", "--summary", "HEAD"
):
raise ValueError("Candidate must modify existing dependency files only")
for index in range(0, len(records) - 1, 2):
fields = records[index].split()
if (
len(fields) != 5
or fields[:2] != [":100644", "100644"]
or fields[4] != "M"
or records[index + 1] not in paths
):
raise ValueError("Candidate patch changed an unapproved path or mode")
git("apply", "--whitespace=nowarn", str(artifact / "candidate.patch"))
candidate = ENGINE["valid_snapshot"](directory)
check_scope(snapshot, candidate, expected["selections"])
return envelope, candidate


def validation_state(root, directory):
"""Capture declaration bytes and modes before running project code."""
inputs = PROJECT["configuration_inputs"](root, directory)
paths = set(inputs) | {(directory / "pnpm-lock.yaml").relative_to(root).as_posix()}
for parent in (directory, *directory.parents):
if parent.is_relative_to(root):
paths.update(
(parent / name).relative_to(root).as_posix()
for name in (".npmrc", ".pnpmfile.cjs")
)
state = {}
for name in paths:
path = root / project_path(root, name)
if path.exists():
if path.is_symlink() or not path.is_file():
raise ValueError("Validation declarations must remain regular files")
state[name] = (path.stat().st_mode, path.read_bytes())
else:
state[name] = None
return state


def validate(expected, artifact, evidence):
root = Path.cwd().resolve()
if (
git("rev-parse", "HEAD").strip() != expected["head"]
or git("status", "--porcelain", "--untracked-files=all").strip()
):
raise ValueError("Validation requires a clean checkout of the expected head")
if artifact.resolve() != artifact or artifact.is_relative_to(root):
raise ValueError("Candidate artifacts must be outside the project checkout")
if (
evidence.resolve() != evidence
or evidence.is_relative_to(root)
or evidence.is_relative_to(artifact)
or artifact.is_relative_to(evidence)
):
raise ValueError(
"Validation evidence must be separate from the checkout and candidate"
)
evidence.mkdir(parents=True, exist_ok=False)
try:
with suppress_hooks():
directory, selected, snapshot = project_context()
envelope, candidate = apply_candidate(
expected, artifact, directory, snapshot
)
state = validation_state(root, directory)
deadline = time.monotonic() + 40 * 60

def unchanged():
if (
validation_state(root, directory) != state
or ENGINE["valid_snapshot"](directory) != candidate
):
raise ValueError("Validation changed dependency files or configuration")

for label, arguments in (
("Frozen install", ("install", "--frozen-lockfile")),
(
"Configured project/local-patch checks",
("run", selected["validationScript"]),
),
):
result = ENGINE["run"](directory, *arguments, deadline=deadline)
(
evidence
/ ("install.log" if label == "Frozen install" else "checks.log")
).write_text(result.stdout + "\n" + result.stderr)
unchanged()
if result.returncode:
raise ValueError(f"{label} failed (exit {result.returncode})")
with suppress_hooks():
after = ENGINE["audit"](directory, deadline=deadline)
unchanged()
# Reuse the strict selected-fix and no-new-findings checks against a fresh
# audit. The in-memory preparation envelope stays the comparison source.
validate_envelope(
dict(envelope, candidate=after), expected, envelope["project_directory"]
)
(evidence / "audit.json").write_text(
json.dumps(after, ensure_ascii=False) + "\n"
)
message = "Validation passed: frozen install, configured project/local-patch checks and fresh advisory checks. No dependency change has been published."
except Exception as error:
(evidence / "result.md").write_text(
"Validation failed. No dependency change has been published.\n\n"
+ str(error)
+ "\n"
)
raise
(evidence / "result.md").write_text(message + "\n")
return after


def prepare(expected, artifact):
with suppress_hooks():
return prepare_candidate(expected, artifact)
Expand Down Expand Up @@ -572,14 +729,19 @@ def prepare_candidate(expected, artifact):

def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("operation", choices=("guard", "prepare"))
parser.add_argument("operation", choices=("guard", "prepare", "validate"))
parser.add_argument("--artifact", type=Path)
parser.add_argument("--evidence", type=Path)
args = parser.parse_args()
expected = request()
if args.operation == "guard":
guard(expected)
elif args.artifact is None:
parser.error("prepare requires --artifact")
parser.error("prepare and validate require --artifact")
elif args.operation == "validate":
if args.evidence is None:
parser.error("validate requires --evidence")
validate(expected, args.artifact, args.evidence)
else:
prepare(expected, args.artifact)

Expand Down
Loading
Loading