diff --git a/.github/scripts/diagnose-ajv-pack.py b/.github/scripts/diagnose-ajv-pack.py new file mode 100644 index 0000000000..c17a23fda1 --- /dev/null +++ b/.github/scripts/diagnose-ajv-pack.py @@ -0,0 +1,129 @@ +"""Two bounded, credential-free npm directory-pack probes; never a verify substitute.""" +import hashlib +import json +import os +import resource +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time + +SOURCE = "34f49a201a804564cfae81e425266b3f9f987e30" +LOCK = "5ae57d1ddd475691dac1f1cfbd4836e54f7da1956b47e6e705b218ae3070ae2e" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def inventory(root): + rows = {} + for path in sorted(root.rglob("*")): + assert not path.is_symlink(), "Unexpected AJV package symlink" + rows[str(path.relative_to(root))] = { + "mode": path.stat().st_mode & 0o777, + **({"sha256": digest(path)} if path.is_file() else {"directory": True}), + } + return rows + + +def bound_output_files(): + resource.setrlimit(resource.RLIMIT_FSIZE, (16 * 1024 * 1024, 16 * 1024 * 1024)) + + +def run_probe(node, npm, package, root, evidence): + root.mkdir() + for name in ("home", "cache", "tmp", "artifacts"): + (root / name).mkdir() + for name in ("user.npmrc", "global.npmrc"): + (root / name).write_text("") + env = { + "PATH": str(node.parent) + ":/usr/bin:/bin", "CI": "true", + "HOME": str(root / "home"), "TMPDIR": str(root / "tmp"), + "NPM_CONFIG_USERCONFIG": str(root / "user.npmrc"), + "NPM_CONFIG_GLOBALCONFIG": str(root / "global.npmrc"), + "NPM_CONFIG_CACHE": str(root / "cache"), + "NPM_CONFIG_UPDATE_NOTIFIER": "false", "NPM_CONFIG_AUDIT": "false", + "NPM_CONFIG_FUND": "false", + } + argv = [str(node), str(npm), "pack", str(package), "--ignore-scripts", + "--pack-destination", str(root / "artifacts"), "--loglevel=verbose"] + started = time.monotonic() + with (evidence / (root.name + ".stdout.log")).open("wb") as stdout, \ + (evidence / (root.name + ".stderr.log")).open("wb") as stderr: + process = subprocess.Popen(argv, cwd=root / "artifacts", env=env, + stdout=stdout, stderr=stderr, start_new_session=True, + preexec_fn=bound_output_files) + timed_out = False + try: + process.wait(timeout=60) + except subprocess.TimeoutExpired: + timed_out = True + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + # The clean environment has no tokens/config credentials. Do not dump ambient env. + for label, folder in (("npm-logs", root / "cache/_logs"), ("tarballs", root / "artifacts")): + target = evidence / (root.name + "-" + label) + target.mkdir() + if folder.exists(): + files = list(folder.iterdir()) + assert len(files) <= 32 and sum(p.stat().st_size for p in files) <= 16 * 1024 * 1024 + for path in files: + assert path.is_file() and not path.is_symlink() + shutil.copyfile(path, target / path.name) + return {"argv": argv, "cwd": str(root / "artifacts"), "pid": process.pid, + "exitCode": process.returncode, "timedOut": timed_out, + "elapsedSeconds": time.monotonic() - started, "processReaped": True, + "environment": env} + + +def main(): + os.umask(0o077) + repo = Path.cwd().resolve() + evidence = repo / "ajv-pack-diagnostic" + evidence.mkdir(exist_ok=True) + node = Path(shutil.which("node")).resolve() + npm = Path(shutil.which("npm")).resolve() + assert sys.platform == "linux" and os.uname().machine == "x86_64" + assert subprocess.check_output(["git", "rev-parse", "HEAD"], text=True, timeout=10).strip() == SOURCE + assert digest(repo / "pnpm-lock.yaml") == LOCK + assert subprocess.check_output([node, "--version"], text=True, timeout=10).strip() == "v24.21.0" + assert json.loads((npm.parent.parent / "package.json").read_text())["version"] == "11.19.0" + source = (repo / "node_modules/.pnpm/ajv@8.20.0/node_modules/ajv").resolve() + assert source.is_relative_to(repo / "node_modules/.pnpm") + assert json.loads((source / "package.json").read_text())["version"] == "8.20.0" + before = inventory(source) + (evidence / "ajv-input-inventory.json").write_text(json.dumps(before, indent=2) + "\n") + work = Path(tempfile.mkdtemp(prefix="pc-ajv-pack-")) + receipt = {"source": SOURCE, "lockSha256": LOCK, "platform": os.uname().sysname, + "architecture": os.uname().machine, "node": {"version": "24.21.0", "path": str(node), "sha256": digest(node)}, + "npm": {"version": "11.19.0", "path": str(npm), "sha256": digest(npm), + "manifestSha256": digest(npm.parent.parent / "package.json")}, + "driverSha256": digest(Path(__file__)), "workRoot": str(work), "results": []} + try: + plain = work / "plain-ajv" + shutil.copytree(source, plain) + assert inventory(plain) == before + for label, package in (("pnpm-layout", source), ("plain-copy", plain)): + receipt["results"].append(run_probe(node, npm, package, work / label, evidence)) + assert inventory(source) == before + receipt["sourceUnchanged"] = True + finally: + shutil.rmtree(work) + receipt["privateRootRemoved"] = not work.exists() + (evidence / "receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") + hashes = {str(p.relative_to(evidence)): digest(p) for p in sorted(evidence.rglob("*")) if p.is_file()} + (evidence / "artifact-hashes.json").write_text(json.dumps(hashes, indent=2) + "\n") + # Preserve failed probes as failures, while still collecting the comparison. + return 0 if all(row["exitCode"] == 0 for row in receipt["results"]) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml index ab6f8eb5c2..d27bf46de1 100644 --- a/.github/workflows/docker-runner-check.yml +++ b/.github/workflows/docker-runner-check.yml @@ -19,6 +19,10 @@ on: description: "Run the complete offline Runner verify command on GitHub-hosted Ubuntu without building an image" type: boolean default: false + diagnose_ajv_pack: + description: "Diagnose only pinned AJV npm directory packing on Linux (no Runner build/tests)" + type: boolean + default: false expected_source_sha: description: "Require this immutable target commit (required for verify_runner)" type: string @@ -49,7 +53,7 @@ permissions: {} concurrency: # Publishing a newer image must not discard an earlier verification's evidence. - group: docker-runner-check-${{ github.event.pull_request.number || github.ref }}-${{ inputs.verify_runner && 'runner-verification' || inputs.verify_source && 'verification' || inputs.verify_public_install && 'public-install' || inputs.build_eval_viewer && 'viewer' || 'image' }} + group: docker-runner-check-${{ github.event.pull_request.number || github.ref }}-${{ inputs.diagnose_ajv_pack && 'ajv-diagnostic' || inputs.verify_runner && 'runner-verification' || inputs.verify_source && 'verification' || inputs.verify_public_install && 'public-install' || inputs.build_eval_viewer && 'viewer' || 'image' }} cancel-in-progress: true jobs: @@ -99,6 +103,7 @@ jobs: EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} EXPECTED_LOCK_SHA256: ${{ inputs.expected_resolved_lock_sha256 }} VERIFY_RUNNER: ${{ inputs.verify_runner }} + DIAGNOSE_AJV: ${{ inputs.diagnose_ajv_pack }} OTHER_MODE: ${{ inputs.publish_eval_image || inputs.verify_source || inputs.verify_public_install || inputs.build_eval_viewer }} run: | set -euo pipefail @@ -116,6 +121,12 @@ jobs: [[ "$EXPECTED_LOCK_SHA256" =~ ^[0-9a-f]{64}$ ]] test "$OTHER_MODE" != true fi + if [ "$DIAGNOSE_AJV" = true ]; then + test "$VERIFY_RUNNER" != true + test "$OTHER_MODE" != true + test "$EXPECTED_SOURCE_SHA" = 34f49a201a804564cfae81e425266b3f9f987e30 + test "$EXPECTED_LOCK_SHA256" = 5ae57d1ddd475691dac1f1cfbd4836e54f7da1956b47e6e705b218ae3070ae2e + fi if [ -n "$EXPECTED_SOURCE_SHA" ]; then test "$target_sha" = "$EXPECTED_SOURCE_SHA" fi @@ -196,9 +207,70 @@ jobs: packages/paperclip-runner/**/test-results/ retention-days: 14 + manual_ajv_pack_diagnostic: + name: Pinned AJV directory-pack diagnostic on Linux + if: github.event_name == 'workflow_dispatch' && inputs.diagnose_ajv_pack + needs: authorize_manual + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.authorize_manual.outputs.target_sha }} + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.workflow_sha }} + path: .ajv-diagnostic-driver + sparse-checkout: .github/scripts/diagnose-ajv-pack.py + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.21.0 + package-manager-cache: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + - name: Install only exact reviewed dependency graph without scripts + timeout-minutes: 6 + env: + EXPECTED_LOCK_SHA256: ${{ inputs.expected_resolved_lock_sha256 }} + run: | + set -euo pipefail + umask 077 + mkdir -p ajv-pack-diagnostic "$RUNNER_TEMP/ajv-install-home" + export HOME="$RUNNER_TEMP/ajv-install-home" + export NPM_CONFIG_USERCONFIG="$HOME/user.npmrc" + export NPM_CONFIG_GLOBALCONFIG="$HOME/global.npmrc" + touch "$NPM_CONFIG_USERCONFIG" "$NPM_CONFIG_GLOBALCONFIG" + test "$(git rev-parse HEAD)" = 34f49a201a804564cfae81e425266b3f9f987e30 + test "$(node --version)" = v24.21.0 + test "$(npm --version)" = 11.19.0 + test "$(pnpm --version)" = 9.15.4 + git rev-parse HEAD > ajv-pack-diagnostic/source.txt + git -C .ajv-diagnostic-driver rev-parse HEAD > ajv-pack-diagnostic/driver-source.txt + cp pnpm-lock.yaml ajv-pack-diagnostic/original-pnpm-lock.yaml + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile > ajv-pack-diagnostic/resolution.log 2>&1 + cp pnpm-lock.yaml ajv-pack-diagnostic/resolved-pnpm-lock.yaml + printf '%s pnpm-lock.yaml\n' "$EXPECTED_LOCK_SHA256" | sha256sum --check --strict + pnpm install --frozen-lockfile --ignore-scripts > ajv-pack-diagnostic/install.log 2>&1 + - name: Compare installed-layout and identical plain-package packing + timeout-minutes: 3 + run: python3 .ajv-diagnostic-driver/.github/scripts/diagnose-ajv-pack.py + - name: Retain diagnostic evidence even on npm failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ajv-pack-diagnostic-${{ github.run_id }} + path: ajv-pack-diagnostic/ + retention-days: 14 + manual_image: name: Build and verify on EC2 - if: github.event_name == 'workflow_dispatch' && !inputs.verify_runner + if: github.event_name == 'workflow_dispatch' && !inputs.verify_runner && !inputs.diagnose_ajv_pack needs: authorize_manual runs-on: runs-on/fleet=paperclip-public-pr-x64/env=public-ci timeout-minutes: 90