diff --git a/.github/workflows/heal-generated-files-finalize.yml b/.github/workflows/heal-generated-files-finalize.yml new file mode 100644 index 0000000000..e9c8d4620b --- /dev/null +++ b/.github/workflows/heal-generated-files-finalize.yml @@ -0,0 +1,178 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Privileged companion to heal-generated-files.yml. This workflow is loaded +# from the repository default branch. It never executes artifact or pull +# request code. The App token is created only after the candidate commit has +# been reconstructed from its exact parents and fully verified. + +name: Finalize Generated File Healing + +on: + workflow_run: + workflows: ["Prepare Generated File Healing"] + types: [completed] + +concurrency: + group: finalize-generated-file-healing + cancel-in-progress: false + +permissions: + actions: read + contents: read + pull-requests: read + +jobs: + finalize: + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository && + contains(fromJSON('["pull_request", "push", "workflow_dispatch"]'), github.event.workflow_run.event) + runs-on: ubuntu-latest + steps: + - name: Resolve current trusted revision and pull request metadata + id: metadata + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require("node:fs"); + const path = require("node:path"); + const { owner, repo } = context.repo; + const repository = `${owner}/${repo}`; + const repositoryResponse = await github.rest.repos.get({ owner, repo }); + const defaultBranch = repositoryResponse.data.default_branch; + const branchResponse = await github.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch, + }); + const trustedSha = branchResponse.data.commit.sha; + if (!/^[0-9a-f]{40}$/.test(trustedSha)) { + core.setFailed("The default branch did not resolve to a full commit SHA."); + return; + } + let pullRequests = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + base: defaultBranch, + per_page: 100, + }); + pullRequests = pullRequests.filter( + (pullRequest) => pullRequest.head.repo?.full_name === repository, + ); + const metadata = { + repository, + defaultBranch, + trustedSha, + runId: String(context.payload.workflow_run.id), + pullRequests: pullRequests.map((pullRequest) => ({ + number: pullRequest.number, + state: pullRequest.state, + isCrossRepository: + pullRequest.head.repo?.full_name !== pullRequest.base.repo?.full_name, + headRepository: pullRequest.head.repo?.full_name, + baseRepository: pullRequest.base.repo?.full_name, + headRefName: pullRequest.head.ref, + headSha: pullRequest.head.sha, + baseRefName: pullRequest.base.ref, + baseSha: pullRequest.base.sha, + })), + }; + const metadataPath = path.join( + process.env.RUNNER_TEMP, + "generated-file-healing-live-metadata.json", + ); + fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); + core.setOutput("path", metadataPath); + core.setOutput("default_branch", defaultBranch); + core.setOutput("trusted_sha", trustedSha); + + - name: Checkout trusted finalizer + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.metadata.outputs.trusted_sha }} + fetch-depth: 0 + path: trusted + persist-credentials: false + + - name: Verify trusted finalizer revision + env: + DEFAULT_BRANCH: ${{ steps.metadata.outputs.default_branch }} + REPOSITORY: ${{ github.repository }} + TRUSTED_SHA: ${{ steps.metadata.outputs.trusted_sha }} + run: | + set -euo pipefail + test "$(git -C trusted rev-parse HEAD)" = "$TRUSTED_SHA" + remote_sha="$( + git -c credential.helper= -c credential.interactive=never \ + ls-remote "https://github.com/${REPOSITORY}.git" \ + "refs/heads/${DEFAULT_BRANCH}" | awk '{print $1}' + )" + test "$remote_sha" = "$TRUSTED_SHA" + git -C trusted merge-base --is-ancestor "$TRUSTED_SHA" "$remote_sha" + + - name: Install trusted pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + package_json_file: trusted/ts/package.json + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + + - name: Download candidate artifact + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: generated-file-healing-${{ github.event.workflow_run.id }} + path: ${{ runner.temp }}/generated-file-healing + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Verify candidate object graph and live pull request state + id: verify + env: + LIVE_METADATA_PATH: ${{ steps.metadata.outputs.path }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + plan="$RUNNER_TEMP/generated-file-healing-plan.json" + node trusted/ts/tools/scripts/heal-generated-files.mjs verify \ + --artifact "$RUNNER_TEMP/generated-file-healing" \ + --live-metadata "$LIVE_METADATA_PATH" \ + --run-id "$RUN_ID" \ + --repository-url "https://github.com/${REPOSITORY}.git" \ + --pnpm "$(command -v pnpm)" \ + --plan "$plan" + echo "plan=$plan" >> "$GITHUB_OUTPUT" + candidate_count="$( + node -p \ + "JSON.parse(require('fs').readFileSync(process.argv[1])).candidates.length" \ + "$plan" + )" + echo "candidate_count=$candidate_count" >> "$GITHUB_OUTPUT" + + - name: Mint minimally scoped App token + id: app-token + if: steps.verify.outputs.candidate_count != '0' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.DEPENDABOT_APP_ID }} + private-key: ${{ secrets.DEPENDABOT_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: read + + - name: Revalidate and push with exact-head lease + if: steps.verify.outputs.candidate_count != '0' + env: + GITHUB_API_URL: ${{ github.api_url }} + HEAL_PUSH_TOKEN: ${{ steps.app-token.outputs.token }} + PLAN_PATH: ${{ steps.verify.outputs.plan }} + run: | + set -euo pipefail + node trusted/ts/tools/scripts/heal-generated-files.mjs push \ + --plan "$PLAN_PATH" diff --git a/.github/workflows/heal-generated-files.yml b/.github/workflows/heal-generated-files.yml index 528fbaf6e1..f35769e31d 100644 --- a/.github/workflows/heal-generated-files.yml +++ b/.github/workflows/heal-generated-files.yml @@ -1,40 +1,17 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# Auto-heal generated-file conflicts on open pull requests. When main moves, -# open PRs can start conflicting on generated files - the pnpm lockfile and the -# autogenerated README.AUTOGEN.md companions - and get ejected from the merge -# queue. This job merges main into each affected same-repo PR branch and -# resolves those files so no one has to do it by hand. The logic lives in -# ts/tools/scripts/heal-generated-files.mjs. -# -# Triggers: the push-to-main scan reconciles every affected PR when main moves; -# pull_request events heal a single PR the moment it needs it (opened behind -# main, or an author push that reconflicts the lockfile). No self-commit guard -# is needed - the healing push re-triggers this workflow, but the re-run trial- -# merges main, finds nothing left to resolve, and exits without pushing, so the -# cycle terminates by idempotency (same reasoning as docs-pr-amend.yml). - -name: Heal Generated Files +# The producer never has repository-write credentials. It resolves the current +# default-branch commit through the API, checks out only that immutable commit, +# and treats pull request commits as data in a separate credential-free bare +# repository. A privileged workflow_run companion verifies the artifact again +# before it mints an App token. + +name: Prepare Generated File Healing on: - # main moving is what pushes open PRs into conflict, so this scan stays the - # primary trigger - one run reconciles every affected branch at once. push: branches: ["main"] - # Also heal a single PR the moment it needs it, without waiting for the next - # push to main: opened/reopened already behind main, or an author push that - # reintroduces a lockfile conflict. No `paths` filter - the conflict comes - # from merging main, not from what the PR itself changed, so the trial merge - # is the real gate and exits fast when there is nothing to resolve. - # - # `synchronize` fires a run on every push to every same-repo PR. That is - # deliberate: the lockfile currently churns often, so PRs reconflict on it - # frequently and healing right on the author's push is what spares them the - # manual resolve. Each run is cheap (checkout + trial-merge, exits fast when - # clean). If that churn settles and the run volume stops being worth it, drop - # to [opened, reopened] and let the push-to-main scan catch author-push - # reconflicts on the next merge. pull_request: types: [opened, reopened, synchronize] workflow_dispatch: @@ -44,74 +21,219 @@ on: type: string concurrency: - # One shared lane for every trigger (push scan, PR events, manual dispatch) - # so two runs never push to the same branch at once, and never cancel a run - # mid-push. - group: heal-generated-files - cancel-in-progress: false + group: prepare-generated-file-healing-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true permissions: - contents: write - pull-requests: write + contents: read + pull-requests: read jobs: - heal: - # Fork PRs are skipped: their pull_request runs get a read-only token and no - # app secret, so the healing commit could not be pushed back anyway. push - # and workflow_dispatch carry no pull_request payload, so they always run. + prepare: if: >- github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest - env: - # `vars`/`secrets` cannot be used directly in step `if` conditions, so - # expose whether the app credentials are configured as a string. The App - # id is a repo variable (as in docs-pr-amend.yml / fix-dependabot-alerts). - HAS_APP_CREDS: ${{ vars.DEPENDABOT_APP_ID != '' }} steps: - - name: Setup Git LF - run: git config --global core.autocrlf false - - # Push to PR branches with the typeagent-bot app token (not the default - # GITHUB_TOKEN), so the healing commit re-triggers the PR's normal checks. - - name: Generate app token - id: app-token - if: env.HAS_APP_CREDS == 'true' - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + - name: Resolve trusted revision and pull request metadata + id: metadata + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - app-id: ${{ vars.DEPENDABOT_APP_ID }} - private-key: ${{ secrets.DEPENDABOT_APP_PRIVATE_KEY }} + github-token: ${{ github.token }} + script: | + const fs = require("node:fs"); + const path = require("node:path"); + const { owner, repo } = context.repo; + const repository = `${owner}/${repo}`; + const repositoryResponse = await github.rest.repos.get({ owner, repo }); + const defaultBranch = repositoryResponse.data.default_branch; + const branchResponse = await github.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch, + }); + const trustedSha = branchResponse.data.commit.sha; + if (!/^[0-9a-f]{40}$/.test(trustedSha)) { + core.setFailed("The default branch did not resolve to a full commit SHA."); + return; + } + if ( + context.eventName === "push" && + context.ref !== `refs/heads/${defaultBranch}` + ) { + core.setFailed( + `Push trigger ${context.ref} is not the repository default branch ${defaultBranch}.`, + ); + return; + } - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + let pullRequests; + const requested = context.payload.inputs?.pr?.trim(); + if (context.eventName === "pull_request") { + pullRequests = [ + ( + await github.rest.pulls.get({ + owner, + repo, + pull_number: context.payload.pull_request.number, + }) + ).data, + ]; + } else if (requested) { + if (!/^[1-9]\d*$/.test(requested)) { + core.setFailed(`Invalid pull request number: ${requested}`); + return; + } + pullRequests = [ + ( + await github.rest.pulls.get({ + owner, + repo, + pull_number: Number(requested), + }) + ).data, + ]; + } else { + pullRequests = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + base: defaultBranch, + per_page: 100, + }); + pullRequests = pullRequests.filter( + (pullRequest) => pullRequest.head.repo?.full_name === repository, + ); + } + + const metadata = { + repository, + defaultBranch, + trustedSha, + runId: process.env.GITHUB_RUN_ID, + pullRequests: pullRequests.map((pullRequest) => ({ + number: pullRequest.number, + state: pullRequest.state, + isCrossRepository: + pullRequest.head.repo?.full_name !== pullRequest.base.repo?.full_name, + headRepository: pullRequest.head.repo?.full_name, + baseRepository: pullRequest.base.repo?.full_name, + headRefName: pullRequest.head.ref, + headSha: pullRequest.head.sha, + baseRefName: pullRequest.base.ref, + baseSha: pullRequest.base.sha, + })), + }; + const metadataPath = path.join( + process.env.RUNNER_TEMP, + "generated-file-healing-metadata.json", + ); + fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); + core.setOutput("path", metadataPath); + core.setOutput("default_branch", defaultBranch); + core.setOutput("trusted_sha", trustedSha); + + - name: Fetch and verify trusted implementation without credentials + env: + DEFAULT_BRANCH: ${{ steps.metadata.outputs.default_branch }} + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/trusted-checkout-home/gitconfig + GIT_CONFIG_NOSYSTEM: "1" + GIT_TERMINAL_PROMPT: "0" + GCM_INTERACTIVE: "Never" + HOME: ${{ runner.temp }}/trusted-checkout-home + REPOSITORY: ${{ github.repository }} + TRUSTED_SHA: ${{ steps.metadata.outputs.trusted_sha }} + XDG_CONFIG_HOME: ${{ runner.temp }}/trusted-checkout-home/xdg + run: | + set -euo pipefail + mkdir -p "$HOME" "$XDG_CONFIG_HOME" + : > "$GIT_CONFIG_GLOBAL" + git -c credential.helper= -c credential.interactive=never \ + -c core.hooksPath=/dev/null init trusted + git -C trusted remote add origin "https://github.com/${REPOSITORY}.git" + git -C trusted -c credential.helper= -c credential.interactive=never \ + -c core.hooksPath=/dev/null fetch --no-tags origin \ + "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + git -C trusted -c core.hooksPath=/dev/null checkout --detach "$TRUSTED_SHA" + test "$(git -C trusted rev-parse HEAD)" = "$TRUSTED_SHA" + remote_sha="$( + git -c credential.helper= -c credential.interactive=never \ + ls-remote "https://github.com/${REPOSITORY}.git" \ + "refs/heads/${DEFAULT_BRANCH}" | awk '{print $1}' + )" + test "$remote_sha" = "$TRUSTED_SHA" + git -C trusted merge-base --is-ancestor "$TRUSTED_SHA" "$remote_sha" + + - name: Verify trusted healer contract + id: healer-contract + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + script="trusted/ts/tools/scripts/heal-generated-files.mjs" + library="trusted/ts/tools/scripts/lib/generatedFileHealing.mjs" + if test -f "$script" && test -f "$library"; then + echo "available=true" >> "$GITHUB_OUTPUT" + exit 0 + fi - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - name: Install pnpm + previously_deployed="$( + git -C trusted log --format=%H --all -- \ + .github/workflows/heal-generated-files-finalize.yml + )" + if test "$EVENT_NAME" != "pull_request" || test -n "$previously_deployed"; then + echo "::error::The trusted default branch does not contain the secure generated-file healer." + exit 1 + fi + + # The rollout PR cannot execute its own healer without violating the + # default-branch trust boundary. It produces no artifact; once this + # workflow has landed, any missing trusted contract fails above. + echo "available=false" >> "$GITHUB_OUTPUT" + + - name: Report secure healer bootstrap + if: steps.healer-contract.outputs.available != 'true' + run: | + echo "::notice::The secure healer is not yet present on the trusted default branch; candidate preparation begins after this workflow lands." + echo "Secure candidate preparation was not run because its trusted implementation is not yet on the default branch." >> "$GITHUB_STEP_SUMMARY" + + - name: Install trusted pnpm + if: steps.healer-contract.outputs.available == 'true' + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: - package_json_file: ts/package.json - - # No pnpm store cache here. This job never runs a full `pnpm install`: - # it only trial-merges main and, when the lockfile conflicts, runs - # `pnpm install --lockfile-only` (dependency-graph resolution that writes - # the lockfile without populating the store). With `cache: pnpm`, - # setup-node's post-step tries to save the pnpm store on a cache miss and - # fails with "Path(s) specified in the action for caching do(es) not - # exist" because nothing ever populated it - so the job passed or failed - # purely on whether another workflow had already cached that lockfile - # hash. The store cache gives no benefit to a lockfile-only resolve, so - # dropping it makes the job deterministic. - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + package_json_file: trusted/ts/package.json + + - name: Setup Node.js + if: steps.healer-contract.outputs.available == 'true' + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 - - name: Heal conflicted generated files - if: env.HAS_APP_CREDS == 'true' + - name: Prepare credential-free healing candidates + if: steps.healer-contract.outputs.available == 'true' env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - GITHUB_REPOSITORY: ${{ github.repository }} - # Empty on the push scan (heal every open PR); one number on a - # pull_request event or manual dispatch (heal just that PR). - INPUT_PR: ${{ github.event.inputs.pr || github.event.pull_request.number }} - run: node ts/tools/scripts/heal-generated-files.mjs + METADATA_PATH: ${{ steps.metadata.outputs.path }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + pnpm_path="$(command -v pnpm)" + mkdir -p "$RUNNER_TEMP/generated-file-healing-home" + env -i \ + HOME="$RUNNER_TEMP/generated-file-healing-home" \ + LANG="${LANG:-C.UTF-8}" \ + PATH="$PATH" \ + TMPDIR="$RUNNER_TEMP" \ + node trusted/ts/tools/scripts/heal-generated-files.mjs prepare \ + --metadata "$METADATA_PATH" \ + --output "$RUNNER_TEMP/generated-file-healing" \ + --repository-url "https://github.com/${REPOSITORY}.git" \ + --pnpm "$pnpm_path" + + - name: Upload healing candidates + if: steps.healer-contract.outputs.available == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: generated-file-healing-${{ github.run_id }} + path: ${{ runner.temp }}/generated-file-healing + if-no-files-found: error + retention-days: 1 diff --git a/ts/tools/scripts/heal-generated-files.mjs b/ts/tools/scripts/heal-generated-files.mjs index 3cab404cb4..0c45ddc920 100644 --- a/ts/tools/scripts/heal-generated-files.mjs +++ b/ts/tools/scripts/heal-generated-files.mjs @@ -1,199 +1,97 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Auto-heal generated-file conflicts on open pull requests. Run by the -// heal-generated-files workflow after main moves (or on manual dispatch). For each -// same-repo PR that conflicts with main ONLY in generated files - the pnpm -// lockfile and/or the autogenerated README.AUTOGEN.md companions - merge main -// into the PR branch, resolve those files, and push the result so the PR (and -// the merge queue) stops choking on them. The lockfile is rebuilt with pnpm; -// the READMEs keep our copy and are regenerated by the docs-pr-amend workflow -// that re-runs on the healed push. PRs that conflict in any other file are -// left untouched for a human to resolve. - -import { execFileSync } from "node:child_process"; - -const LOCKFILE = "ts/pnpm-lock.yaml"; -const REPO = process.env.GITHUB_REPOSITORY; -const ONLY_PR = process.env.INPUT_PR?.trim(); - -// All git/gh/pnpm calls go through execFileSync with an argument array (no -// shell), so branch names coming from the API can never be interpreted by a -// shell. -function run(cmd, args, opts = {}) { - return execFileSync(cmd, args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - ...opts, - }); -} - -function tryRun(cmd, args, opts = {}) { - try { - return { ok: true, out: run(cmd, args, opts) }; - } catch (err) { - return { ok: false, out: `${err.stdout ?? ""}${err.stderr ?? ""}` }; - } -} - -function candidatePRs() { - if (ONLY_PR) { - const p = JSON.parse( - run("gh", [ - "pr", - "view", - ONLY_PR, - "--repo", - REPO, - "--json", - "number,headRefName,isCrossRepository", - ]), - ); - if (p.isCrossRepository) { - console.log( - `[heal] PR #${ONLY_PR} is from a fork - cannot push, skipping`, - ); - return []; +// This CLI is always loaded from a full, API-resolved default-branch commit. +// Candidate preparation has no write credentials. The privileged command only +// verifies trusted objects, revalidates the pull request, and performs one +// exact-head leased push. + +import { + createGitHubApi, + loadJsonFile, + prepareCandidateArtifact, + pushVerifiedPlan, + verifyCandidateArtifact, +} from "./lib/generatedFileHealing.mjs"; + +function parseArguments(argv) { + const [command, ...rest] = argv; + const values = new Map(); + for (let index = 0; index < rest.length; index++) { + const argument = rest[index]; + if (!argument.startsWith("--")) { + throw new Error(`Unexpected argument: ${argument}`); } - return [{ number: p.number, ref: p.headRefName }]; + const equal = argument.indexOf("="); + if (equal !== -1) { + values.set(argument.slice(2, equal), argument.slice(equal + 1)); + continue; + } + const value = rest[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`Missing value for ${argument}`); + } + values.set(argument.slice(2), value); + index++; } - // Open PRs targeting main, from this repo (fork branches aren't pushable), - // that aren't already cleanly mergeable. A trial merge decides the rest. - const prs = JSON.parse( - run("gh", [ - "pr", - "list", - "--repo", - REPO, - "--state", - "open", - "--base", - "main", - "--limit", - "100", - "--json", - "number,mergeable,isCrossRepository,headRefName", - ]), - ); - return prs - .filter((p) => !p.isCrossRepository && p.mergeable !== "MERGEABLE") - .map((p) => ({ number: p.number, ref: p.headRefName })); -} - -function unmergedFiles() { - return run("git", ["diff", "--name-only", "--diff-filter=U"]) - .split("\n") - .map((s) => s.trim()) - .filter(Boolean); -} - -// Generated files we can resolve automatically after merging main. A conflict -// in anything else means a human needs to look at the PR. -function isAutogenReadme(file) { - return /^ts\/packages\/.+\/README\.AUTOGEN\.md$/.test(file); -} - -function isResolvable(file) { - return file === LOCKFILE || isAutogenReadme(file); + const required = (name) => { + const value = values.get(name); + if (!value) { + throw new Error(`Missing --${name}`); + } + return value; + }; + return { command, required }; } -// Resolve the merged-in conflicts in place and stage the results. -function resolveConflicts(conflicts) { - // Autogen READMEs: keep our copy. The docs-pr-amend workflow regenerates - // them from the merged source on the healed push (it re-runs because we push - // with the app token), so no build or model access is needed here. - for (const file of conflicts.filter(isAutogenReadme)) { - run("git", ["checkout", "--ours", "--", file]); - run("git", ["add", file]); - } - - // Lockfile: seed from main's copy, then let pnpm rebuild it against the - // merged package.json files. - if (conflicts.includes(LOCKFILE)) { - run("git", ["checkout", "--theirs", "--", LOCKFILE]); - run( - "pnpm", - [ - "install", - "--lockfile-only", - "--no-frozen-lockfile", - "--ignore-scripts", - ], - { cwd: "ts", stdio: "inherit" }, +async function main() { + const { command, required } = parseArguments(process.argv.slice(2)); + if (command === "prepare") { + const summary = prepareCandidateArtifact({ + metadata: loadJsonFile(required("metadata")), + outputDirectory: required("output"), + repositoryUrl: required("repository-url"), + pnpmCommand: required("pnpm"), + }); + console.log( + `[heal] prepared ${summary.candidates.length} candidate(s); skipped ${summary.skipped.length}`, ); - run("git", ["add", LOCKFILE]); + return; } -} - -function healPr({ number, ref }) { - console.log(`\n[heal] PR #${number} (${ref})`); - try { - run("git", ["fetch", "origin", "main", ref]); - run("git", ["checkout", "-B", "_heal", `origin/${ref}`]); - - // Trial-merge main. --no-ff/--no-commit so a clean merge leaves a - // MERGE_HEAD we simply discard, and a conflicted one leaves the unmerged - // set to inspect. - tryRun("git", ["merge", "--no-ff", "--no-commit", "origin/main"]); - const conflicts = unmergedFiles(); - - if (conflicts.length === 0) { - console.log("[heal] no conflict - skipping"); - return; - } - if (!conflicts.every(isResolvable)) { - const other = conflicts.filter((f) => !isResolvable(f)); - console.log( - `[heal] conflicts beyond generated files (${other.join(", ")}) - leaving for a human`, - ); - return; - } - - resolveConflicts(conflicts); - run("git", ["commit", "--no-edit"]); - - // Non-force push: if the PR head moved meanwhile the push is rejected and - // the next run retries, so a concurrent update is never clobbered. - const push = tryRun("git", ["push", "origin", `_heal:${ref}`]); - if (push.ok) { - console.log( - `[heal] PR #${number}: resolved ${conflicts.join(", ")} and pushed`, - ); - } else { - console.log( - `[heal] PR #${number}: push rejected (branch moved?) - will retry\n${push.out}`, - ); + if (command === "verify") { + const plan = verifyCandidateArtifact({ + artifactDirectory: required("artifact"), + liveMetadata: loadJsonFile(required("live-metadata")), + expectedRunId: required("run-id"), + repositoryUrl: required("repository-url"), + pnpmCommand: required("pnpm"), + planPath: required("plan"), + }); + console.log(`[heal] verified ${plan.candidates.length} candidate(s)`); + return; + } + if (command === "push") { + const plan = loadJsonFile(required("plan")); + const token = process.env.HEAL_PUSH_TOKEN; + if (!token) { + throw new Error("HEAL_PUSH_TOKEN is required"); } - } finally { - // Return to a clean, detached state so the next PR starts fresh. This - // only touches the ephemeral CI checkout and the throwaway _heal branch. - tryRun("git", ["merge", "--abort"]); - tryRun("git", ["reset", "--hard"]); - tryRun("git", ["checkout", "--detach", "origin/main"]); - tryRun("git", ["branch", "-D", "_heal"]); + delete process.env.HEAL_PUSH_TOKEN; + const api = createGitHubApi({ + repository: plan.repository, + token, + apiUrl: process.env.GITHUB_API_URL, + }); + const result = await pushVerifiedPlan({ plan, api, token }); + console.log( + `[heal] pushed ${result.pushed.length} candidate(s); skipped ${result.skipped.length}`, + ); + return; } + throw new Error(`Unknown command: ${command ?? "(missing)"}`); } -const prs = candidatePRs(); -if (prs.length === 0) { - console.log("[heal] nothing to do - no conflicting same-repo PRs"); - process.exit(0); -} - -run("git", ["config", "user.name", "typeagent-bot"]); -run("git", [ - "config", - "user.email", - "typeagent-bot[bot]@users.noreply.github.com", -]); - -let failures = 0; -for (const pr of prs) { - try { - healPr(pr); - } catch (err) { - failures++; - console.error(`[heal] PR #${pr.number} failed: ${err.message}`); - } -} -process.exit(failures ? 1 : 0); +main().catch((error) => { + console.error(`[heal] ${error.stack ?? error.message}`); + process.exitCode = 1; +}); diff --git a/ts/tools/scripts/lib/generatedFileHealing.mjs b/ts/tools/scripts/lib/generatedFileHealing.mjs new file mode 100644 index 0000000000..23bbfbf097 --- /dev/null +++ b/ts/tools/scripts/lib/generatedFileHealing.mjs @@ -0,0 +1,1856 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + constants as fsConstants, + copyFileSync, + fstatSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + realpathSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; + +export const ARTIFACT_VERSION = 1; +export const LOCKFILE = "ts/pnpm-lock.yaml"; + +const SHA_PATTERN = /^[0-9a-f]{40}$/; +const AUTOGEN_README_PATTERN = /^ts\/packages\/.+\/README\.AUTOGEN\.md$/; +const BLOCKED_PACKAGE_MANAGER_PATHS = [ + /(^|\/)\.npmrc$/, + /(^|\/)\.pnpmrc$/, + /(^|\/)\.?pnpmfile(?:\.[^/]+)?$/, + /^ts\/pnpm-workspace\.yaml$/, + /^ts\/patches\//, + /^ts\/\.pnpm\//, +]; +const BLOCKED_MERGE_BEHAVIOR_PATHS = [ + /(^|\/)\.gitattributes$/, + /^\.gitmodules$/, +]; +const TRUSTED_ROOT_PACKAGE_FIELDS = [ + "packageManager", + "pnpm", + "engines", + "devEngines", +]; +const COMMIT_MESSAGE = "chore: heal generated file conflicts"; +const BOT_NAME = "typeagent-bot"; +const BOT_EMAIL = "typeagent-bot[bot]@users.noreply.github.com"; + +function fail(message) { + throw new Error(message); +} + +class RepositoryFatalError extends Error { + constructor(message) { + super(message); + this.name = "RepositoryFatalError"; + } +} + +class CandidateOutcomeError extends Error { + constructor(status, message) { + super(message); + this.name = "CandidateOutcomeError"; + this.status = status; + } +} + +function blockCandidate(message) { + throw new CandidateOutcomeError("blocked", message); +} + +function retryCandidate(message) { + throw new CandidateOutcomeError("retryable", message); +} + +function fatalRepository(message) { + throw new RepositoryFatalError(message); +} + +function isInfrastructureFailure(error) { + return new Set([ + "EACCES", + "EBUSY", + "EIO", + "EMFILE", + "ENFILE", + "ENOMEM", + "ENOSPC", + "EPERM", + "EROFS", + ]).has(error?.code); +} + +function candidateOutcome(number, error, fallbackStatus = "retryable") { + if ( + error instanceof RepositoryFatalError || + isInfrastructureFailure(error) + ) { + throw error; + } + return { + number, + status: + error instanceof CandidateOutcomeError + ? error.status + : fallbackStatus, + reason: error.message, + conflicts: + error instanceof CandidateOutcomeError + ? (error.conflicts ?? []) + : [], + }; +} + +function assert(condition, message) { + if (!condition) { + fail(message); + } +} + +function normalizeRepository(repository) { + assert( + typeof repository === "string" && + /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository), + `Invalid repository: ${repository}`, + ); + return repository; +} + +function normalizeSha(value, label) { + assert( + typeof value === "string" && SHA_PATTERN.test(value), + `${label} must be a full lowercase commit SHA`, + ); + return value; +} + +function normalizeRef(value, label) { + assert( + typeof value === "string" && + value.length > 0 && + value.length <= 255 && + !value.startsWith("-") && + !value.startsWith("/") && + !value.endsWith("/") && + !value.endsWith(".") && + !value.includes("..") && + !value.includes("@{") && + !/[~^:?*[\\\x00-\x20\x7f]/.test(value) && + !value + .split("/") + .some((part) => part === "" || part.endsWith(".lock")), + `${label} is not a safe Git branch name`, + ); + return value; +} + +function normalizePrNumber(value) { + assert( + Number.isInteger(value) && value > 0, + `Invalid pull request number: ${value}`, + ); + return value; +} + +function normalizePath(path) { + assert(typeof path === "string" && path.length > 0, "Git path is invalid"); + assert(!path.includes("\\"), `Git path contains a backslash: ${path}`); + assert( + path + .split("/") + .every((part) => part !== "" && part !== "." && part !== ".."), + `Git path contains an unsafe component: ${path}`, + ); + return path; +} + +function normalizeCandidatePath(path) { + try { + return normalizePath(path); + } catch (error) { + blockCandidate(error.message); + } +} + +export function isAutogenReadme(path) { + return AUTOGEN_README_PATTERN.test(normalizePath(path)); +} + +export function isResolvableConflict(path) { + const normalized = normalizePath(path); + return normalized === LOCKFILE || isAutogenReadme(normalized); +} + +export function isBlockedPackageManagerPath(path) { + const normalized = normalizePath(path); + return BLOCKED_PACKAGE_MANAGER_PATHS.some((pattern) => + pattern.test(normalized), + ); +} + +function stableJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function deepEqual(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function readJson(path, label = path) { + const stat = lstatSync(path); + assert(stat.isFile() && !stat.isSymbolicLink(), `${label} must be a file`); + assert(stat.size <= 1024 * 1024, `${label} is unexpectedly large`); + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + fail(`${label} is not valid JSON: ${error.message}`); + } +} + +function writeJson(path, value) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, stableJson(value)); +} + +function commandError(command, args, result) { + return new Error( + `${command} ${args.join(" ")} failed with exit code ${result.status}\n${result.stdout ?? ""}${result.stderr ?? ""}`, + ); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + if (result.error) { + if (result.error.code === "ENOENT") { + fatalRepository( + `Trusted pnpm executable is unavailable: ${command}`, + ); + } + throw result.error; + } + if (result.status !== 0) { + throw commandError(command, args, result); + } + return result.stdout; +} + +function tryRun(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + return { + ok: !result.error && result.status === 0, + status: result.status, + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + error: result.error, + }; +} + +function createIsolatedEnvironment(baseEnvironment, home) { + mkdirSync(home, { recursive: true }); + const globalConfig = join(home, "gitconfig"); + const npmConfig = join(home, "npmrc"); + writeFileSync(globalConfig, ""); + writeFileSync( + npmConfig, + [ + "ignore-scripts=true", + "manage-package-manager-versions=false", + "enable-pre-post-scripts=false", + "", + ].join("\n"), + ); + + const environment = { + CI: "true", + HOME: home, + XDG_CONFIG_HOME: join(home, "xdg-config"), + XDG_CACHE_HOME: join(home, "xdg-cache"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: globalConfig, + GIT_TERMINAL_PROMPT: "0", + GIT_ATTR_NOSYSTEM: "1", + GCM_INTERACTIVE: "Never", + NPM_CONFIG_USERCONFIG: npmConfig, + npm_config_userconfig: npmConfig, + NPM_CONFIG_IGNORE_SCRIPTS: "true", + npm_config_ignore_scripts: "true", + NPM_CONFIG_MANAGE_PACKAGE_MANAGER_VERSIONS: "false", + npm_config_manage_package_manager_versions: "false", + }; + for (const name of [ + "PATH", + "Path", + "PATHEXT", + "SYSTEMROOT", + "SystemRoot", + "WINDIR", + "COMSPEC", + "TEMP", + "TMP", + "TMPDIR", + "LANG", + "LC_ALL", + "TZ", + ]) { + if (baseEnvironment[name] !== undefined) { + environment[name] = baseEnvironment[name]; + } + } + return environment; +} + +function gitArguments(args) { + return [ + "-c", + "core.hooksPath=/dev/null", + "-c", + "credential.helper=", + "-c", + "credential.interactive=never", + "-c", + "protocol.file.allow=always", + "-c", + "safe.bareRepository=all", + "-c", + "merge.pnpm-lock.driver=false", + "-c", + "merge.keep-ours.driver=false", + ...args, + ]; +} + +function git(repositoryPath, args, options = {}) { + const location = repositoryPath + ? ["-C", repositoryPath, ...gitArguments(args)] + : gitArguments(args); + return run("git", location, options).trim(); +} + +function tryGit(repositoryPath, args, options = {}) { + const location = repositoryPath + ? ["-C", repositoryPath, ...gitArguments(args)] + : gitArguments(args); + return tryRun("git", location, options); +} + +function lines(value) { + return value + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function zeroTerminated(value) { + return value.split("\0").filter(Boolean); +} + +function readGitFile(repositoryPath, revision, path, environment) { + return run( + "git", + [ + "-C", + repositoryPath, + ...gitArguments(["show", `${revision}:${normalizePath(path)}`]), + ], + { env: environment }, + ); +} + +function validatePullRequest(pr, repository, defaultBranch, trustedSha) { + assert(pr && typeof pr === "object", "Pull request metadata is missing"); + const normalized = { + number: normalizePrNumber(pr.number), + state: pr.state, + isCrossRepository: pr.isCrossRepository, + headRepository: normalizeRepository(pr.headRepository), + baseRepository: normalizeRepository(pr.baseRepository), + headRefName: normalizeRef(pr.headRefName, "Pull request head ref"), + headSha: normalizeSha(pr.headSha, "Pull request head SHA"), + baseRefName: normalizeRef(pr.baseRefName, "Pull request base ref"), + baseSha: normalizeSha(pr.baseSha, "Pull request base SHA"), + }; + assert(normalized.state === "open", `PR #${normalized.number} is not open`); + assert( + normalized.isCrossRepository === false, + `PR #${normalized.number} is cross-repository`, + ); + assert( + normalized.headRepository === repository, + `PR #${normalized.number} head repository does not match ${repository}`, + ); + assert( + normalized.baseRepository === repository, + `PR #${normalized.number} base repository does not match ${repository}`, + ); + assert( + normalized.baseRefName === defaultBranch, + `PR #${normalized.number} targets ${normalized.baseRefName}, not ${defaultBranch}`, + ); + assert( + normalized.headRefName !== defaultBranch, + `PR #${normalized.number} head ref must not be the default branch`, + ); + assert( + normalized.headSha !== trustedSha, + `PR #${normalized.number} head unexpectedly equals the trusted revision`, + ); + return normalized; +} + +export function validateMetadata(metadata) { + assert(metadata && typeof metadata === "object", "Metadata is missing"); + const repository = normalizeRepository(metadata.repository); + const defaultBranch = normalizeRef( + metadata.defaultBranch, + "Default branch", + ); + const trustedSha = normalizeSha( + metadata.trustedSha, + "Trusted default-branch SHA", + ); + assert( + typeof metadata.runId === "string" && /^\d+$/.test(metadata.runId), + "Workflow run ID is invalid", + ); + assert( + Array.isArray(metadata.pullRequests), + "Pull request list is invalid", + ); + const pullRequests = metadata.pullRequests.map((pr) => + validatePullRequest(pr, repository, defaultBranch, trustedSha), + ); + assert( + new Set(pullRequests.map((pr) => pr.number)).size === + pullRequests.length, + "Pull request metadata contains duplicate numbers", + ); + assert( + new Set(pullRequests.map((pr) => pr.headRefName)).size === + pullRequests.length, + "Pull request metadata contains duplicate head refs", + ); + return { + repository, + defaultBranch, + trustedSha, + runId: metadata.runId, + pullRequests, + }; +} + +function validateMatchingPullRequest(expected, actual) { + for (const field of [ + "number", + "state", + "isCrossRepository", + "headRepository", + "baseRepository", + "headRefName", + "headSha", + "baseRefName", + ]) { + assert( + actual[field] === expected[field], + `PR #${expected.number} ${field} changed after candidate preparation`, + ); + } +} + +function initRepository({ directory, repositoryUrl, metadata, environment }) { + mkdirSync(directory, { recursive: true }); + git(null, ["init", "--bare", directory], { env: environment }); + git(directory, ["config", "user.name", BOT_NAME], { + env: environment, + }); + git(directory, ["config", "user.email", BOT_EMAIL], { + env: environment, + }); + git(directory, ["remote", "add", "origin", repositoryUrl], { + env: environment, + }); + + git( + directory, + [ + "fetch", + "--no-tags", + "origin", + `+refs/heads/${metadata.defaultBranch}:refs/heal/default`, + ], + { env: environment }, + ); + + const fetchedDefault = git( + directory, + ["rev-parse", "refs/heal/default^{commit}"], + { env: environment }, + ); + if (fetchedDefault !== metadata.trustedSha) { + fatalRepository( + `Default branch moved from trusted SHA ${metadata.trustedSha} to ${fetchedDefault}`, + ); + } + git( + directory, + ["merge-base", "--is-ancestor", metadata.trustedSha, fetchedDefault], + { env: environment }, + ); + + git(directory, ["fsck", "--strict"], { env: environment }); +} + +function fetchPullRequest(repositoryPath, pr, environment) { + const target = `refs/heal/heads/pr-${pr.number}`; + tryGit(repositoryPath, ["update-ref", "-d", target], { + env: environment, + }); + const fetched = tryGit( + repositoryPath, + [ + "fetch", + "--no-tags", + "origin", + `+refs/heads/${pr.headRefName}:${target}`, + ], + { env: environment }, + ); + if (!fetched.ok) { + retryCandidate( + `PR #${pr.number} head ${pr.headRefName} could not be fetched: ${fetched.output.trim()}`, + ); + } + const fetchedHead = git( + repositoryPath, + ["rev-parse", `${target}^{commit}`], + { env: environment }, + ); + if (fetchedHead !== pr.headSha) { + retryCandidate( + `PR #${pr.number} head moved from ${pr.headSha} to ${fetchedHead}`, + ); + } +} + +function changedPaths(repositoryPath, trustedSha, headSha, environment) { + return zeroTerminated( + run( + "git", + [ + "-C", + repositoryPath, + ...gitArguments([ + "diff", + "--name-only", + "-z", + `${trustedSha}...${headSha}`, + "--", + ]), + ], + { env: environment }, + ), + ).map(normalizeCandidatePath); +} + +function assertTrustedRootPackageFields( + repositoryPath, + trustedSha, + headSha, + environment, +) { + const trusted = JSON.parse( + readGitFile(repositoryPath, trustedSha, "ts/package.json", environment), + ); + const head = JSON.parse( + readGitFile(repositoryPath, headSha, "ts/package.json", environment), + ); + for (const field of TRUSTED_ROOT_PACKAGE_FIELDS) { + if (!deepEqual(trusted[field], head[field])) { + blockCandidate( + `PR changes trusted package-manager field ts/package.json#${field}`, + ); + } + } +} + +function assertSafePackageManagerInputs( + repositoryPath, + trustedSha, + headSha, + environment, +) { + const blockedPath = changedPaths( + repositoryPath, + trustedSha, + headSha, + environment, + ).find(isBlockedPackageManagerPath); + if (blockedPath) { + blockCandidate( + `PR changes executable package-manager configuration: ${blockedPath}`, + ); + } + assertTrustedRootPackageFields( + repositoryPath, + trustedSha, + headSha, + environment, + ); +} + +function assertTrustedMergeBehavior( + repositoryPath, + trustedSha, + headSha, + environment, +) { + const changed = zeroTerminated( + run( + "git", + [ + "-C", + repositoryPath, + ...gitArguments([ + "diff", + "--name-only", + "-z", + trustedSha, + headSha, + "--", + ]), + ], + { env: environment }, + ), + ).map(normalizeCandidatePath); + const blockedPath = changed.find((path) => + BLOCKED_MERGE_BEHAVIOR_PATHS.some((pattern) => pattern.test(path)), + ); + if (blockedPath) { + blockCandidate( + `PR merge behavior differs from the trusted revision: ${blockedPath}`, + ); + } +} + +function isRegularGitMode(mode) { + return mode === "100644" || mode === "100755"; +} + +function indexEntries(worktree, path, environment) { + const output = run( + "git", + [ + "-C", + worktree, + ...gitArguments(["ls-files", "--stage", "-z", "--", path]), + ], + { env: environment }, + ); + return zeroTerminated(output).map((entry) => { + const match = /^(\d{6}) ([0-9a-f]+) ([0-3])\t(.*)$/.exec(entry); + assert(match, `Invalid index entry for ${path}`); + return { + mode: match[1], + stage: Number(match[3]), + path: match[4], + }; + }); +} + +function assertRegularGeneratedConflict(worktree, path, environment) { + const entries = indexEntries(worktree, path, environment); + if ( + entries.length === 0 || + entries.some( + (entry) => entry.path !== path || !isRegularGitMode(entry.mode), + ) + ) { + blockCandidate(`Generated path has a non-regular Git mode: ${path}`); + } + const ours = entries.find((entry) => entry.stage === 2); + if (!ours) { + blockCandidate(`Generated path has no PR-side conflict entry: ${path}`); + } + return ours.mode; +} + +function containedRegularPath(root, path) { + const normalized = normalizeCandidatePath(path); + const rootPath = realpathSync(root); + const destination = resolve(root, ...normalized.split("/")); + const relativeDestination = relative(rootPath, destination); + if ( + relativeDestination === "" || + relativeDestination === ".." || + relativeDestination.startsWith(`..${sep}`) || + isAbsolute(relativeDestination) + ) { + blockCandidate(`Generated path escapes the worktree: ${path}`); + } + const parent = realpathSync(dirname(destination)); + const relativeParent = relative(rootPath, parent); + if ( + relativeParent === ".." || + relativeParent.startsWith(`..${sep}`) || + isAbsolute(relativeParent) + ) { + blockCandidate(`Generated path parent escapes the worktree: ${path}`); + } + const stat = lstatSync(destination); + if (!stat.isFile() || stat.isSymbolicLink()) { + blockCandidate( + `Generated path is not a regular filesystem file: ${path}`, + ); + } + return { destination, parent }; +} + +function atomicReplaceGeneratedFile({ + worktree, + path, + content, + sourcePath, + gitMode, +}) { + const { destination, parent } = containedRegularPath(worktree, path); + if (sourcePath) { + const sourceStat = lstatSync(sourcePath); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + blockCandidate( + `Generated source is not a regular file: ${sourcePath}`, + ); + } + } + const temporaryPath = join( + parent, + `.${basename(destination)}.heal-${randomUUID()}.tmp`, + ); + let descriptor; + try { + descriptor = openSync(temporaryPath, "wx", 0o600); + const bytes = sourcePath + ? readFileSync(sourcePath) + : Buffer.from(content); + writeFileSync(descriptor, bytes); + const temporaryStat = fstatSync(descriptor); + if (!temporaryStat.isFile()) { + blockCandidate(`Generated temporary path is not regular: ${path}`); + } + closeSync(descriptor); + descriptor = undefined; + chmodSync(temporaryPath, gitMode === "100755" ? 0o755 : 0o644); + renameSync(temporaryPath, destination); + } finally { + if (descriptor !== undefined) { + closeSync(descriptor); + } + rmSync(temporaryPath, { force: true }); + } + const finalPath = containedRegularPath(worktree, path); + assert( + realpathSync(finalPath.destination) === finalPath.destination, + `Generated path did not resolve to the expected file: ${path}`, + ); +} + +function stageGeneratedFile(worktree, path, expectedMode, environment) { + git(worktree, ["add", "--", path], { env: environment }); + const entries = indexEntries(worktree, path, environment); + if ( + entries.length !== 1 || + entries[0].stage !== 0 || + entries[0].path !== path || + entries[0].mode !== expectedMode || + !isRegularGitMode(entries[0].mode) + ) { + blockCandidate(`Generated path staged with an unsafe mode: ${path}`); + } +} + +function copyManifestInputs(worktree, scratchDirectory) { + const resolvedWorktree = realpathSync(worktree); + const resolvedScratch = realpathSync(scratchDirectory); + const manifestOutput = run("git", [ + "-C", + worktree, + ...gitArguments(["ls-files", "-z", "--", ":(glob)ts/**/package.json"]), + ]); + for (const path of zeroTerminated(manifestOutput)) { + if (path.includes("\\")) { + blockCandidate(`Manifest path contains a backslash: ${path}`); + } + const components = path.split("/"); + if ( + components.some( + (part) => part === "" || part === "." || part === "..", + ) || + components[0] !== "ts" || + components.at(-1) !== "package.json" + ) { + blockCandidate(`Unexpected manifest path: ${path}`); + } + const source = join(worktree, ...components); + const sourceStat = lstatSync(source); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + blockCandidate(`Manifest source is not a regular file: ${path}`); + } + const resolvedSource = realpathSync(source); + const sourceRelative = relative(resolvedWorktree, resolvedSource); + if ( + sourceRelative === "" || + sourceRelative === ".." || + sourceRelative.startsWith(`..${sep}`) || + isAbsolute(sourceRelative) + ) { + blockCandidate(`Manifest source escapes the worktree: ${path}`); + } + const destination = resolve(scratchDirectory, ...components.slice(1)); + const destinationRelative = relative(resolvedScratch, destination); + if ( + destinationRelative === "" || + destinationRelative === ".." || + destinationRelative.startsWith(`..${sep}`) || + isAbsolute(destinationRelative) + ) { + blockCandidate(`Manifest destination escapes scratch: ${path}`); + } + mkdirSync(dirname(destination), { recursive: true }); + const resolvedParent = realpathSync(dirname(destination)); + const parentRelative = relative(resolvedScratch, resolvedParent); + if ( + parentRelative === ".." || + parentRelative.startsWith(`..${sep}`) || + isAbsolute(parentRelative) + ) { + blockCandidate( + `Manifest destination parent escapes scratch: ${path}`, + ); + } + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + const destinationStat = lstatSync(destination); + if (!destinationStat.isFile() || destinationStat.isSymbolicLink()) { + blockCandidate( + `Manifest destination is not a regular file: ${path}`, + ); + } + } +} + +function writeTrustedPackageManagerInputs({ + repositoryPath, + trustedSha, + scratchDirectory, + environment, +}) { + for (const path of [ + "ts/pnpm-workspace.yaml", + "ts/pnpm-lock.yaml", + "ts/.pnpmfile.cjs", + ]) { + writeFileSync( + join(scratchDirectory, basename(path)), + readGitFile(repositoryPath, trustedSha, path, environment), + ); + } + writeFileSync( + join(scratchDirectory, ".npmrc"), + [ + "ignore-scripts=true", + "manage-package-manager-versions=false", + "enable-pre-post-scripts=false", + "", + ].join("\n"), + ); +} + +function runPnpm({ pnpmCommand, scratchDirectory, environment }) { + const command = resolve(pnpmCommand); + const pnpmArgs = [ + "install", + "--lockfile-only", + "--no-frozen-lockfile", + "--ignore-scripts", + "--config.manage-package-manager-versions=false", + ]; + const isNodeScript = /\.[cm]?js$/i.test(command); + const executable = isNodeScript ? process.execPath : command; + const args = isNodeScript ? [command, ...pnpmArgs] : pnpmArgs; + const result = spawnSync(executable, args, { + cwd: scratchDirectory, + env: environment, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw commandError(executable, args, result); + } +} + +function regenerateLockfile({ + repositoryPath, + worktree, + trustedSha, + pnpmCommand, + environment, + temporaryDirectory, +}) { + const scratchDirectory = join(temporaryDirectory, "pnpm-scratch"); + mkdirSync(scratchDirectory, { recursive: true }); + copyManifestInputs(worktree, scratchDirectory); + writeTrustedPackageManagerInputs({ + repositoryPath, + trustedSha, + scratchDirectory, + environment, + }); + runPnpm({ + pnpmCommand, + scratchDirectory, + environment, + }); + const generatedLockfile = join(scratchDirectory, "pnpm-lock.yaml"); + assert( + lstatSync(generatedLockfile).isFile(), + "pnpm did not generate pnpm-lock.yaml", + ); + return generatedLockfile; +} + +function unmergedFiles(worktree, environment) { + return zeroTerminated( + run( + "git", + [ + "-C", + worktree, + ...gitArguments([ + "diff", + "--name-only", + "--diff-filter=U", + "-z", + ]), + ], + { env: environment }, + ), + ).map(normalizeCandidatePath); +} + +function buildCandidateTree({ + repositoryPath, + pr, + trustedSha, + pnpmCommand, + environment, + temporaryDirectory, +}) { + mkdirSync(temporaryDirectory, { recursive: true }); + const worktree = join(temporaryDirectory, "worktree"); + git(repositoryPath, ["worktree", "add", "--detach", worktree, pr.headSha], { + env: environment, + }); + let conflicts = []; + try { + assertTrustedMergeBehavior( + repositoryPath, + trustedSha, + pr.headSha, + environment, + ); + const merge = tryGit( + worktree, + ["merge", "--no-ff", "--no-commit", trustedSha], + { env: environment }, + ); + conflicts = unmergedFiles(worktree, environment); + if (merge.ok && conflicts.length === 0) { + return { + status: "clean", + reason: "Merge has no generated-file conflicts", + }; + } + assert( + conflicts.length > 0, + `Merge failed without conflicts:\n${merge.output}`, + ); + const unsupported = conflicts.filter( + (path) => !isResolvableConflict(path), + ); + if (unsupported.length > 0) { + return { + status: "blocked", + reason: `Conflicts beyond generated files: ${unsupported.join(", ")}`, + conflicts, + }; + } + + for (const path of conflicts.filter(isAutogenReadme)) { + const mode = assertRegularGeneratedConflict( + worktree, + path, + environment, + ); + const content = run( + "git", + ["-C", worktree, ...gitArguments(["show", `:2:${path}`])], + { env: environment }, + ); + atomicReplaceGeneratedFile({ + worktree, + path, + content, + gitMode: mode, + }); + stageGeneratedFile(worktree, path, mode, environment); + } + if (conflicts.includes(LOCKFILE)) { + const mode = assertRegularGeneratedConflict( + worktree, + LOCKFILE, + environment, + ); + assertSafePackageManagerInputs( + repositoryPath, + trustedSha, + pr.headSha, + environment, + ); + const generatedLockfile = regenerateLockfile({ + repositoryPath, + worktree, + trustedSha, + pnpmCommand, + environment, + temporaryDirectory, + }); + atomicReplaceGeneratedFile({ + worktree, + path: LOCKFILE, + sourcePath: generatedLockfile, + gitMode: mode, + }); + stageGeneratedFile(worktree, LOCKFILE, mode, environment); + } + + const remaining = unmergedFiles(worktree, environment); + assert( + remaining.length === 0, + `Generated conflicts remain unresolved: ${remaining.join(", ")}`, + ); + git(worktree, ["diff", "--check", "--cached"], { + env: environment, + }); + return { + status: "candidate", + conflicts: [...conflicts].sort(), + treeSha: git(worktree, ["write-tree"], { env: environment }), + }; + } catch (error) { + if (error instanceof CandidateOutcomeError) { + error.conflicts = [...conflicts].sort(); + } + throw error; + } finally { + tryGit(worktree, ["merge", "--abort"], { env: environment }); + tryGit(repositoryPath, ["worktree", "remove", "--force", worktree], { + env: environment, + }); + } +} + +function createCandidateCommit({ + repositoryPath, + pr, + trustedSha, + treeSha, + preparedAt, + environment, +}) { + const commitEnvironment = { + ...environment, + GIT_AUTHOR_NAME: BOT_NAME, + GIT_AUTHOR_EMAIL: BOT_EMAIL, + GIT_AUTHOR_DATE: preparedAt, + GIT_COMMITTER_NAME: BOT_NAME, + GIT_COMMITTER_EMAIL: BOT_EMAIL, + GIT_COMMITTER_DATE: preparedAt, + }; + const message = candidateCommitMessage(pr.number, trustedSha); + const candidateSha = execFileSync( + "git", + [ + "-C", + repositoryPath, + ...gitArguments([ + "commit-tree", + treeSha, + "-p", + pr.headSha, + "-p", + trustedSha, + ]), + ], + { + env: commitEnvironment, + encoding: "utf8", + input: message, + stdio: ["pipe", "pipe", "pipe"], + }, + ).trim(); + normalizeSha(candidateSha, "Candidate commit SHA"); + git( + repositoryPath, + ["update-ref", `refs/heal/candidates/pr-${pr.number}`, candidateSha], + { env: environment }, + ); + return { candidateSha, message }; +} + +function candidateCommitMessage(number, trustedSha) { + return `${COMMIT_MESSAGE}\n\nPR #${normalizePrNumber(number)}\nTrusted-Base: ${normalizeSha(trustedSha, "Trusted default-branch SHA")}\n`; +} + +function createBundle({ + repositoryPath, + pr, + trustedSha, + candidateSha, + bundlePath, + environment, +}) { + git( + repositoryPath, + [ + "bundle", + "create", + bundlePath, + `refs/heal/candidates/pr-${pr.number}`, + `^${pr.headSha}`, + `^${trustedSha}`, + ], + { env: environment }, + ); + const heads = lines( + git(repositoryPath, ["bundle", "list-heads", bundlePath], { + env: environment, + }), + ); + assert( + heads.length === 1 && + heads[0] === `${candidateSha} refs/heal/candidates/pr-${pr.number}`, + `Candidate bundle for PR #${pr.number} has unexpected heads`, + ); +} + +export function prepareCandidateArtifact({ + metadata, + outputDirectory, + repositoryUrl, + pnpmCommand, + baseEnvironment = process.env, + workDirectory, + now = () => new Date(), +}) { + const validated = validateMetadata(metadata); + assert( + typeof repositoryUrl === "string" && repositoryUrl.length > 0, + "Repository URL is required", + ); + assert( + typeof pnpmCommand === "string" && pnpmCommand.length > 0, + "An absolute pnpm command is required", + ); + const temporaryRoot = + workDirectory ?? mkdtempSync(join(tmpdir(), "typeagent-heal-prepare-")); + const ownsTemporaryRoot = workDirectory === undefined; + const environment = createIsolatedEnvironment( + baseEnvironment, + join(temporaryRoot, "home"), + ); + const repositoryPath = join(temporaryRoot, "repository.git"); + rmSync(outputDirectory, { recursive: true, force: true }); + mkdirSync(outputDirectory, { recursive: true }); + + try { + initRepository({ + directory: repositoryPath, + repositoryUrl, + metadata: validated, + environment, + }); + const preparedDate = now(); + preparedDate.setMilliseconds(0); + const preparedAt = preparedDate.toISOString(); + const candidates = []; + const skipped = []; + for (const pr of validated.pullRequests) { + try { + fetchPullRequest(repositoryPath, pr, environment); + const result = buildCandidateTree({ + repositoryPath, + pr, + trustedSha: validated.trustedSha, + pnpmCommand, + environment, + temporaryDirectory: join(temporaryRoot, `pr-${pr.number}`), + }); + if (result.status !== "candidate") { + skipped.push({ + number: pr.number, + status: result.status, + reason: result.reason, + conflicts: result.conflicts ?? [], + }); + continue; + } + const commit = createCandidateCommit({ + repositoryPath, + pr, + trustedSha: validated.trustedSha, + treeSha: result.treeSha, + preparedAt, + environment, + }); + const bundleName = `pr-${pr.number}.bundle`; + createBundle({ + repositoryPath, + pr, + trustedSha: validated.trustedSha, + candidateSha: commit.candidateSha, + bundlePath: join(outputDirectory, bundleName), + environment, + }); + candidates.push({ + ...pr, + candidateSha: commit.candidateSha, + treeSha: result.treeSha, + conflicts: result.conflicts, + bundle: bundleName, + commitMessage: commit.message, + preparedAt, + }); + } catch (error) { + skipped.push(candidateOutcome(pr.number, error)); + } + } + git(repositoryPath, ["fsck", "--strict"], { env: environment }); + const summary = { + version: ARTIFACT_VERSION, + repository: validated.repository, + defaultBranch: validated.defaultBranch, + trustedSha: validated.trustedSha, + producerRunId: validated.runId, + preparedAt, + candidates, + skipped, + }; + writeJson(join(outputDirectory, "summary.json"), summary); + return summary; + } finally { + if (ownsTemporaryRoot) { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + } +} + +function validateCandidateSummary(summary, expectedRunId) { + assert(summary?.version === ARTIFACT_VERSION, "Unknown artifact version"); + const repository = normalizeRepository(summary.repository); + const defaultBranch = normalizeRef( + summary.defaultBranch, + "Artifact default branch", + ); + const trustedSha = normalizeSha(summary.trustedSha, "Artifact trusted SHA"); + assert( + summary.producerRunId === expectedRunId, + "Artifact producer run ID does not match workflow_run", + ); + assert( + typeof summary.preparedAt === "string" && + Number.isFinite(Date.parse(summary.preparedAt)), + "Artifact preparation timestamp is invalid", + ); + assert( + Array.isArray(summary.candidates), + "Artifact candidates are invalid", + ); + for (const candidate of summary.candidates) { + assert( + candidate.preparedAt === summary.preparedAt, + "Candidate preparation timestamp does not match the artifact", + ); + assert( + candidate.commitMessage === + candidateCommitMessage(candidate.number, trustedSha), + "Candidate commit message is invalid", + ); + } + return { + ...summary, + repository, + defaultBranch, + trustedSha, + candidates: summary.candidates.map((candidate) => ({ + ...validatePullRequest( + candidate, + repository, + defaultBranch, + trustedSha, + ), + candidateSha: normalizeSha(candidate.candidateSha, "Candidate SHA"), + treeSha: normalizeSha(candidate.treeSha, "Candidate tree SHA"), + conflicts: Array.isArray(candidate.conflicts) + ? candidate.conflicts.map(normalizePath).sort() + : fail("Candidate conflicts are invalid"), + bundle: candidate.bundle, + commitMessage: candidate.commitMessage, + preparedAt: candidate.preparedAt, + })), + }; +} + +function validateArtifactFiles(artifactDirectory, summary) { + const expected = new Set([ + "summary.json", + ...summary.candidates.map((candidate) => candidate.bundle), + ]); + const actual = readdirSync(artifactDirectory); + assert( + actual.length === expected.size && + actual.every((name) => expected.has(name)), + "Artifact contains unexpected or missing files", + ); + for (const name of actual) { + assert( + basename(name) === name && !name.includes(sep), + `Unsafe artifact path: ${name}`, + ); + const stat = lstatSync(join(artifactDirectory, name)); + assert( + stat.isFile() && !stat.isSymbolicLink(), + `Artifact entry ${name} is not a regular file`, + ); + } +} + +function verifyCommit({ repositoryPath, candidate, trustedSha, environment }) { + const type = git( + repositoryPath, + ["cat-file", "-t", candidate.candidateSha], + { env: environment }, + ); + assert(type === "commit", "Candidate object is not a commit"); + const parents = lines( + git( + repositoryPath, + ["show", "-s", "--format=%P", candidate.candidateSha], + { env: environment }, + ), + ) + .join(" ") + .split(" "); + assert( + parents.length === 2 && + parents[0] === candidate.headSha && + parents[1] === trustedSha, + `Candidate parents for PR #${candidate.number} are invalid`, + ); + const treeSha = git( + repositoryPath, + ["show", "-s", "--format=%T", candidate.candidateSha], + { env: environment }, + ); + assert( + treeSha === candidate.treeSha, + `Candidate tree metadata for PR #${candidate.number} is invalid`, + ); + const commitMetadata = lines( + git( + repositoryPath, + [ + "show", + "-s", + "--format=%an%n%ae%n%aI%n%cn%n%ce%n%cI", + candidate.candidateSha, + ], + { env: environment }, + ), + ); + assert( + commitMetadata.length === 6 && + commitMetadata[0] === BOT_NAME && + commitMetadata[1] === BOT_EMAIL && + commitMetadata[3] === BOT_NAME && + commitMetadata[4] === BOT_EMAIL && + Date.parse(commitMetadata[2]) === + Date.parse(candidate.preparedAt) && + Date.parse(commitMetadata[5]) === Date.parse(candidate.preparedAt), + `Candidate author metadata for PR #${candidate.number} is invalid`, + ); + const rawCommit = run( + "git", + [ + "-C", + repositoryPath, + ...gitArguments(["cat-file", "commit", candidate.candidateSha]), + ], + { env: environment }, + ); + const messageStart = rawCommit.indexOf("\n\n"); + assert( + messageStart !== -1, + `Candidate commit for PR #${candidate.number} has no message`, + ); + assert( + rawCommit.slice(messageStart + 2) === + candidateCommitMessage(candidate.number, trustedSha), + `Candidate commit message for PR #${candidate.number} is invalid`, + ); +} + +function importCandidateBundle({ + repositoryPath, + artifactDirectory, + candidate, + environment, +}) { + assert( + typeof candidate.bundle === "string" && + candidate.bundle === `pr-${candidate.number}.bundle`, + `Candidate bundle name for PR #${candidate.number} is invalid`, + ); + const bundlePath = resolve(artifactDirectory, candidate.bundle); + assert( + relative(resolve(artifactDirectory), bundlePath) === candidate.bundle, + `Candidate bundle path for PR #${candidate.number} escapes the artifact`, + ); + git(repositoryPath, ["bundle", "verify", bundlePath], { + env: environment, + }); + const heads = lines( + git(repositoryPath, ["bundle", "list-heads", bundlePath], { + env: environment, + }), + ); + assert( + heads.length === 1 && + heads[0] === + `${candidate.candidateSha} refs/heal/candidates/pr-${candidate.number}`, + `Candidate bundle for PR #${candidate.number} has unexpected heads`, + ); + git( + repositoryPath, + [ + "fetch", + "--no-tags", + bundlePath, + `refs/heal/candidates/pr-${candidate.number}:refs/heal/verified/pr-${candidate.number}`, + ], + { env: environment }, + ); + const imported = git( + repositoryPath, + ["rev-parse", `refs/heal/verified/pr-${candidate.number}^{commit}`], + { env: environment }, + ); + assert( + imported === candidate.candidateSha, + `Candidate bundle for PR #${candidate.number} imported the wrong commit`, + ); +} + +export function verifyCandidateArtifact({ + artifactDirectory, + liveMetadata, + expectedRunId, + repositoryUrl, + pnpmCommand, + planPath, + baseEnvironment = process.env, + workDirectory, +}) { + const live = validateMetadata(liveMetadata); + const summary = validateCandidateSummary( + readJson(join(artifactDirectory, "summary.json"), "summary.json"), + expectedRunId, + ); + if ( + summary.repository !== live.repository || + summary.defaultBranch !== live.defaultBranch || + summary.trustedSha !== live.trustedSha + ) { + fatalRepository( + "Artifact trusted repository revision is no longer current", + ); + } + validateArtifactFiles(artifactDirectory, summary); + + const liveByNumber = new Map( + live.pullRequests.map((pr) => [pr.number, pr]), + ); + const temporaryRoot = + workDirectory ?? mkdtempSync(join(tmpdir(), "typeagent-heal-verify-")); + const environment = createIsolatedEnvironment( + baseEnvironment, + join(temporaryRoot, "home"), + ); + const repositoryPath = join(temporaryRoot, "push.git"); + try { + initRepository({ + directory: repositoryPath, + repositoryUrl, + metadata: live, + environment, + }); + const verifiedCandidates = []; + const skipped = []; + for (const candidate of summary.candidates) { + try { + const current = liveByNumber.get(candidate.number); + if (!current) { + retryCandidate(`PR #${candidate.number} is no longer open`); + } + validateMatchingPullRequest(candidate, current); + fetchPullRequest(repositoryPath, current, environment); + assert( + candidate.conflicts.length > 0 && + candidate.conflicts.every(isResolvableConflict), + `Candidate conflicts for PR #${candidate.number} are invalid`, + ); + importCandidateBundle({ + repositoryPath, + artifactDirectory, + candidate, + environment, + }); + verifyCommit({ + repositoryPath, + candidate, + trustedSha: summary.trustedSha, + environment, + }); + const expected = buildCandidateTree({ + repositoryPath, + pr: current, + trustedSha: summary.trustedSha, + pnpmCommand, + environment, + temporaryDirectory: join( + temporaryRoot, + `expected-pr-${candidate.number}`, + ), + }); + if (expected.status !== "candidate") { + retryCandidate( + `PR #${candidate.number} no longer produces a healing candidate: ${expected.reason}`, + ); + } + if (!deepEqual(expected.conflicts, candidate.conflicts)) { + retryCandidate( + `Candidate conflict set for PR #${candidate.number} changed`, + ); + } + if (expected.treeSha !== candidate.treeSha) { + retryCandidate( + `Candidate tree for PR #${candidate.number} does not match trusted reconstruction`, + ); + } + verifiedCandidates.push(candidate); + } catch (error) { + skipped.push(candidateOutcome(candidate.number, error)); + } + } + git(repositoryPath, ["fsck", "--strict"], { env: environment }); + const plan = { + version: ARTIFACT_VERSION, + repository: live.repository, + defaultBranch: live.defaultBranch, + trustedSha: live.trustedSha, + repositoryUrl, + repositoryPath, + candidates: verifiedCandidates.map((candidate) => ({ + number: candidate.number, + headRefName: candidate.headRefName, + headSha: candidate.headSha, + baseRefName: candidate.baseRefName, + baseSha: candidate.baseSha, + headRepository: candidate.headRepository, + baseRepository: candidate.baseRepository, + isCrossRepository: candidate.isCrossRepository, + state: candidate.state, + candidateSha: candidate.candidateSha, + treeSha: candidate.treeSha, + })), + skipped, + }; + writeJson(planPath, plan); + return plan; + } catch (error) { + if (workDirectory === undefined) { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + throw error; + } +} + +function validatePushPlan(plan) { + assert(plan?.version === ARTIFACT_VERSION, "Unknown push plan version"); + const repository = normalizeRepository(plan.repository); + const defaultBranch = normalizeRef(plan.defaultBranch, "Default branch"); + const trustedSha = normalizeSha(plan.trustedSha, "Trusted SHA"); + assert( + typeof plan.repositoryPath === "string" && + lstatSync(plan.repositoryPath).isDirectory(), + "Verified bare repository is missing", + ); + assert(Array.isArray(plan.candidates), "Push plan candidates are invalid"); + assert( + plan.skipped === undefined || Array.isArray(plan.skipped), + "Push plan skipped outcomes are invalid", + ); + return { + ...plan, + repository, + defaultBranch, + trustedSha, + skipped: plan.skipped ?? [], + candidates: plan.candidates.map((candidate) => ({ + ...validatePullRequest( + candidate, + repository, + defaultBranch, + trustedSha, + ), + candidateSha: normalizeSha(candidate.candidateSha, "Candidate SHA"), + treeSha: normalizeSha(candidate.treeSha, "Candidate tree SHA"), + })), + }; +} + +function createAskPass(directory) { + const path = join(directory, "git-askpass.sh"); + writeFileSync( + path, + [ + "#!/bin/sh", + 'case "$1" in', + ' *Username*) printf "%s\\n" "x-access-token" ;;', + ' *) printf "%s\\n" "$HEAL_PUSH_TOKEN" ;;', + "esac", + "", + ].join("\n"), + ); + chmodSync(path, 0o700); + return path; +} + +export function createGitHubApi({ + repository, + token, + apiUrl = "https://api.github.com", + fetchImpl = fetch, +}) { + normalizeRepository(repository); + assert( + typeof token === "string" && token.length > 0, + "App token is missing", + ); + async function request(path) { + const response = await fetchImpl(`${apiUrl}${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!response.ok) { + fail(`GitHub API ${path} failed with HTTP ${response.status}`); + } + return response.json(); + } + return { + async getRepository() { + const data = await request(`/repos/${repository}`); + const defaultBranch = normalizeRef( + data.default_branch, + "Repository default branch", + ); + const branch = await request( + `/repos/${repository}/branches/${encodeURIComponent(defaultBranch)}`, + ); + return { + repository, + defaultBranch, + trustedSha: normalizeSha( + branch.commit.sha, + "Live default-branch SHA", + ), + }; + }, + async getPullRequest(number) { + const data = await request(`/repos/${repository}/pulls/${number}`); + return { + number: data.number, + state: data.state, + isCrossRepository: + data.head.repo?.full_name !== data.base.repo?.full_name, + headRepository: data.head.repo?.full_name, + baseRepository: data.base.repo?.full_name, + headRefName: data.head.ref, + headSha: data.head.sha, + baseRefName: data.base.ref, + baseSha: data.base.sha, + }; + }, + }; +} + +export async function pushVerifiedPlan({ + plan, + api, + token, + baseEnvironment = process.env, + push = null, +}) { + const validated = validatePushPlan(plan); + const liveRepository = await api.getRepository(); + if ( + liveRepository.repository !== validated.repository || + liveRepository.defaultBranch !== validated.defaultBranch || + liveRepository.trustedSha !== validated.trustedSha + ) { + fatalRepository( + "Repository default branch changed after final verification", + ); + } + + const pushHome = mkdtempSync(join(tmpdir(), "typeagent-heal-push-")); + const environment = createIsolatedEnvironment(baseEnvironment, pushHome); + const askPass = push ? null : createAskPass(pushHome); + const pushed = []; + const skipped = [...validated.skipped]; + try { + if (!push && validated.candidates.length > 0) { + if (typeof token !== "string" || token.length === 0) { + fatalRepository("App token is missing"); + } + git( + validated.repositoryPath, + ["remote", "set-url", "origin", validated.repositoryUrl], + { env: environment }, + ); + } + for (const candidate of validated.candidates) { + try { + const livePr = validatePullRequest( + await api.getPullRequest(candidate.number), + validated.repository, + validated.defaultBranch, + validated.trustedSha, + ); + validateMatchingPullRequest(candidate, livePr); + verifyCommit({ + repositoryPath: validated.repositoryPath, + candidate: { + ...candidate, + preparedAt: git( + validated.repositoryPath, + [ + "show", + "-s", + "--format=%aI", + candidate.candidateSha, + ], + { env: environment }, + ), + }, + trustedSha: validated.trustedSha, + environment, + }); + const pushArgs = [ + "--porcelain", + "--no-verify", + `--force-with-lease=refs/heads/${candidate.headRefName}:${candidate.headSha}`, + "origin", + `${candidate.candidateSha}:refs/heads/${candidate.headRefName}`, + ]; + if (push) { + await push({ + repositoryPath: validated.repositoryPath, + candidate, + args: pushArgs, + environment, + }); + } else { + const pushResult = tryGit( + validated.repositoryPath, + ["push", ...pushArgs], + { + env: { + ...environment, + GIT_ASKPASS: askPass, + HEAL_PUSH_TOKEN: token, + }, + }, + ); + if (!pushResult.ok) { + if ( + /authentication failed|could not read username|permission denied|http 40[13]/i.test( + pushResult.output, + ) + ) { + fatalRepository( + `App-authenticated push failed: ${pushResult.output.trim()}`, + ); + } + retryCandidate( + `Push for PR #${candidate.number} failed: ${pushResult.output.trim()}`, + ); + } + } + pushed.push(candidate.number); + } catch (error) { + skipped.push(candidateOutcome(candidate.number, error)); + } + } + return { pushed, skipped }; + } finally { + rmSync(pushHome, { recursive: true, force: true }); + } +} + +export function loadJsonFile(path) { + return readJson(path); +} diff --git a/ts/tools/scripts/test/generatedFileHealing.spec.mjs b/ts/tools/scripts/test/generatedFileHealing.spec.mjs new file mode 100644 index 0000000000..bcdea09b74 --- /dev/null +++ b/ts/tools/scripts/test/generatedFileHealing.spec.mjs @@ -0,0 +1,1897 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve, sep } from "node:path"; +import test from "node:test"; +import yaml from "js-yaml"; +import { + loadJsonFile, + prepareCandidateArtifact, + pushVerifiedPlan, + validateMetadata, + verifyCandidateArtifact, +} from "../lib/generatedFileHealing.mjs"; + +const REPOSITORY = "microsoft/TypeAgent"; +const PRODUCER_WORKFLOW = resolve( + import.meta.dirname, + "../../../../.github/workflows/heal-generated-files.yml", +); +const FINALIZER_WORKFLOW = resolve( + import.meta.dirname, + "../../../../.github/workflows/heal-generated-files-finalize.yml", +); +const HEALER_SCRIPT = resolve( + import.meta.dirname, + "../heal-generated-files.mjs", +); + +function checkedOutput(command, args, result) { + assert.ifError(result.error); + assert.equal( + result.status, + 0, + `${command} ${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`, + ); + return result.stdout.trim(); +} + +function runGit(args, options = {}) { + const result = spawnSync("git", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + return checkedOutput("git", args, result); +} + +function runNode(args, options = {}) { + const result = spawnSync(process.execPath, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + return checkedOutput(process.execPath, args, result); +} + +function locatePnpmWithWhich() { + const args = ["pnpm"]; + const result = spawnSync("which", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return checkedOutput("which", args, result); +} + +function locatePnpmWithWhere() { + const args = ["pnpm"]; + const result = spawnSync("where.exe", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return checkedOutput("where.exe", args, result); +} + +function git(repository, ...args) { + return runGit([ + "-c", + "core.hooksPath=/dev/null", + "-c", + "commit.gpgSign=false", + "-c", + "safe.bareRepository=all", + "-C", + repository, + ...args, + ]); +} + +function write(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function packageJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function createFakePnpm(directory, auditPath, options = {}) { + const script = join(directory, "fake-pnpm.mjs"); + write( + script, + `#!/usr/bin/env node +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +function manifests(directory) { + const values = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) values.push(...manifests(path)); + if (entry.isFile() && entry.name === "package.json") { + values.push(JSON.parse(readFileSync(path, "utf8"))); + } + } + return values; +} + +const visible = { + env: process.env, + homeGitConfig: (() => { + try { return readFileSync(join(process.env.HOME, "gitconfig"), "utf8"); } + catch { return ""; } + })(), + npmrc: readFileSync(join(process.cwd(), ".npmrc"), "utf8"), + pnpmfile: readFileSync(join(process.cwd(), ".pnpmfile.cjs"), "utf8"), +}; +writeFileSync(${JSON.stringify(auditPath)}, JSON.stringify(visible, null, 2)); +const packages = manifests(process.cwd()) + .map((pkg) => ({ name: pkg.name, dependencies: pkg.dependencies ?? {} })) + .sort((left, right) => String(left.name).localeCompare(String(right.name))); +if ( + ${JSON.stringify(options.failPackageName ?? null)} && + packages.some((pkg) => pkg.name === ${JSON.stringify(options.failPackageName ?? null)}) +) { + process.stderr.write("inert pnpm failure\\n"); + process.exit(2); +} +writeFileSync( + join(process.cwd(), "pnpm-lock.yaml"), + "lockfileVersion: '9.0'\\n# " + JSON.stringify(packages) + "\\n", +); +`, + ); + chmodSync(script, 0o700); + return script; +} + +function resolveStandalonePnpmCommand() { + if (process.platform !== "win32") { + return locatePnpmWithWhich(); + } + const candidates = []; + if (process.env.PNPM_HOME) { + candidates.push( + resolve(process.env.PNPM_HOME, "..", "pnpm", "bin", "pnpm.cjs"), + resolve( + process.env.PNPM_HOME, + "node_modules", + "pnpm", + "bin", + "pnpm.cjs", + ), + ); + } + for (const command of locatePnpmWithWhere().split(/\r?\n/)) { + candidates.push( + resolve( + dirname(command), + "node_modules", + "pnpm", + "bin", + "pnpm.cjs", + ), + ); + if (dirname(command).endsWith(`${sep}.bin`)) { + candidates.push( + resolve(dirname(command), "..", "pnpm", "bin", "pnpm.cjs"), + ); + } + } + const command = candidates.find(existsSync); + assert.ok(command, "Unable to locate the standalone pnpm entrypoint"); + return command; +} + +function createFixture(options = {}) { + const root = mkdtempSync(join(tmpdir(), "generated-healing-test-")); + const source = join(root, "source"); + const remote = join(root, "remote.git"); + const markerDirectory = join(root, "markers"); + mkdirSync(source, { recursive: true }); + mkdirSync(markerDirectory, { recursive: true }); + git(source, "init", "--initial-branch=main"); + git(source, "config", "user.name", "fixture"); + git(source, "config", "user.email", "fixture@example.com"); + + write( + join(source, "ts", "package.json"), + packageJson({ + name: "fixture-root", + private: true, + packageManager: "pnpm@11.9.0", + scripts: {}, + }), + ); + write( + join(source, "ts", "pnpm-workspace.yaml"), + "packages:\n - packages/*\n", + ); + write( + join(source, "ts", ".pnpmfile.cjs"), + `module.exports = { + hooks: { + readPackage(pkg) { + if (process.env.GH_TOKEN || process.env.GITHUB_TOKEN) { + throw new Error("credential exposed to trusted pnpmfile"); + } + if (pkg.name === "fixture-app") { + pkg.dependencies = { ...(pkg.dependencies || {}), "fixture-lib": "workspace:*" }; + } + return pkg; + }, + }, +}; +`, + ); + write(join(source, "ts", "pnpm-lock.yaml"), "lockfileVersion: base\n"); + write( + join(source, "ts", "packages", "app", "README.AUTOGEN.md"), + "base readme\n", + ); + write( + join(source, "ts", "packages", "app", "package.json"), + packageJson({ + name: "fixture-app", + version: "1.0.0", + scripts: {}, + }), + ); + write( + join(source, "ts", "packages", "lib", "package.json"), + packageJson({ name: "fixture-lib", version: "1.0.0" }), + ); + write( + join(source, "ts", "tools", "scripts", "heal-generated-files.mjs"), + 'console.log("trusted healer");\n', + ); + if (options.nonGeneratedConflict || options.maliciousMergeAttributes) { + write(join(source, "ts", "packages", "app", "source.ts"), "base\n"); + } + git(source, "add", "-A"); + git(source, "commit", "-m", "base"); + const historicalBaseSha = git(source, "rev-parse", "HEAD"); + + git(source, "checkout", "-b", "feature"); + write(join(source, "ts", "pnpm-lock.yaml"), "lockfileVersion: pr\n"); + const rootPackagePath = join(source, "ts", "package.json"); + const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")); + rootPackage.scripts.postinstall = `node -e "require('fs').writeFileSync('${join(markerDirectory, "root-lifecycle").replaceAll("\\", "\\\\")}','ran')"`; + if (options.maliciousPackageManager) { + rootPackage.packageManager = "pnpm@99.0.0"; + } + write(rootPackagePath, packageJson(rootPackage)); + write( + join(source, "ts", "packages", "app", "README.AUTOGEN.md"), + "pull request readme\n", + ); + write( + join(source, "ts", "packages", "app", "package.json"), + packageJson({ + name: "fixture-app", + version: "1.0.0", + scripts: { + postinstall: `node -e "require('fs').writeFileSync('${join(markerDirectory, "lifecycle").replaceAll("\\", "\\\\")}','ran')"`, + }, + }), + ); + write( + join(source, "ts", "tools", "scripts", "heal-generated-files.mjs"), + `require("node:fs").writeFileSync(${JSON.stringify(join(markerDirectory, "healer"))}, "ran");\n`, + ); + if (options.maliciousPnpmfile) { + write( + join(source, "ts", ".pnpmfile.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(join(markerDirectory, "pnpmfile"))}, "ran"); module.exports = {};\n`, + ); + } + if (options.maliciousNpmrc) { + write( + join(source, "ts", ".npmrc"), + `node-options=--require=${join(markerDirectory, "npmrc-loader.cjs")}\n`, + ); + write( + join(markerDirectory, "npmrc-loader.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(join(markerDirectory, "npmrc"))}, "ran");\n`, + ); + } + if (options.maliciousWorkspace) { + write( + join(source, "ts", "pnpm-workspace.yaml"), + "packages:\n - packages/*\nconfigDependencies:\n malicious: 1.0.0\n", + ); + } + if (options.nonGeneratedConflict) { + write( + join(source, "ts", "packages", "app", "source.ts"), + "pull request\n", + ); + } + if (options.maliciousMergeAttributes) { + write( + join(source, ".gitattributes"), + "ts/packages/app/source.ts merge=union\n", + ); + write( + join(source, "ts", "packages", "app", "source.ts"), + "pull request\n", + ); + } + git(source, "add", "-A"); + git(source, "commit", "-m", "pull request"); + const headSha = git(source, "rev-parse", "HEAD"); + + git(source, "checkout", "main"); + write(join(source, "ts", "pnpm-lock.yaml"), "lockfileVersion: main\n"); + write( + join(source, "ts", "packages", "app", "README.AUTOGEN.md"), + "main readme\n", + ); + write(join(source, "main-only.txt"), "main\n"); + if (options.nonGeneratedConflict) { + write(join(source, "ts", "packages", "app", "source.ts"), "main\n"); + } + if (options.maliciousMergeAttributes) { + write(join(source, "ts", "packages", "app", "source.ts"), "main\n"); + } + git(source, "add", "-A"); + git(source, "commit", "-m", "trusted main"); + const trustedSha = git(source, "rev-parse", "HEAD"); + + runGit(["clone", "--bare", source, remote]); + git(remote, "symbolic-ref", "HEAD", "refs/heads/main"); + const metadata = { + repository: REPOSITORY, + defaultBranch: "main", + trustedSha, + runId: "12345", + pullRequests: [ + { + number: 17, + state: "open", + isCrossRepository: false, + headRepository: REPOSITORY, + baseRepository: REPOSITORY, + headRefName: "feature", + headSha, + baseRefName: "main", + baseSha: historicalBaseSha, + }, + ], + }; + return { + root, + source, + remote, + markerDirectory, + metadata, + trustedSha, + historicalBaseSha, + headSha, + cleanup() { + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +function prepare(fixture, options = {}) { + const outputDirectory = join(fixture.root, "artifact"); + const auditPath = join(fixture.root, "pnpm-audit.json"); + const attackerHome = join(fixture.root, "attacker-home"); + const runnerTemp = join(fixture.root, "runner-temp-with-credentials"); + mkdirSync(attackerHome, { recursive: true }); + mkdirSync(runnerTemp, { recursive: true }); + write( + join(attackerHome, "gitconfig"), + '[http "https://github.com/"]\n\textraheader = AUTHORIZATION: sentinel-git-extraheader\n[include]\n\tpath = sentinel-checkout-credential-file\n', + ); + write( + join(runnerTemp, "checkout-credential"), + "sentinel-runner-temp-credential\n", + ); + const pnpmCommand = + options.pnpmCommand ?? + createFakePnpm(join(fixture.root, "tools"), auditPath); + const summary = prepareCandidateArtifact({ + metadata: fixture.metadata, + outputDirectory, + repositoryUrl: fixture.remote, + pnpmCommand, + baseEnvironment: { + ...process.env, + GH_TOKEN: "sentinel-gh-token", + GITHUB_TOKEN: "sentinel-github-token", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", + GIT_CONFIG_VALUE_0: "AUTHORIZATION: sentinel-git-config-env", + HOME: attackerHome, + RUNNER_TEMP: runnerTemp, + TEST_SECRET: "sentinel-test-secret", + }, + workDirectory: join(fixture.root, "prepare"), + now: () => new Date("2026-10-20T20:00:00.000Z"), + }); + return { summary, outputDirectory, auditPath, pnpmCommand }; +} + +function verify(fixture, prepared) { + const planPath = join(fixture.root, "plan.json"); + const plan = verifyCandidateArtifact({ + artifactDirectory: prepared.outputDirectory, + liveMetadata: fixture.metadata, + expectedRunId: fixture.metadata.runId, + repositoryUrl: fixture.remote, + pnpmCommand: prepared.pnpmCommand, + planPath, + baseEnvironment: process.env, + workDirectory: join(fixture.root, "verify"), + }); + return { plan, planPath }; +} + +function assertGeneratedSymlinkBlocked(reason, generatedPath, trustedSha) { + if (/non-regular Git mode|regular filesystem file/.test(reason)) { + return; + } + assert.ok( + reason.includes( + `Conflicts beyond generated files: ${generatedPath}~${trustedSha}`, + ), + `Unexpected generated-symlink rejection: ${reason}`, + ); +} + +function rewriteCandidateCommitMessage(fixture, prepared, message) { + const summaryPath = join(prepared.outputDirectory, "summary.json"); + const summary = JSON.parse(readFileSync(summaryPath, "utf8")); + const candidate = summary.candidates[0]; + const repositoryPath = join(fixture.root, "prepare", "repository.git"); + const environment = { + ...process.env, + GIT_AUTHOR_NAME: "typeagent-bot", + GIT_AUTHOR_EMAIL: "typeagent-bot[bot]@users.noreply.github.com", + GIT_AUTHOR_DATE: candidate.preparedAt, + GIT_COMMITTER_NAME: "typeagent-bot", + GIT_COMMITTER_EMAIL: "typeagent-bot[bot]@users.noreply.github.com", + GIT_COMMITTER_DATE: candidate.preparedAt, + }; + const candidateSha = runGit( + [ + "-c", + "commit.gpgSign=false", + "-c", + "safe.bareRepository=all", + "-C", + repositoryPath, + "commit-tree", + candidate.treeSha, + "-p", + candidate.headSha, + "-p", + fixture.trustedSha, + ], + { + env: environment, + input: message, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + git( + repositoryPath, + "update-ref", + `refs/heal/candidates/pr-${candidate.number}`, + candidateSha, + ); + const bundlePath = join(prepared.outputDirectory, candidate.bundle); + rmSync(bundlePath); + git( + repositoryPath, + "bundle", + "create", + bundlePath, + `refs/heal/candidates/pr-${candidate.number}`, + `^${candidate.headSha}`, + `^${fixture.trustedSha}`, + ); + candidate.candidateSha = candidateSha; + write(summaryPath, packageJson(summary)); +} + +function createForgedCandidateArtifact(fixture, path) { + const artifactDirectory = join(fixture.root, "forged-artifact"); + const repositoryPath = join(fixture.root, "forged-repository.git"); + const preparedAt = "2026-10-20T20:00:00.000Z"; + runGit(["clone", "--bare", fixture.remote, repositoryPath]); + const treeSha = git( + repositoryPath, + "rev-parse", + `${fixture.metadata.pullRequests[0].headSha}^{tree}`, + ); + const message = `chore: heal generated file conflicts\n\nPR #17\nTrusted-Base: ${fixture.trustedSha}\n`; + const candidateSha = runGit( + [ + "-c", + "commit.gpgSign=false", + "-c", + "safe.bareRepository=all", + "-C", + repositoryPath, + "commit-tree", + treeSha, + "-p", + fixture.metadata.pullRequests[0].headSha, + "-p", + fixture.trustedSha, + ], + { + env: { + ...process.env, + GIT_AUTHOR_NAME: "typeagent-bot", + GIT_AUTHOR_EMAIL: "typeagent-bot[bot]@users.noreply.github.com", + GIT_AUTHOR_DATE: preparedAt, + GIT_COMMITTER_NAME: "typeagent-bot", + GIT_COMMITTER_EMAIL: + "typeagent-bot[bot]@users.noreply.github.com", + GIT_COMMITTER_DATE: preparedAt, + }, + input: message, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + git( + repositoryPath, + "update-ref", + "refs/heal/candidates/pr-17", + candidateSha, + ); + mkdirSync(artifactDirectory, { recursive: true }); + git( + repositoryPath, + "bundle", + "create", + join(artifactDirectory, "pr-17.bundle"), + "refs/heal/candidates/pr-17", + `^${fixture.metadata.pullRequests[0].headSha}`, + `^${fixture.trustedSha}`, + ); + write( + join(artifactDirectory, "summary.json"), + packageJson({ + version: 1, + repository: REPOSITORY, + defaultBranch: "main", + trustedSha: fixture.trustedSha, + producerRunId: fixture.metadata.runId, + preparedAt, + candidates: [ + { + ...fixture.metadata.pullRequests[0], + candidateSha, + treeSha, + conflicts: [path], + bundle: "pr-17.bundle", + commitMessage: message, + preparedAt, + }, + ], + skipped: [], + }), + ); + return artifactDirectory; +} + +test("trusted healer prepares a candidate without exposing credentials or running PR code", () => { + const fixture = createFixture(); + try { + const prepared = prepare(fixture); + assert.equal(prepared.summary.candidates.length, 1); + assert.equal(prepared.summary.skipped.length, 0); + assert.equal( + existsSync(join(fixture.markerDirectory, "healer")), + false, + ); + assert.equal( + existsSync(join(fixture.markerDirectory, "lifecycle")), + false, + ); + assert.equal( + existsSync(join(fixture.markerDirectory, "root-lifecycle")), + false, + ); + + const audit = JSON.parse(readFileSync(prepared.auditPath, "utf8")); + const visible = JSON.stringify(audit); + assert.doesNotMatch(visible, /sentinel-(gh|github|test|git|runner)-/); + assert.doesNotMatch(visible, /runner-temp-with-credentials/); + assert.match(audit.pnpmfile, /trusted pnpmfile|readPackage/); + assert.match(audit.npmrc, /ignore-scripts=true/); + assert.equal(audit.env.GH_TOKEN, undefined); + assert.equal(audit.env.GITHUB_TOKEN, undefined); + assert.equal(audit.env.TEST_SECRET, undefined); + } finally { + fixture.cleanup(); + } +}); + +test("prepare CLI consumes metadata without invoking GitHub CLI or APIs", () => { + const fixture = createFixture(); + try { + const metadataPath = join(fixture.root, "metadata.json"); + const outputDirectory = join(fixture.root, "cli-artifact"); + const auditPath = join(fixture.root, "cli-pnpm-audit.json"); + const commandDirectory = join( + fixture.root, + "commands with spaces [literal]", + ); + const ghMarker = join(fixture.markerDirectory, "gh"); + const pnpmCommand = createFakePnpm(commandDirectory, auditPath); + const ghCommand = join( + commandDirectory, + process.platform === "win32" ? "gh.cmd" : "gh", + ); + write( + ghCommand, + process.platform === "win32" + ? `@echo off\r\nnode -e "require('fs').writeFileSync(${JSON.stringify(ghMarker)}, 'ran')"\r\nexit /b 99\r\n` + : `#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(ghMarker)}, "ran");\nprocess.exit(99);\n`, + ); + chmodSync(ghCommand, 0o700); + write(metadataPath, packageJson(fixture.metadata)); + + runNode( + [ + HEALER_SCRIPT, + "prepare", + "--metadata", + metadataPath, + "--output", + outputDirectory, + "--repository-url", + fixture.remote, + "--pnpm", + pnpmCommand, + ], + { + env: { + ...process.env, + GH_TOKEN: "", + GITHUB_API_URL: "https://invalid.example.test", + GITHUB_TOKEN: "", + PATH: `${commandDirectory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`, + }, + }, + ); + + assert.equal(existsSync(ghMarker), false); + assert.equal(existsSync(join(outputDirectory, "summary.json")), true); + const source = readFileSync(HEALER_SCRIPT, "utf8"); + const library = readFileSync( + resolve(import.meta.dirname, "../lib/generatedFileHealing.mjs"), + "utf8", + ); + assert.doesNotMatch(source, /(["'])gh\1/); + assert.doesNotMatch(library, /(["'])gh\1/); + } finally { + fixture.cleanup(); + } +}); + +for (const [name, options, marker, expected] of [ + [ + "pnpmfile", + { maliciousPnpmfile: true }, + "pnpmfile", + /executable package-manager configuration/, + ], + [ + "npmrc", + { maliciousNpmrc: true }, + "npmrc", + /executable package-manager configuration/, + ], + [ + "workspace resolver config", + { maliciousWorkspace: true }, + "workspace", + /executable package-manager configuration/, + ], + [ + "package manager pin", + { maliciousPackageManager: true }, + "package-manager", + /package\.json#packageManager/, + ], +]) { + test(`rejects PR-controlled ${name} before candidate creation`, () => { + const fixture = createFixture(options); + try { + const prepared = prepare(fixture); + assert.equal(prepared.summary.candidates.length, 0); + assert.equal(prepared.summary.skipped.length, 1); + assert.equal(prepared.summary.skipped[0].status, "blocked"); + assert.match(prepared.summary.skipped[0].reason, expected); + assert.equal( + existsSync(join(fixture.markerDirectory, marker)), + false, + ); + assert.equal( + existsSync(join(fixture.root, "artifact", "summary.json")), + true, + ); + } finally { + fixture.cleanup(); + } + }); +} + +test( + "literal backslash manifest paths cannot escape scratch through a symlink", + { skip: process.platform === "win32" }, + () => { + const fixture = createFixture(); + try { + const outsidePath = join( + fixture.root, + "outside-trusted-healer.mjs", + ); + write(outsidePath, "trusted outside content\n"); + git(fixture.source, "checkout", "feature"); + write( + join( + fixture.source, + "ts", + String.raw`00\..\..\worktree`, + "payload", + "package.json", + ), + packageJson({ name: "escape-payload", version: "1.0.0" }), + ); + mkdirSync(join(fixture.source, "payload"), { recursive: true }); + symlinkSync( + outsidePath, + join(fixture.source, "payload", "package.json"), + ); + git(fixture.source, "add", "-A"); + git(fixture.source, "commit", "-m", "add manifest escape"); + git(fixture.source, "push", fixture.remote, "feature"); + fixture.metadata.pullRequests[0].headSha = git( + fixture.source, + "rev-parse", + "HEAD", + ); + + const prepared = prepare(fixture); + assert.equal(prepared.summary.candidates.length, 0); + assert.equal(prepared.summary.skipped[0].status, "blocked"); + assert.match(prepared.summary.skipped[0].reason, /backslash/); + assert.equal( + readFileSync(outsidePath, "utf8"), + "trusted outside content\n", + ); + assert.equal( + git( + fixture.source, + "show", + "main:ts/tools/scripts/heal-generated-files.mjs", + ), + 'console.log("trusted healer");', + ); + } finally { + fixture.cleanup(); + } + }, +); + +test( + "symlinked package manifests are blocked before scratch copying", + { skip: process.platform === "win32" }, + () => { + const fixture = createFixture(); + try { + git(fixture.source, "checkout", "feature"); + mkdirSync(join(fixture.source, "ts", "packages", "symlinked"), { + recursive: true, + }); + symlinkSync( + "../app/package.json", + join( + fixture.source, + "ts", + "packages", + "symlinked", + "package.json", + ), + ); + git(fixture.source, "add", "-A"); + git(fixture.source, "commit", "-m", "add symlink manifest"); + git(fixture.source, "push", fixture.remote, "feature"); + fixture.metadata.pullRequests[0].headSha = git( + fixture.source, + "rev-parse", + "HEAD", + ); + + const prepared = prepare(fixture); + assert.equal(prepared.summary.candidates.length, 0); + assert.equal(prepared.summary.skipped[0].status, "blocked"); + assert.match( + prepared.summary.skipped[0].reason, + /not a regular file/, + ); + } finally { + fixture.cleanup(); + } + }, +); + +for (const generatedPath of [ + "ts/pnpm-lock.yaml", + "ts/packages/app/README.AUTOGEN.md", +]) { + test( + `generated symlink ${generatedPath} cannot overwrite an outside file during preparation`, + { skip: process.platform === "win32" }, + () => { + const fixture = createFixture(); + try { + const outsidePath = join( + fixture.root, + `${basename(generatedPath)}-outside`, + ); + write(outsidePath, "outside trusted content\n"); + git(fixture.source, "checkout", "feature"); + rmSync(join(fixture.source, ...generatedPath.split("/"))); + symlinkSync( + outsidePath, + join(fixture.source, ...generatedPath.split("/")), + ); + git(fixture.source, "add", "-A"); + git(fixture.source, "commit", "-m", "symlink generated file"); + git(fixture.source, "push", fixture.remote, "feature"); + fixture.metadata.pullRequests[0].headSha = git( + fixture.source, + "rev-parse", + "HEAD", + ); + + const prepared = prepare(fixture); + assert.equal(prepared.summary.candidates.length, 0); + assert.equal(prepared.summary.skipped.length, 1); + assertGeneratedSymlinkBlocked( + prepared.summary.skipped[0].reason, + generatedPath, + fixture.trustedSha, + ); + assert.equal( + readFileSync(outsidePath, "utf8"), + "outside trusted content\n", + ); + assert.equal( + existsSync( + join(fixture.root, "prepare", "pr-17", "worktree"), + ), + false, + ); + } finally { + fixture.cleanup(); + } + }, + ); +} + +test( + "privileged reconstruction blocks a forged generated symlink candidate", + { skip: process.platform === "win32" }, + () => { + const fixture = createFixture(); + try { + const outsidePath = join( + fixture.root, + "privileged-reconstruction-outside", + ); + write(outsidePath, "outside trusted content\n"); + git(fixture.source, "checkout", "feature"); + rmSync(join(fixture.source, "ts", "pnpm-lock.yaml")); + symlinkSync( + outsidePath, + join(fixture.source, "ts", "pnpm-lock.yaml"), + ); + git(fixture.source, "add", "-A"); + git(fixture.source, "commit", "-m", "symlink lockfile"); + git(fixture.source, "push", fixture.remote, "feature"); + fixture.metadata.pullRequests[0].headSha = git( + fixture.source, + "rev-parse", + "HEAD", + ); + const artifactDirectory = createForgedCandidateArtifact( + fixture, + "ts/pnpm-lock.yaml", + ); + + const plan = verifyCandidateArtifact({ + artifactDirectory, + liveMetadata: fixture.metadata, + expectedRunId: fixture.metadata.runId, + repositoryUrl: fixture.remote, + pnpmCommand: createFakePnpm( + join(fixture.root, "forged-tools"), + join(fixture.root, "forged-audit"), + ), + planPath: join(fixture.root, "forged-plan.json"), + workDirectory: join(fixture.root, "forged-verify"), + }); + assert.equal(plan.candidates.length, 0); + assert.equal(plan.skipped.length, 1); + assertGeneratedSymlinkBlocked( + plan.skipped[0].reason, + "ts/pnpm-lock.yaml", + fixture.trustedSha, + ); + assert.equal( + readFileSync(outsidePath, "utf8"), + "outside trusted content\n", + ); + assert.equal( + existsSync( + join( + fixture.root, + "forged-verify", + "expected-pr-17", + "worktree", + ), + ), + false, + ); + } finally { + fixture.cleanup(); + } + }, +); + +test("isolated repository configures the fixed bot identity before merging", () => { + const fixture = createFixture(); + try { + const prepared = prepare(fixture); + assert.equal(prepared.summary.candidates.length, 1); + const repositoryPath = join(fixture.root, "prepare", "repository.git"); + assert.equal( + git(repositoryPath, "config", "--local", "user.name"), + "typeagent-bot", + ); + assert.equal( + git(repositoryPath, "config", "--local", "user.email"), + "typeagent-bot[bot]@users.noreply.github.com", + ); + assert.equal( + readFileSync( + join(fixture.root, "prepare", "home", "gitconfig"), + "utf8", + ), + "", + ); + } finally { + fixture.cleanup(); + } +}); + +test("a blocked malicious PR does not prevent another PR candidate", () => { + const fixture = createFixture(); + try { + git(fixture.source, "checkout", "feature"); + git(fixture.source, "checkout", "-b", "malicious-feature"); + write( + join(fixture.source, "ts", ".pnpmfile.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(join(fixture.markerDirectory, "mixed-pnpmfile"))}, "ran"); module.exports = {};\n`, + ); + git(fixture.source, "add", "ts/.pnpmfile.cjs"); + git(fixture.source, "commit", "-m", "add malicious pnpmfile"); + git(fixture.source, "push", fixture.remote, "malicious-feature"); + const maliciousHead = git(fixture.source, "rev-parse", "HEAD"); + git(fixture.remote, "branch", "valid-feature-2", fixture.headSha); + fixture.metadata.pullRequests = [ + fixture.metadata.pullRequests[0], + { + ...fixture.metadata.pullRequests[0], + number: 18, + headRefName: "malicious-feature", + headSha: maliciousHead, + }, + { + ...fixture.metadata.pullRequests[0], + number: 19, + headRefName: "valid-feature-2", + }, + ]; + + const prepared = prepare(fixture); + assert.deepEqual( + prepared.summary.candidates.map((candidate) => candidate.number), + [17, 19], + ); + assert.deepEqual( + prepared.summary.skipped.map((candidate) => candidate.number), + [18], + ); + assert.equal(prepared.summary.skipped[0].status, "blocked"); + assert.match( + prepared.summary.skipped[0].reason, + /executable package-manager configuration/, + ); + assert.equal( + existsSync(join(fixture.markerDirectory, "mixed-pnpmfile")), + false, + ); + assert.equal( + existsSync(join(prepared.outputDirectory, "summary.json")), + true, + ); + } finally { + fixture.cleanup(); + } +}); + +test("deleted heads, malformed manifests, and pnpm failures do not abort a scan", () => { + const fixture = createFixture(); + try { + git(fixture.remote, "branch", "valid-feature-2", fixture.headSha); + + git(fixture.source, "checkout", "-B", "invalid-json", "feature"); + write( + join(fixture.source, "ts", "packages", "app", "package.json"), + "{ invalid json\n", + ); + git(fixture.source, "add", "-A"); + git(fixture.source, "commit", "-m", "invalid manifest"); + git(fixture.source, "push", fixture.remote, "invalid-json"); + const invalidJsonHead = git(fixture.source, "rev-parse", "HEAD"); + + git(fixture.source, "checkout", "-B", "pnpm-failure", "feature"); + const appPath = join( + fixture.source, + "ts", + "packages", + "app", + "package.json", + ); + const app = JSON.parse(readFileSync(appPath, "utf8")); + app.name = "trigger-pnpm-failure"; + write(appPath, packageJson(app)); + git(fixture.source, "add", "-A"); + git(fixture.source, "commit", "-m", "trigger pnpm failure"); + git(fixture.source, "push", fixture.remote, "pnpm-failure"); + const pnpmFailureHead = git(fixture.source, "rev-parse", "HEAD"); + + const original = fixture.metadata.pullRequests[0]; + fixture.metadata.pullRequests = [ + { + ...original, + number: 18, + headRefName: "deleted-feature", + }, + { + ...original, + number: 19, + headRefName: "invalid-json", + headSha: invalidJsonHead, + }, + { + ...original, + number: 20, + headRefName: "pnpm-failure", + headSha: pnpmFailureHead, + }, + original, + { + ...original, + number: 21, + headRefName: "valid-feature-2", + }, + ]; + const outputDirectory = join(fixture.root, "isolated-artifact"); + const summary = prepareCandidateArtifact({ + metadata: fixture.metadata, + outputDirectory, + repositoryUrl: fixture.remote, + pnpmCommand: createFakePnpm( + join(fixture.root, "isolated-tools"), + join(fixture.root, "isolated-audit"), + { failPackageName: "trigger-pnpm-failure" }, + ), + workDirectory: join(fixture.root, "isolated-prepare"), + }); + + assert.deepEqual( + summary.candidates.map((candidate) => candidate.number), + [17, 21], + ); + assert.deepEqual( + summary.skipped.map((candidate) => candidate.number), + [18, 19, 20], + ); + assert.ok( + summary.skipped.every( + (candidate) => candidate.status === "retryable", + ), + ); + assert.match(summary.skipped[0].reason, /could not be fetched/); + assert.match(summary.skipped[1].reason, /JSON|failed with exit code/); + assert.match(summary.skipped[2].reason, /failed with exit code/); + assert.equal(existsSync(join(outputDirectory, "summary.json")), true); + } finally { + fixture.cleanup(); + } +}); + +test("valid candidate preserves the PR README and exact merge parents", () => { + const fixture = createFixture(); + try { + assert.notEqual(fixture.historicalBaseSha, fixture.trustedSha); + assert.equal( + fixture.metadata.pullRequests[0].baseSha, + fixture.historicalBaseSha, + ); + const prepared = prepare(fixture); + assert.equal( + prepared.summary.candidates[0].commitMessage, + `chore: heal generated file conflicts\n\nPR #17\nTrusted-Base: ${fixture.trustedSha}\n`, + ); + const { plan } = verify(fixture, prepared); + const candidate = plan.candidates[0]; + const parents = git( + plan.repositoryPath, + "show", + "-s", + "--format=%P", + candidate.candidateSha, + ).split(" "); + assert.deepEqual(parents, [fixture.headSha, fixture.trustedSha]); + assert.equal( + git( + plan.repositoryPath, + "show", + `${candidate.candidateSha}:ts/packages/app/README.AUTOGEN.md`, + ), + "pull request readme", + ); + assert.match( + git( + plan.repositoryPath, + "show", + `${candidate.candidateSha}:ts/pnpm-lock.yaml`, + ), + /fixture-lib/, + ); + } finally { + fixture.cleanup(); + } +}); + +test("finalization accepts a historical PR base SHA distinct from trusted main", () => { + const fixture = createFixture(); + try { + const prepared = prepare(fixture); + const liveMetadata = { + ...fixture.metadata, + pullRequests: [ + { + ...fixture.metadata.pullRequests[0], + baseSha: "3".repeat(40), + }, + ], + }; + const plan = verifyCandidateArtifact({ + artifactDirectory: prepared.outputDirectory, + liveMetadata, + expectedRunId: fixture.metadata.runId, + repositoryUrl: fixture.remote, + pnpmCommand: prepared.pnpmCommand, + planPath: join(fixture.root, "historical-base-plan.json"), + workDirectory: join(fixture.root, "historical-base-verify"), + }); + + assert.deepEqual( + plan.candidates.map((candidate) => candidate.number), + [17], + ); + assert.equal(plan.skipped.length, 0); + const parents = git( + plan.repositoryPath, + "show", + "-s", + "--format=%P", + plan.candidates[0].candidateSha, + ).split(" "); + assert.deepEqual(parents, [fixture.headSha, fixture.trustedSha]); + } finally { + fixture.cleanup(); + } +}); + +test("artifact metadata rejects commit message suffixes, newlines, and directives", () => { + const fixture = createFixture(); + try { + const prepared = prepare(fixture); + const summaryPath = join(prepared.outputDirectory, "summary.json"); + const original = JSON.parse(readFileSync(summaryPath, "utf8")); + const expected = original.candidates[0].commitMessage; + for (const [name, message] of [ + ["suffix", `${expected}unexpected suffix\n`], + ["newline", `${expected}\n`], + ["directive", `${expected}[skip ci]\n`], + ]) { + const changed = structuredClone(original); + changed.candidates[0].commitMessage = message; + write(summaryPath, packageJson(changed)); + assert.throws( + () => + verifyCandidateArtifact({ + artifactDirectory: prepared.outputDirectory, + liveMetadata: fixture.metadata, + expectedRunId: fixture.metadata.runId, + repositoryUrl: fixture.remote, + pnpmCommand: prepared.pnpmCommand, + planPath: join(fixture.root, `${name}-plan.json`), + workDirectory: join(fixture.root, `${name}-verify`), + }), + /commit message is invalid/, + ); + } + write(summaryPath, packageJson(original)); + } finally { + fixture.cleanup(); + } +}); + +test("commit verification rejects an object with a check-suppression directive", () => { + const fixture = createFixture(); + try { + const prepared = prepare(fixture); + const expected = prepared.summary.candidates[0].commitMessage; + rewriteCandidateCommitMessage( + fixture, + prepared, + `${expected}[skip ci]\n`, + ); + const { plan } = verify(fixture, prepared); + assert.equal(plan.candidates.length, 0); + assert.equal(plan.skipped.length, 1); + assert.match( + plan.skipped[0].reason, + /commit message for PR #17 is invalid/, + ); + } finally { + fixture.cleanup(); + } +}); + +test("verified push advances only the exact PR head", async () => { + const fixture = createFixture(); + try { + git(fixture.remote, "branch", "unrelated", fixture.trustedSha); + const prepared = prepare(fixture); + const { plan } = verify(fixture, prepared); + const originalMain = git( + fixture.remote, + "rev-parse", + "refs/heads/main", + ); + const originalUnrelated = git( + fixture.remote, + "rev-parse", + "refs/heads/unrelated", + ); + const api = { + async getRepository() { + return { + repository: REPOSITORY, + defaultBranch: "main", + trustedSha: fixture.trustedSha, + }; + }, + async getPullRequest() { + return fixture.metadata.pullRequests[0]; + }, + }; + await pushVerifiedPlan({ plan, api, token: "inert-test-token" }); + assert.equal( + git(fixture.remote, "rev-parse", "refs/heads/feature"), + plan.candidates[0].candidateSha, + ); + assert.equal( + git(fixture.remote, "rev-parse", "refs/heads/main"), + originalMain, + ); + assert.equal( + git(fixture.remote, "rev-parse", "refs/heads/unrelated"), + originalUnrelated, + ); + } finally { + fixture.cleanup(); + } +}); + +test("exact-head lease rejects a race after final verification", async () => { + const fixture = createFixture(); + try { + const prepared = prepare(fixture); + const { plan } = verify(fixture, prepared); + git(fixture.source, "checkout", "feature"); + write(join(fixture.source, "race.txt"), "moved\n"); + git(fixture.source, "add", "race.txt"); + git(fixture.source, "commit", "-m", "move head"); + git(fixture.source, "push", fixture.remote, "feature"); + const movedHead = git( + fixture.remote, + "rev-parse", + "refs/heads/feature", + ); + const api = { + async getRepository() { + return { + repository: REPOSITORY, + defaultBranch: "main", + trustedSha: fixture.trustedSha, + }; + }, + async getPullRequest() { + return fixture.metadata.pullRequests[0]; + }, + }; + const result = await pushVerifiedPlan({ + plan, + api, + token: "inert-test-token", + }); + assert.deepEqual(result.pushed, []); + assert.equal(result.skipped.length, 1); + assert.match( + result.skipped[0].reason, + /stale info|failed with exit code/, + ); + assert.equal( + git(fixture.remote, "rev-parse", "refs/heads/feature"), + movedHead, + ); + } finally { + fixture.cleanup(); + } +}); + +test("one raced push does not prevent another verified candidate from advancing", async () => { + const fixture = createFixture(); + try { + git(fixture.remote, "branch", "feature-race", fixture.headSha); + git(fixture.remote, "branch", "feature-valid", fixture.headSha); + const original = fixture.metadata.pullRequests[0]; + fixture.metadata.pullRequests = [ + { + ...original, + number: 18, + headRefName: "feature-race", + }, + { + ...original, + number: 19, + headRefName: "feature-valid", + }, + ]; + const prepared = prepare(fixture); + const { plan } = verify(fixture, prepared); + + git(fixture.source, "checkout", "-B", "feature-race", "feature"); + write(join(fixture.source, "race.txt"), "moved\n"); + git(fixture.source, "add", "race.txt"); + git(fixture.source, "commit", "-m", "race candidate"); + git(fixture.source, "push", fixture.remote, "feature-race"); + const movedRace = git( + fixture.remote, + "rev-parse", + "refs/heads/feature-race", + ); + const api = { + async getRepository() { + return { + repository: REPOSITORY, + defaultBranch: "main", + trustedSha: fixture.trustedSha, + }; + }, + async getPullRequest(number) { + return fixture.metadata.pullRequests.find( + (pr) => pr.number === number, + ); + }, + }; + const result = await pushVerifiedPlan({ + plan, + api, + token: "inert-test-token", + }); + + assert.deepEqual(result.pushed, [19]); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].number, 18); + assert.equal( + git(fixture.remote, "rev-parse", "refs/heads/feature-race"), + movedRace, + ); + assert.equal( + git(fixture.remote, "rev-parse", "refs/heads/feature-valid"), + plan.candidates.find((candidate) => candidate.number === 19) + .candidateSha, + ); + } finally { + fixture.cleanup(); + } +}); + +test("final API revalidation rejects changed PR identity without updating a ref", async () => { + const fixture = createFixture(); + try { + const prepared = prepare(fixture); + const { plan } = verify(fixture, prepared); + const expectedHead = git( + fixture.remote, + "rev-parse", + "refs/heads/feature", + ); + const variants = [ + { state: "closed" }, + { isCrossRepository: true }, + { headRepository: "attacker/fork" }, + { baseRepository: "attacker/fork" }, + { baseRefName: "release" }, + { headRefName: "renamed" }, + { headSha: "4".repeat(40) }, + ]; + for (const variant of variants) { + const api = { + async getRepository() { + return { + repository: REPOSITORY, + defaultBranch: "main", + trustedSha: fixture.trustedSha, + }; + }, + async getPullRequest() { + return { + ...fixture.metadata.pullRequests[0], + ...variant, + }; + }, + }; + const result = await pushVerifiedPlan({ + plan, + api, + token: "inert-test-token", + }); + assert.deepEqual(result.pushed, []); + assert.equal(result.skipped.length, 1); + assert.equal( + git(fixture.remote, "rev-parse", "refs/heads/feature"), + expectedHead, + ); + } + } finally { + fixture.cleanup(); + } +}); + +test("non-generated conflicts produce no candidate and update no ref", () => { + const fixture = createFixture({ nonGeneratedConflict: true }); + try { + const prepared = prepare(fixture); + assert.equal(prepared.summary.candidates.length, 0); + assert.equal(prepared.summary.skipped[0].status, "blocked"); + assert.match(prepared.summary.skipped[0].reason, /source\.ts/); + assert.equal( + git(fixture.remote, "rev-parse", "refs/heads/feature"), + fixture.headSha, + ); + } finally { + fixture.cleanup(); + } +}); + +test("PR-controlled merge attributes cannot hide a non-generated conflict", () => { + const fixture = createFixture({ maliciousMergeAttributes: true }); + try { + const prepared = prepare(fixture); + assert.equal(prepared.summary.candidates.length, 0); + assert.equal(prepared.summary.skipped.length, 1); + assert.equal(prepared.summary.skipped[0].status, "blocked"); + assert.match( + prepared.summary.skipped[0].reason, + /merge behavior differs.*\.gitattributes/, + ); + assert.equal( + git(fixture.remote, "rev-parse", "refs/heads/feature"), + fixture.headSha, + ); + } finally { + fixture.cleanup(); + } +}); + +test("missing, malformed, or moved trusted revisions fail before candidate creation", () => { + const fixture = createFixture(); + try { + for (const trustedSha of [ + "not-a-sha", + "0".repeat(40), + fixture.headSha, + ]) { + const outputDirectory = join( + fixture.root, + `artifact-${trustedSha.slice(0, 8)}`, + ); + assert.throws( + () => + prepareCandidateArtifact({ + metadata: { + ...fixture.metadata, + trustedSha, + pullRequests: fixture.metadata.pullRequests.map( + (pr) => ({ ...pr, baseSha: trustedSha }), + ), + }, + outputDirectory, + repositoryUrl: fixture.remote, + pnpmCommand: createFakePnpm( + join( + fixture.root, + `tools-${trustedSha.slice(0, 8)}`, + ), + join(fixture.root, "unused-audit.json"), + ), + workDirectory: join( + fixture.root, + `work-${trustedSha.slice(0, 8)}`, + ), + }), + /full lowercase commit SHA|couldn't find remote ref|Default branch moved|head unexpectedly equals/, + ); + assert.equal( + existsSync(join(outputDirectory, "summary.json")), + false, + ); + } + } finally { + fixture.cleanup(); + } +}); + +for (const [name, mutate, expected] of [ + ["fork", (pr) => ({ ...pr, isCrossRepository: true }), /cross-repository/], + [ + "wrong repository", + (pr) => ({ ...pr, headRepository: "attacker/fork" }), + /head repository/, + ], + [ + "wrong base repository", + (pr) => ({ ...pr, baseRepository: "attacker/fork" }), + /base repository/, + ], + [ + "wrong base", + (pr) => ({ ...pr, baseRefName: "release" }), + /targets release/, + ], + ["closed PR", (pr) => ({ ...pr, state: "closed" }), /is not open/], + [ + "moved head", + (pr) => ({ ...pr, headSha: "1".repeat(40) }), + /head moved|changed/, + ], + [ + "renamed head", + (pr) => ({ ...pr, headRefName: "renamed" }), + /head ref|couldn't find remote ref/, + ], +]) { + test(`${name} metadata fails closed`, () => { + const fixture = createFixture(); + try { + const metadata = { + ...fixture.metadata, + pullRequests: [mutate(fixture.metadata.pullRequests[0])], + }; + if (name === "moved head" || name === "renamed head") { + const summary = prepareCandidateArtifact({ + metadata, + outputDirectory: join(fixture.root, "bad-artifact"), + repositoryUrl: fixture.remote, + pnpmCommand: createFakePnpm( + join(fixture.root, "bad-tools"), + join(fixture.root, "bad-audit"), + ), + workDirectory: join(fixture.root, "bad-work"), + }); + assert.equal(summary.candidates.length, 0); + assert.equal(summary.skipped.length, 1); + assert.match(summary.skipped[0].reason, expected); + } else { + assert.throws(() => validateMetadata(metadata), expected); + } + } finally { + fixture.cleanup(); + } + }); +} + +test("artifact verification rejects stale live PR metadata", () => { + const fixture = createFixture(); + try { + const prepared = prepare(fixture); + const stale = { + ...fixture.metadata, + pullRequests: [ + { + ...fixture.metadata.pullRequests[0], + headSha: "2".repeat(40), + }, + ], + }; + const plan = verifyCandidateArtifact({ + artifactDirectory: prepared.outputDirectory, + liveMetadata: stale, + expectedRunId: fixture.metadata.runId, + repositoryUrl: fixture.remote, + pnpmCommand: prepared.pnpmCommand, + planPath: join(fixture.root, "stale-plan.json"), + workDirectory: join(fixture.root, "stale-verify"), + }); + assert.equal(plan.candidates.length, 0); + assert.equal(plan.skipped.length, 1); + assert.match(plan.skipped[0].reason, /headSha changed/); + } finally { + fixture.cleanup(); + } +}); + +test("one stale artifact candidate does not prevent another from finalizing", () => { + const fixture = createFixture(); + try { + git(fixture.remote, "branch", "valid-feature-2", fixture.headSha); + const original = fixture.metadata.pullRequests[0]; + fixture.metadata.pullRequests = [ + original, + { + ...original, + number: 18, + headRefName: "valid-feature-2", + }, + ]; + const prepared = prepare(fixture); + const liveMetadata = { + ...fixture.metadata, + pullRequests: [ + { + ...original, + headSha: "2".repeat(40), + }, + fixture.metadata.pullRequests[1], + ], + }; + const plan = verifyCandidateArtifact({ + artifactDirectory: prepared.outputDirectory, + liveMetadata, + expectedRunId: fixture.metadata.runId, + repositoryUrl: fixture.remote, + pnpmCommand: prepared.pnpmCommand, + planPath: join(fixture.root, "mixed-stale-plan.json"), + workDirectory: join(fixture.root, "mixed-stale-verify"), + }); + + assert.deepEqual( + plan.candidates.map((candidate) => candidate.number), + [18], + ); + assert.deepEqual( + plan.skipped.map((candidate) => candidate.number), + [17], + ); + assert.match(plan.skipped[0].reason, /headSha changed/); + } finally { + fixture.cleanup(); + } +}); + +test("real pnpm heals an entirely local workspace without lifecycle markers", (t) => { + const probe = spawnSync("pnpm", ["--version"], { + encoding: "utf8", + shell: process.platform === "win32", + }); + if (probe.status !== 0) { + t.skip("standalone pnpm is not available"); + return; + } + const fixture = createFixture(); + try { + const marker = join(fixture.markerDirectory, "real-pnpm-lifecycle"); + git(fixture.source, "checkout", "feature"); + const appPath = join( + fixture.source, + "ts", + "packages", + "app", + "package.json", + ); + const app = JSON.parse(readFileSync(appPath, "utf8")); + app.scripts.postinstall = `node -e "require('fs').writeFileSync('${marker.replaceAll("\\", "\\\\")}','ran')"`; + write(appPath, packageJson(app)); + git(fixture.source, "add", appPath); + git(fixture.source, "commit", "-m", "local workspace dependency"); + git(fixture.source, "push", fixture.remote, "feature"); + fixture.metadata.pullRequests[0].headSha = git( + fixture.source, + "rev-parse", + "HEAD", + ); + + const outputDirectory = join(fixture.root, "real-artifact"); + const pnpmCommand = resolveStandalonePnpmCommand(); + const summary = prepareCandidateArtifact({ + metadata: fixture.metadata, + outputDirectory, + repositoryUrl: fixture.remote, + pnpmCommand, + workDirectory: join(fixture.root, "real-prepare"), + }); + assert.equal(summary.candidates.length, 1); + assert.equal(existsSync(marker), false); + assert.equal( + existsSync(join(fixture.markerDirectory, "root-lifecycle")), + false, + ); + const verified = verifyCandidateArtifact({ + artifactDirectory: outputDirectory, + liveMetadata: fixture.metadata, + expectedRunId: fixture.metadata.runId, + repositoryUrl: fixture.remote, + pnpmCommand, + planPath: join(fixture.root, "real-plan.json"), + workDirectory: join(fixture.root, "real-verify"), + }); + const lockfile = git( + verified.repositoryPath, + "show", + `${verified.candidates[0].candidateSha}:ts/pnpm-lock.yaml`, + ); + assert.match(lockfile, /fixture-lib/); + } finally { + fixture.cleanup(); + } +}); + +test("workflow split keeps trusted preparation credential-free and token use isolated", () => { + const producerText = readFileSync(PRODUCER_WORKFLOW, "utf8"); + const finalizerText = readFileSync(FINALIZER_WORKFLOW, "utf8"); + const producer = yaml.load(producerText); + const finalizer = yaml.load(finalizerText); + const producerSteps = producer.jobs.prepare.steps; + const finalizerSteps = finalizer.jobs.finalize.steps; + const allCheckouts = [...producerSteps, ...finalizerSteps].filter((step) => + String(step.uses ?? "").startsWith("actions/checkout@"), + ); + + assert.equal(producer.permissions.contents, "read"); + assert.equal(producer.permissions["pull-requests"], "read"); + const metadataStep = producerSteps.find( + (step) => + step.name === "Resolve trusted revision and pull request metadata", + ); + const fetchStep = producerSteps.find( + (step) => + step.name === + "Fetch and verify trusted implementation without credentials", + ); + const prepareStep = producerSteps.find( + (step) => step.name === "Prepare credential-free healing candidates", + ); + const contractStep = producerSteps.find( + (step) => step.name === "Verify trusted healer contract", + ); + const uploadStep = producerSteps.find( + (step) => step.name === "Upload healing candidates", + ); + assert.equal(metadataStep.with["github-token"], "${{ github.token }}"); + assert.doesNotMatch(JSON.stringify(fetchStep), /\$\{\{ github\.token \}\}/); + assert.match(fetchStep.run, /credential\.helper=/); + assert.match( + fetchStep.run, + /\+refs\/heads\/\$\{DEFAULT_BRANCH\}:refs\/remotes\/origin\/\$\{DEFAULT_BRANCH\}/, + ); + assert.deepEqual(Object.keys(prepareStep.env).sort(), [ + "METADATA_PATH", + "REPOSITORY", + ]); + assert.doesNotMatch( + JSON.stringify(prepareStep), + /\$\{\{ github\.token \}\}/, + ); + assert.match(prepareStep.run, /env -i/); + assert.doesNotMatch( + prepareStep.run, + /\b(?:GH_TOKEN|GITHUB_TOKEN|GIT_ASKPASS|SSH_ASKPASS|GIT_CONFIG_COUNT)\b/, + ); + assert.equal( + producerSteps.filter((step) => + JSON.stringify(step).includes("${{ github.token }}"), + ).length, + 1, + ); + assert.equal(contractStep.if, undefined); + assert.match(contractStep.run, /previously_deployed/); + assert.match(contractStep.run, /EVENT_NAME.*pull_request/s); + assert.equal( + prepareStep.if, + "steps.healer-contract.outputs.available == 'true'", + ); + assert.equal(uploadStep.if, prepareStep.if); + assert.equal( + producerSteps.some((step) => + String(step.uses ?? "").includes("create-github-app-token"), + ), + false, + ); + assert.ok(allCheckouts.length > 0); + assert.ok( + allCheckouts.every( + (step) => step.with?.["persist-credentials"] === false, + ), + ); + assert.equal( + finalizer.on.workflow_run.workflows[0], + "Prepare Generated File Healing", + ); + const verifyIndex = finalizerSteps.findIndex( + (step) => + step.name === + "Verify candidate object graph and live pull request state", + ); + const tokenIndex = finalizerSteps.findIndex( + (step) => step.name === "Mint minimally scoped App token", + ); + const pushIndex = finalizerSteps.findIndex( + (step) => step.name === "Revalidate and push with exact-head lease", + ); + assert.ok(verifyIndex < tokenIndex); + assert.ok(tokenIndex < pushIndex); + assert.equal( + finalizerSteps[tokenIndex].with["permission-contents"], + "write", + ); + assert.equal( + finalizerSteps[tokenIndex].with["permission-pull-requests"], + "read", + ); + assert.deepEqual(Object.keys(finalizerSteps[pushIndex].env).sort(), [ + "GITHUB_API_URL", + "HEAL_PUSH_TOKEN", + "PLAN_PATH", + ]); + assert.doesNotMatch(producerText, /\b(?:GH_TOKEN|GITHUB_TOKEN)\b/); + assert.doesNotMatch(finalizerText, /GH_TOKEN|GITHUB_TOKEN/); + assert.match(finalizerText, /--force-with-lease|exact-head lease/); + assert.match(producerText, /trusted\/ts\/package\.json/); + assert.match(finalizerText, /trusted\/ts\/package\.json/); + assert.match(producerText, /baseRepository/); + assert.match(finalizerText, /baseRepository/); +});