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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions .github/workflows/pr-merge-conflict-fixer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

name: PR Conflicts / Resolve

on:
push:
branches:
- main

permissions:
contents: read

jobs:
scan:
name: Scan conflicting PRs
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
outputs:
count: ${{ steps.scan.outputs.count }}
matrix: ${{ steps.scan.outputs.matrix }}
steps:
- name: Checkout trusted workflow code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.sha }}

- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"

- id: scan
name: Reproduce PR merges
env:
GITHUB_TOKEN: ${{ github.token }}
run: node --experimental-strip-types --no-warnings tools/pr-merge-conflict-fixer/discover.mts

resolve:
name: Resolve PR #${{ matrix.item.pr_number }}
needs: scan
if: ${{ needs.scan.outputs.count != '0' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.scan.outputs.matrix) }}
env:
ARTIFACT_DIR: ${{ github.workspace }}/resolution-artifact
OPENSHELL_GATEWAY_ENDPOINT: http://127.0.0.1:8080
PI_IMAGE: ghcr.io/nvidia/openshell-community/sandboxes/pi@sha256:00d0c5e9e733f94f6db3eaa2ab70d4fd75bcc4aace6b13a54535cbf2dd20dfcd
RESOLUTION_WORKDIR: ${{ github.workspace }}/repo
RESOLVER_CONFIG_DIR: ${{ github.workspace }}/pi-config
SANDBOX_NAME: pr-conflict-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.item.pr_number }}
TRUSTED_CHECKOUT: ${{ github.workspace }}/trusted
Comment thread
coderabbitai[bot] marked this conversation as resolved.
steps:
- name: Checkout trusted workflow code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
path: trusted
persist-credentials: false
ref: ${{ github.sha }}

- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"

- id: prepare
name: Reproduce the recorded conflict
env:
MATRIX_ENTRY: ${{ toJSON(matrix.item) }}
run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" prepare

- name: Install OpenShell
run: |
env -u GITHUB_TOKEN -u GH_TOKEN -u PR_REVIEW_ADVISOR_API_KEY \
NEMOCLAW_NON_INTERACTIVE=1 \
bash "$TRUSTED_CHECKOUT/scripts/install-openshell.sh"

- name: Configure OpenShell inference
env:
OPENAI_API_KEY: ${{ secrets.PR_REVIEW_ADVISOR_API_KEY }}
run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" configure

- name: Create the credential-free sandbox
run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" create

- name: Run one Pi conflict-resolution task
run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" run

- name: Export the Git patch
env:
CONFLICT_TREE: ${{ steps.prepare.outputs.conflict_tree }}
run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" export

- name: Delete the sandbox
if: always()
run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" delete

- name: Upload the resolution patch
if: success()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-conflict-resolution-${{ matrix.item.pr_number }}-${{ matrix.item.head_sha }}-${{ matrix.item.base_sha }}
path: ${{ env.ARTIFACT_DIR }}/resolution.patch

publish:
name: Publish PR #${{ matrix.item.pr_number }}
needs:
- scan
- resolve
if: ${{ always() && needs.scan.outputs.count != '0' }}
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: read
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.scan.outputs.matrix) }}
env:
ARTIFACT_DIR: ${{ github.workspace }}/resolution-artifact
TRUSTED_CHECKOUT: ${{ github.workspace }}/trusted
steps:
- name: Checkout trusted publisher code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
path: trusted
persist-credentials: false
ref: ${{ github.sha }}

- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"

- id: download
name: Download the resolution patch
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: pr-conflict-resolution-${{ matrix.item.pr_number }}-${{ matrix.item.head_sha }}-${{ matrix.item.base_sha }}
path: ${{ env.ARTIFACT_DIR }}

- name: Validate and publish the merge commit
if: ${{ steps.download.outcome == 'success' }}
env:
GITHUB_TOKEN: ${{ github.token }}
MATRIX_ENTRY: ${{ toJSON(matrix.item) }}
run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/publish.mts"
4 changes: 4 additions & 0 deletions test/helpers/vitest-watch-triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [
pattern: /(?:^|\/)\.github\/workflows\/pr-e2e-gate\.yaml$/,
testsToRun: runTests("test/pr-e2e-gate-workflow.test.ts", "test/pr-e2e-required.test.ts"),
},
{
pattern: /(?:^|\/)\.github\/workflows\/pr-merge-conflict-fixer\.yaml$/,
testsToRun: runTests("test/pr-merge-conflict-fixer-workflow-boundary.test.ts"),
},
{
pattern:
/(?:^|\/)\.github\/workflows\/(?:hosted-runner-recovery|wsl-e2e|macos-e2e|platform-vitest-main)\.yaml$/,
Expand Down
168 changes: 168 additions & 0 deletions test/pr-merge-conflict-fixer-workflow-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import YAML from "yaml";

const root = path.resolve(import.meta.dirname, "..");
const workflowSource = fs.readFileSync(
path.join(root, ".github", "workflows", "pr-merge-conflict-fixer.yaml"),
"utf8",
);
const workflow = YAML.parse(workflowSource) as Record<string, unknown>;
const policy = YAML.parse(
fs.readFileSync(path.join(root, "tools", "pr-merge-conflict-fixer", "policy.yaml"), "utf8"),
) as Record<string, unknown>;

function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}

function steps(job: Record<string, unknown>): Array<Record<string, unknown>> {
return Array.isArray(job.steps) ? (job.steps as Array<Record<string, unknown>>) : [];
}

function required<T>(value: T | undefined, message: string): T {
expect(value, message).toBeDefined();
return value as T;
}

function namedStep(job: Record<string, unknown>, name: string): Record<string, unknown> {
return required(
steps(job).find((candidate) => candidate.name === name),
`Missing workflow step: ${name}`,
);
}

function checkout(job: Record<string, unknown>): Record<string, unknown> {
return required(
steps(job).find((candidate) => String(candidate.uses ?? "").startsWith("actions/checkout@")),
"Missing checkout step",
);
}

function resolverInvocation(command: string): string {
return `node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" ${command}`;
}

describe("PR merge conflict fixer workflow boundary", () => {
const jobs = record(workflow.jobs);
const scan = record(jobs.scan);
const resolve = record(jobs.resolve);
const publish = record(jobs.publish);

it("runs only after pushes to main with one write stage (#7542)", () => {
expect(record(workflow.on)).toEqual({ push: { branches: ["main"] } });
expect(workflow.permissions).toEqual({ contents: "read" });
expect(Object.keys(jobs).sort()).toEqual(["publish", "resolve", "scan"]);
expect(scan.permissions).toEqual({ contents: "read", "pull-requests": "read" });
expect(resolve.permissions).toEqual({ contents: "read" });
expect(publish.permissions).toEqual({ contents: "write", "pull-requests": "read" });
expect(resolve["timeout-minutes"]).toBe(30);
expect(
Object.values(jobs).filter((job) => record(job)["timeout-minutes"] !== undefined),
).toHaveLength(1);
});

it("loads each resolve command from the pushed main SHA (#6952)", () => {
const actionReferences = [scan, resolve, publish]
.flatMap((job) => steps(job))
.map((step) => step.uses)
.filter((reference): reference is string => typeof reference === "string");
expect(actionReferences).not.toHaveLength(0);
expect(
actionReferences.every((reference) => /^[^@\s]+\/[^@\s]+@[0-9a-f]{40}$/u.test(reference)),
).toBe(true);

for (const job of [scan, resolve, publish]) {
expect(checkout(job).with).toMatchObject({
"persist-credentials": false,
ref: "${{ github.sha }}",
});
}
expect(namedStep(publish, "Validate and publish the merge commit").run).toContain(
"$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/publish.mts",
);

for (const [name, command] of [
["Reproduce the recorded conflict", "prepare"],
["Configure OpenShell inference", "configure"],
["Create the credential-free sandbox", "create"],
["Run one Pi conflict-resolution task", "run"],
["Export the Git patch", "export"],
["Delete the sandbox", "delete"],
]) {
expect(namedStep(resolve, name).run).toBe(resolverInvocation(command));
}
});

it("keeps credentials and direct network egress out of Pi (#7542)", () => {
const configure = namedStep(resolve, "Configure OpenShell inference");
expect(configure.env).toEqual({
OPENAI_API_KEY: "${{ secrets.PR_REVIEW_ADVISOR_API_KEY }}",
});
expect(configure.run).toBe(resolverInvocation("configure"));

const pi = namedStep(resolve, "Run one Pi conflict-resolution task");
expect(pi.env).toBeUndefined();
expect(pi.run).toBe(resolverInvocation("run"));
expect(namedStep(resolve, "Install OpenShell").run).toContain(
"env -u GITHUB_TOKEN -u GH_TOKEN -u PR_REVIEW_ADVISOR_API_KEY",
);
expect(record(resolve.env).OPENSHELL_GATEWAY_ENDPOINT).toBe("http://127.0.0.1:8080");
expect(record(resolve.env).PI_IMAGE).toBe(
"ghcr.io/nvidia/openshell-community/sandboxes/pi@sha256:00d0c5e9e733f94f6db3eaa2ab70d4fd75bcc4aace6b13a54535cbf2dd20dfcd",
);
expect(workflowSource.match(/\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/gu)).toEqual([
"${{ secrets.PR_REVIEW_ADVISOR_API_KEY }}",
]);
expect(workflowSource).not.toMatch(
/\b(?:PAT|GitHub App|checks:\s*write|statuses:\s*write)\b/iu,
);
expect(policy.network_policies).toEqual({});
});

it("allows only the resolver runtime and sandbox paths (#7542)", () => {
expect(policy.filesystem_policy).toEqual({
include_workdir: false,
read_only: ["/usr/bin", "/usr/lib", "/usr/share/git-core", "/etc"],
read_write: ["/dev", "/sandbox"],
});
expect(policy.landlock).toEqual({ compatibility: "hard_requirement" });
expect(policy.process).toEqual({ run_as_group: "sandbox", run_as_user: "sandbox" });
expect(record(resolve.env)).toMatchObject({
ARTIFACT_DIR: "${{ github.workspace }}/resolution-artifact",
RESOLUTION_WORKDIR: "${{ github.workspace }}/repo",
RESOLVER_CONFIG_DIR: "${{ github.workspace }}/pi-config",
TRUSTED_CHECKOUT: "${{ github.workspace }}/trusted",
});
expect(record(publish.env).ARTIFACT_DIR).toBe("${{ github.workspace }}/resolution-artifact");
});

it("publishes only a successfully exported patch and always deletes the sandbox (#7542)", () => {
const exporter = namedStep(resolve, "Export the Git patch");
expect(exporter.env).toEqual({
CONFLICT_TREE: "${{ steps.prepare.outputs.conflict_tree }}",
});
expect(exporter.run).toBe(resolverInvocation("export"));

const cleanup = namedStep(resolve, "Delete the sandbox");
expect(cleanup.if).toBe("always()");
expect(cleanup.run).toBe(resolverInvocation("delete"));

const upload = namedStep(resolve, "Upload the resolution patch");
const download = namedStep(publish, "Download the resolution patch");
expect(upload.if).toBe("success()");
expect(download["continue-on-error"]).toBe(true);
expect(record(download.with).name).toBe(record(upload.with).name);
expect(record(download.with).path).toBe("${{ env.ARTIFACT_DIR }}");

const publisher = namedStep(publish, "Validate and publish the merge commit");
expect(publisher.if).toBe("${{ steps.download.outcome == 'success' }}");
expect(record(publisher.env).GITHUB_TOKEN).toBe("${{ github.token }}");
});
});
Loading
Loading