diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index fc99c1ad..371d99c8 100644
--- a/apps/docs/docs.json
+++ b/apps/docs/docs.json
@@ -27,7 +27,8 @@
"setup/intro",
"setup/development",
"setup/authentication",
- "setup/monorepo"
+ "setup/monorepo",
+ "setup/ci"
]
}
]
diff --git a/apps/docs/images/vercel_ignored_build_script_screenshot.png b/apps/docs/images/vercel_ignored_build_script_screenshot.png
new file mode 100644
index 00000000..6ebf7077
Binary files /dev/null and b/apps/docs/images/vercel_ignored_build_script_screenshot.png differ
diff --git a/apps/docs/setup/ci.mdx b/apps/docs/setup/ci.mdx
new file mode 100644
index 00000000..bf34cb9b
--- /dev/null
+++ b/apps/docs/setup/ci.mdx
@@ -0,0 +1,69 @@
+---
+title: "CI Configuration"
+description: "How to configure your CI pipeline for Open SWE"
+---
+
+## Skip CI until last commit
+
+Open SWE will push a commit after every change to the repository. This will cause your GitHub CI workflows to run for every commit, which is unnecessary.
+
+To prevent this, Open SWE supports adding an environment variable which will append `[skip ci]` to the commit message. This can be used to make GitHub skip CI for that commit.
+
+Below are instructions showing how to use this to skip creating Vercel CI preview deployments on every commit:
+
+
+
+ Set the environment variable `SKIP_CI_UNTIL_LAST_COMMIT` to `true` in Open SWE's environment variables:
+
+ `apps/open-swe/.env`
+ ```bash
+ SKIP_CI_UNTIL_LAST_COMMIT="true"
+ ```
+
+
+
+
+ Add a custom 'Ignored Build Step' in Vercel to skip CI when commit messages contain the string `[skip ci]`.
+
+ 1. Navigate to your Vercel project dashboard
+ 2. Go to **Settings** → **Git** tab
+ 3. Scroll to **Ignored Build Step** section
+ 4. Select **Custom** and add the following script:
+
+ 
+
+ ```bash
+ git log -1 --pretty=oneline --abbrev-commit | grep -w "\[skip ci\]" && exit 0 || exit 1
+ ```
+
+
+ **Do not test this command locally** as it will close your terminal. Use the testing method below instead.
+
+
+
+
+
+ To verify your setup works without closing your terminal, use this safe testing command:
+
+ ```bash
+ # Safe local testing - won't close your terminal
+ if git log -1 --pretty=oneline --abbrev-commit | grep -w "\[skip ci\]"; then
+ echo "Found [skip ci] - Vercel would skip this build"
+ else
+ echo "No [skip ci] found - Vercel would proceed with build"
+ fi
+ ```
+
+ Test with both types of commits:
+ - Commits with `[skip ci]` should show "Vercel would skip this build"
+ - Commits without `[skip ci]` should show "Vercel would proceed with build"
+
+
+
+
+### How it works
+
+1. **During task execution**: Open SWE includes `[skip ci]` in commit messages
+2. **Vercel behavior**: Skips creating preview deployments for these commits (script exits with code 1)
+3. **Task completion**: Open SWE pushes a final commit without `[skip ci]` to trigger deployment (script exits with code 0)
+4. **Result**: Only one preview deployment per Open SWE task
diff --git a/apps/open-swe/.env.example b/apps/open-swe/.env.example
index c3d38e46..4496a3ca 100644
--- a/apps/open-swe/.env.example
+++ b/apps/open-swe/.env.example
@@ -50,3 +50,6 @@ OPEN_SWE_APP_URL="http://localhost:3000"
# Should be the same value as the one used in the web app, so that secrets
# encrypted in the web app can be decrypted in the agent.
SECRETS_ENCRYPTION_KEY=""
+# Whether or not to append the string "[skip ci]" to the commit message.
+# See the documentation for how to set this up: docs.langchain.com/labs/swe/setup/ci#skip-ci-until-last-commit
+SKIP_CI_UNTIL_LAST_COMMIT="true"
\ No newline at end of file
diff --git a/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts b/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts
index 32386978..5cd80fb6 100644
--- a/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts
+++ b/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts
@@ -10,6 +10,7 @@ import {
import {
checkoutBranchAndCommit,
getChangedFilesStatus,
+ pushEmptyCommit,
} from "../../../utils/github/git.js";
import {
createPullRequest,
@@ -167,6 +168,12 @@ export async function openPullRequest(
);
}
+ if (process.env.SKIP_CI_UNTIL_LAST_COMMIT === "true") {
+ await pushEmptyCommit(state.targetRepository, sandbox, {
+ githubInstallationToken,
+ });
+ }
+
const { title, body } = toolCall.args as z.infer;
const prForTask = getPullRequestNumberFromActiveTask(
diff --git a/apps/open-swe/src/utils/github/git.ts b/apps/open-swe/src/utils/github/git.ts
index 685851ad..1816be38 100644
--- a/apps/open-swe/src/utils/github/git.ts
+++ b/apps/open-swe/src/utils/github/git.ts
@@ -169,6 +169,16 @@ export async function stashAndClearChanges(
}
}
+function constructCommitMessage(): string {
+ const baseCommitMessage = "Apply patch";
+ const skipCiString = "[skip ci]";
+ const vercelSkipCi = process.env.SKIP_CI_UNTIL_LAST_COMMIT === "true";
+ if (vercelSkipCi) {
+ return `${baseCommitMessage} ${skipCiString}`;
+ }
+ return baseCommitMessage;
+}
+
export async function checkoutBranchAndCommit(
config: GraphConfig,
targetRepository: TargetRepository,
@@ -203,7 +213,12 @@ export async function checkoutBranchAndCommit(
}
const userName = `${botAppName}[bot]`;
const userEmail = `${botAppName}@users.noreply.github.com`;
- await sandbox.git.commit(absoluteRepoDir, "Apply patch", userName, userEmail);
+ await sandbox.git.commit(
+ absoluteRepoDir,
+ constructCommitMessage(),
+ userName,
+ userEmail,
+ );
// Push the changes using the git API so it handles authentication for us.
const pushRes = await withRetry(
@@ -301,6 +316,71 @@ export async function checkoutBranchAndCommit(
return { branchName, updatedTaskPlan };
}
+export async function pushEmptyCommit(
+ targetRepository: TargetRepository,
+ sandbox: Sandbox,
+ options: {
+ githubInstallationToken: string;
+ },
+) {
+ const botAppName = process.env.GITHUB_APP_NAME;
+ if (!botAppName) {
+ logger.error("GITHUB_APP_NAME environment variable is not set.");
+ throw new Error("GITHUB_APP_NAME environment variable is not set.");
+ }
+ const userName = `${botAppName}[bot]`;
+ const userEmail = `${botAppName}@users.noreply.github.com`;
+
+ try {
+ const absoluteRepoDir = getRepoAbsolutePath(targetRepository);
+ const setGitConfigRes = await sandbox.process.executeCommand(
+ `git config user.name "${userName}" && git config user.email "${userEmail}"`,
+ absoluteRepoDir,
+ undefined,
+ TIMEOUT_SEC,
+ );
+ if (setGitConfigRes.exitCode !== 0) {
+ logger.error(`Failed to set git config`, {
+ exitCode: setGitConfigRes.exitCode,
+ result: setGitConfigRes.result,
+ });
+ return;
+ }
+
+ const emptyCommitRes = await sandbox.process.executeCommand(
+ "git commit --allow-empty -m 'Empty commit to trigger CI'",
+ absoluteRepoDir,
+ undefined,
+ TIMEOUT_SEC,
+ );
+ if (emptyCommitRes.exitCode !== 0) {
+ logger.error(`Failed to push empty commit`, {
+ exitCode: emptyCommitRes.exitCode,
+ result: emptyCommitRes.result,
+ });
+ return;
+ }
+
+ await sandbox.git.push(
+ absoluteRepoDir,
+ "git",
+ options.githubInstallationToken,
+ );
+
+ logger.info("Successfully pushed empty commit");
+ } catch (e) {
+ const errorFields = getSandboxErrorFields(e);
+ logger.error(`Failed to push empty commit`, {
+ ...(errorFields && { errorFields }),
+ ...(e instanceof Error && {
+ name: e.name,
+ message: e.message,
+ stack: e.stack,
+ }),
+ });
+ }
+}
+
export async function pullLatestChanges(
absoluteRepoDir: string,
sandbox: Sandbox,