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: + + ![Vercel Custom Build Step Screenshot](/images/vercel_ignored_build_script_screenshot.png) + + ```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,