diff --git a/.changeset/ao-update-rebuild-staleness.md b/.changeset/ao-update-rebuild-staleness.md new file mode 100644 index 0000000000..05d88a3345 --- /dev/null +++ b/.changeset/ao-update-rebuild-staleness.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-cli": patch +--- + +Fix `ao update` skipping the rebuild when compiled output is stale at the current commit. The rebuild used to fire only when the fetch advanced the local SHA, so a manual `git pull`, a branch switch, an interrupted earlier build, or a manual `pnpm clean` could leave `dist/` out of sync with `src/` while `ao update` reported "Already on latest version" and never rebuilt. The rebuild is now gated on whether the build output is actually in sync with HEAD (tracked via a gitignored `node_modules/.ao-build-sha` marker plus a build-output existence check), and a new `ao update --force-rebuild` flag forces a rebuild on demand. Applies to git/source installs on both bash and PowerShell. diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 9cdfe5dce7..02545d98e7 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -33,7 +33,7 @@ jobs: name: Publish canary runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: main fetch-depth: 0 @@ -79,9 +79,9 @@ jobs: echo "skip=false" >> "$GITHUB_OUTPUT" fi - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 if: steps.check.outputs.skip != 'true' - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 if: steps.check.outputs.skip != 'true' with: node-version: 20 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2ab5c11ce..eb28aeced8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,9 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20 cache: pnpm @@ -33,9 +33,9 @@ jobs: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20 cache: pnpm @@ -55,9 +55,9 @@ jobs: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20 cache: pnpm @@ -80,9 +80,9 @@ jobs: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20 cache: pnpm diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 12ea5ac75e..b51ae3103c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -19,12 +19,12 @@ jobs: # Non-blocking: never prevent merging even if this job fails continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: fetch-depth: 0 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20 cache: pnpm diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml index 756066eb7a..7773266249 100644 --- a/.github/workflows/deploy-vps.yml +++ b/.github/workflows/deploy-vps.yml @@ -78,7 +78,7 @@ jobs: env: DEPLOY_SHA: ${{ steps.resolve_deploy.outputs.deploy_sha }} FETCH_REF: ${{ steps.resolve_deploy.outputs.fetch_ref }} - uses: appleboy/ssh-action@v1.2.2 + uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f with: host: ${{ secrets.VPS_HOST }} username: aoagent diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index cb21ad3012..6faa60e5d6 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -18,9 +18,9 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20 cache: pnpm diff --git a/.github/workflows/onboarding-test.yml b/.github/workflows/onboarding-test.yml index 704514a8ba..d2dbd0890e 100644 --- a/.github/workflows/onboarding-test.yml +++ b/.github/workflows/onboarding-test.yml @@ -19,10 +19,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Build test image working-directory: tests/integration @@ -47,7 +47,7 @@ jobs: - name: Upload test logs if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: onboarding-test-logs path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb2c322cb0..6eed2fd84c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,12 +39,12 @@ jobs: github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20 cache: pnpm @@ -65,7 +65,7 @@ jobs: # `publish:` command. We deliberately omit `publish:` so the action # never runs `changeset publish`. npm publishing is handled by a # private cron that detects the GitHub release. - - uses: changesets/action@v1 + - uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b id: changesets with: version: pnpm changeset version diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5f8cf574db..affc740d95 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: fetch-depth: 0 # Full history to ensure base/head SHAs are available for PR scans @@ -80,10 +80,10 @@ jobs: if: github.event_name == 'pull_request' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Dependency Review - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 with: fail-on-severity: moderate @@ -92,13 +92,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20 cache: pnpm diff --git a/packages/cli/__tests__/scripts/update-script.test.ts b/packages/cli/__tests__/scripts/update-script.test.ts index 03b6d25233..3ad6c1635d 100644 --- a/packages/cli/__tests__/scripts/update-script.test.ts +++ b/packages/cli/__tests__/scripts/update-script.test.ts @@ -12,9 +12,40 @@ import { dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { isWindows } from "@aoagents/ao-core"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const scriptPath = join(packageRoot, "src", "assets", "scripts", "ao-update.sh"); +const buildOutputSentinels = [ + "packages/core/dist/index.js", + "packages/cli/dist/index.js", + "packages/web/.next/BUILD_ID", + "packages/plugins/agent-aider/dist/index.js", + "packages/plugins/agent-claude-code/dist/index.js", + "packages/plugins/agent-codex/dist/index.js", + "packages/plugins/agent-cursor/dist/index.js", + "packages/plugins/agent-grok/dist/index.js", + "packages/plugins/agent-kimicode/dist/index.js", + "packages/plugins/agent-opencode/dist/index.js", + "packages/plugins/notifier-composio/dist/index.js", + "packages/plugins/notifier-dashboard/dist/index.js", + "packages/plugins/notifier-desktop/dist/index.js", + "packages/plugins/notifier-discord/dist/index.js", + "packages/plugins/notifier-openclaw/dist/index.js", + "packages/plugins/notifier-slack/dist/index.js", + "packages/plugins/notifier-webhook/dist/index.js", + "packages/plugins/runtime-process/dist/index.js", + "packages/plugins/runtime-tmux/dist/index.js", + "packages/plugins/scm-github/dist/index.js", + "packages/plugins/scm-gitlab/dist/index.js", + "packages/plugins/terminal-iterm2/dist/index.js", + "packages/plugins/terminal-web/dist/index.js", + "packages/plugins/tracker-github/dist/index.js", + "packages/plugins/tracker-gitlab/dist/index.js", + "packages/plugins/tracker-linear/dist/index.js", + "packages/plugins/workspace-clone/dist/index.js", + "packages/plugins/workspace-worktree/dist/index.js", +]; function writeExecutable(path: string, content: string): void { writeFileSync(path, content); @@ -25,6 +56,14 @@ function createFakeBinary(binDir: string, name: string, body: string): void { writeExecutable(join(binDir, name), `#!/bin/bash\nset -e\n${body}\n`); } +function createBuildOutputs(repoRoot: string): void { + for (const sentinel of buildOutputSentinels) { + const path = join(repoRoot, sentinel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, ""); + } +} + describe("ao-update.sh", () => { it("falls back to origin when no upstream remote exists", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-script-")); @@ -92,7 +131,7 @@ esac\nexit 0`, // Bash-script tests skipped on Windows: spawnSync("bash", ...) requires bash // which isn't guaranteed without Git for Windows. The Windows code path uses // detectWindowsBash() at runtime, exercised separately. - it.skipIf(process.platform === "win32")( + it.skipIf(isWindows())( "syncs the fork with upstream via gh and fast-forwards the local checkout from upstream", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-upstream-script-")); @@ -162,20 +201,22 @@ esac\nexit 0`, }, ); - it.skipIf(process.platform === "win32")("uses forced npm link so stale global ao shims are overwritten", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-stale-shim-")); - const fakeRepo = join(tempRoot, "repo"); - mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); - mkdirSync(join(fakeRepo, "packages", "ao"), { recursive: true }); + it.skipIf(isWindows())( + "uses forced npm link so stale global ao shims are overwritten", + () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-stale-shim-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "ao"), { recursive: true }); - const binDir = join(tempRoot, "bin"); - mkdirSync(binDir, { recursive: true }); - const commandLog = join(tempRoot, "commands.log"); + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + const commandLog = join(tempRoot, "commands.log"); - createFakeBinary( - binDir, - "git", - `case "$*" in + createFakeBinary( + binDir, + "git", + `case "$*" in "remote get-url upstream") exit 1 ;; "rev-parse --is-inside-work-tree") printf 'true\n' ;; "status --porcelain") ;; @@ -186,90 +227,94 @@ esac\nexit 0`, "pull --ff-only origin main") ;; esac exit 0`, - ); - createFakeBinary( - binDir, - "pnpm", - `if [ "$1" = "--version" ]; then printf '9.15.4\n'; fi + ); + createFakeBinary( + binDir, + "pnpm", + `if [ "$1" = "--version" ]; then printf '9.15.4\n'; fi exit 0`, - ); - createFakeBinary( - binDir, - "npm", - `printf 'npm %s\n' "$*" >> ${JSON.stringify(commandLog)} + ); + createFakeBinary( + binDir, + "npm", + `printf 'npm %s\n' "$*" >> ${JSON.stringify(commandLog)} if [ "$*" = "link" ]; then printf 'npm error code EEXIST\n' >&2 exit 1 fi exit 0`, - ); - createFakeBinary( - binDir, - "node", - `if [ "$1" = "--version" ]; then printf 'v20.11.1\n'; fi + ); + createFakeBinary( + binDir, + "node", + `if [ "$1" = "--version" ]; then printf 'v20.11.1\n'; fi exit 0`, - ); + ); - const result = spawnSync("bash", [scriptPath, "--skip-smoke"], { - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ""}`, - AO_REPO_ROOT: fakeRepo, - }, - encoding: "utf8", - }); + const result = spawnSync("bash", [scriptPath, "--skip-smoke"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); - const commands = existsSync(commandLog) ? readFileSync(commandLog, "utf8") : ""; - rmSync(tempRoot, { recursive: true, force: true }); + const commands = existsSync(commandLog) ? readFileSync(commandLog, "utf8") : ""; + rmSync(tempRoot, { recursive: true, force: true }); - expect(result.status).toBe(0); - expect(commands).toContain("npm link --force"); - expect(commands).not.toContain("npm link\n"); - expect(result.stdout).not.toContain("Permission denied"); - }); + expect(result.status).toBe(0); + expect(commands).toContain("npm link --force"); + expect(commands).not.toContain("npm link\n"); + expect(result.stdout).not.toContain("Permission denied"); + }, + ); - it.skipIf(process.platform === "win32")("runs the built-in smoke commands in smoke-only mode", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-smoke-")); - const fakeRepo = join(tempRoot, "repo"); - mkdirSync(join(fakeRepo, "packages", "ao", "bin"), { recursive: true }); - writeFileSync(join(fakeRepo, "packages", "ao", "bin", "ao.js"), "#!/usr/bin/env node\n"); + it.skipIf(isWindows())( + "runs the built-in smoke commands in smoke-only mode", + () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-smoke-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "packages", "ao", "bin"), { recursive: true }); + writeFileSync(join(fakeRepo, "packages", "ao", "bin", "ao.js"), "#!/usr/bin/env node\n"); - const binDir = join(tempRoot, "bin"); - mkdirSync(binDir, { recursive: true }); - const commandLog = join(tempRoot, "commands.log"); - createFakeBinary( - binDir, - "node", - `if [ "$1" = "--version" ]; then printf 'v20.11.1\\n'; fi + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + const commandLog = join(tempRoot, "commands.log"); + createFakeBinary( + binDir, + "node", + `if [ "$1" = "--version" ]; then printf 'v20.11.1\\n'; fi printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)} exit 0`, - ); + ); - const result = spawnSync("bash", [scriptPath, "--smoke-only"], { - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ""}`, - AO_REPO_ROOT: fakeRepo, - }, - encoding: "utf8", - }); + const result = spawnSync("bash", [scriptPath, "--smoke-only"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); - const commands = readFileSync(commandLog, "utf8"); - rmSync(tempRoot, { recursive: true, force: true }); + const commands = readFileSync(commandLog, "utf8"); + rmSync(tempRoot, { recursive: true, force: true }); - expect(result.status).toBe(0); - expect(commands).toContain( - `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} --version`, - ); - expect(commands).toContain( - `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} doctor --help`, - ); - expect(commands).toContain( - `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} update --help`, - ); - }); + expect(result.status).toBe(0); + expect(commands).toContain( + `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} --version`, + ); + expect(commands).toContain( + `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} doctor --help`, + ); + expect(commands).toContain( + `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} update --help`, + ); + }, + ); - it.skipIf(process.platform === "win32")( + it.skipIf(isWindows())( "resolves the source checkout root when AO_REPO_ROOT is unset", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-root-detect-")); @@ -352,23 +397,32 @@ exit 0`, expect(result.stderr).toContain("commit or stash"); }); - it.skipIf(process.platform === "win32")("skips rebuild but still runs smoke tests when local HEAD matches remote HEAD", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-already-latest-")); - const fakeRepo = join(tempRoot, "repo"); - mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); - mkdirSync(join(fakeRepo, "packages", "ao", "bin"), { recursive: true }); - writeFileSync(join(fakeRepo, "packages", "ao", "bin", "ao.js"), "#!/usr/bin/env node\n"); + it.skipIf(isWindows())( + "skips rebuild but still runs smoke tests when local HEAD matches remote HEAD and the build is fresh", + () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-already-latest-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "ao", "bin"), { recursive: true }); + writeFileSync(join(fakeRepo, "packages", "ao", "bin", "ao.js"), "#!/usr/bin/env node\n"); - const binDir = join(tempRoot, "bin"); - mkdirSync(binDir, { recursive: true }); - const commandLog = join(tempRoot, "commands.log"); + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + const commandLog = join(tempRoot, "commands.log"); - const sha = "abc123def456abc123def456abc123def456abc123"; + const sha = "abc123def456abc123def456abc123def456abc123"; - createFakeBinary( - binDir, - "git", - `printf 'git %s\\n' "$*" >> ${JSON.stringify(commandLog)} + // Build is fresh: the output exists and the marker records the current HEAD, + // so the rebuild should be skipped even though the script no longer relies on + // the SHA having advanced. + createBuildOutputs(fakeRepo); + mkdirSync(join(fakeRepo, "node_modules"), { recursive: true }); + writeFileSync(join(fakeRepo, "node_modules", ".ao-build-sha"), `${sha}\n`); + + createFakeBinary( + binDir, + "git", + `printf 'git %s\\n' "$*" >> ${JSON.stringify(commandLog)} case "$*" in "remote get-url upstream") exit 1 ;; "rev-parse --is-inside-work-tree") printf 'true\\n' ;; @@ -380,51 +434,205 @@ case "$*" in *) ;; esac exit 0`, - ); - createFakeBinary( - binDir, - "pnpm", - `printf 'pnpm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf '9.15.4\\n'\nfi\nexit 0`, - ); - createFakeBinary( - binDir, - "npm", - `printf 'npm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`, - ); - createFakeBinary( - binDir, - "node", - `printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf 'v20.11.1\\n'\nfi\nexit 0`, - ); + ); + createFakeBinary( + binDir, + "pnpm", + `printf 'pnpm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf '9.15.4\\n'\nfi\nexit 0`, + ); + createFakeBinary( + binDir, + "npm", + `printf 'npm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`, + ); + createFakeBinary( + binDir, + "node", + `printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf 'v20.11.1\\n'\nfi\nexit 0`, + ); - const result = spawnSync("bash", [scriptPath], { - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ""}`, - AO_REPO_ROOT: fakeRepo, - }, - encoding: "utf8", - }); + const result = spawnSync("bash", [scriptPath], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); - const commands = readFileSync(commandLog, "utf8"); - rmSync(tempRoot, { recursive: true, force: true }); + const commands = readFileSync(commandLog, "utf8"); + rmSync(tempRoot, { recursive: true, force: true }); - expect(result.status).toBe(0); - expect(result.stdout).toContain("Already on latest version"); - // Rebuild commands should NOT have run - expect(commands).not.toContain("pnpm install"); - expect(commands).not.toContain("pnpm build"); - expect(commands).not.toContain("pnpm --filter @aoagents/ao-core build"); - expect(commands).not.toContain("npm link"); - expect(commands).not.toContain("git pull --ff-only origin main"); - // Smoke tests SHOULD still have run - expect(commands).toContain( - `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} --version`, - ); - expect(commands).toContain( - `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} doctor --help`, - ); - }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Already on latest version"); + // Rebuild commands should NOT have run + expect(commands).not.toContain("pnpm install"); + expect(commands).not.toContain("pnpm build"); + expect(commands).not.toContain("pnpm --filter @aoagents/ao-core build"); + expect(commands).not.toContain("npm link"); + expect(commands).not.toContain("git pull --ff-only origin main"); + // Smoke tests SHOULD still have run + expect(commands).toContain( + `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} --version`, + ); + expect(commands).toContain( + `node ${join(fakeRepo, "packages", "ao", "bin", "ao.js")} doctor --help`, + ); + }, + ); + + it.skipIf(isWindows())( + "rebuilds when HEAD matches remote but the build is stale (marker mismatch)", + () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-stale-build-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "ao", "bin"), { recursive: true }); + writeFileSync(join(fakeRepo, "packages", "ao", "bin", "ao.js"), "#!/usr/bin/env node\n"); + + const sha = "abc123def456abc123def456abc123def456abc123"; + + // Dist exists, but it was built from a different commit (e.g. the user ran a + // manual `git pull` so HEAD already matches remote, yet dist is from before). + createBuildOutputs(fakeRepo); + mkdirSync(join(fakeRepo, "node_modules"), { recursive: true }); + writeFileSync(join(fakeRepo, "node_modules", ".ao-build-sha"), "stale000stale000\n"); + + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + const commandLog = join(tempRoot, "commands.log"); + + createFakeBinary( + binDir, + "git", + `printf 'git %s\\n' "$*" >> ${JSON.stringify(commandLog)} +case "$*" in + "remote get-url upstream") exit 1 ;; + "rev-parse --is-inside-work-tree") printf 'true\\n' ;; + "status --porcelain") ;; + "branch --show-current") printf 'main\\n' ;; + "fetch origin main") ;; + "rev-parse HEAD") printf '${sha}\\n' ;; + "rev-parse origin/main") printf '${sha}\\n' ;; + *) ;; +esac +exit 0`, + ); + createFakeBinary( + binDir, + "pnpm", + `printf 'pnpm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf '9.15.4\\n'\nfi\nexit 0`, + ); + createFakeBinary( + binDir, + "npm", + `printf 'npm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`, + ); + createFakeBinary( + binDir, + "node", + `printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf 'v20.11.1\\n'\nfi\nexit 0`, + ); + + const result = spawnSync("bash", [scriptPath, "--skip-smoke"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); + + try { + expect(result.status, result.stderr || result.stdout).toBe(0); + const commands = readFileSync(commandLog, "utf8"); + const markerAfter = readFileSync(join(fakeRepo, "node_modules", ".ao-build-sha"), "utf8"); + + // No new commits, so no pull — but the stale build must be rebuilt. + expect(commands).not.toContain("git pull --ff-only origin main"); + expect(result.stdout).toContain("Rebuilding"); + expect(commands).toContain("pnpm install"); + expect(commands).toContain("pnpm build"); + expect(commands).toContain("npm link --force"); + // Marker is updated to the freshly-built HEAD. + expect(markerAfter.trim()).toBe(sha); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(isWindows())( + "rebuilds on --force-rebuild even when HEAD matches remote and the build is fresh", + () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-force-rebuild-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "ao", "bin"), { recursive: true }); + writeFileSync(join(fakeRepo, "packages", "ao", "bin", "ao.js"), "#!/usr/bin/env node\n"); + + const sha = "abc123def456abc123def456abc123def456abc123"; + + // Build is fresh — without --force-rebuild this would be a no-op. + createBuildOutputs(fakeRepo); + mkdirSync(join(fakeRepo, "node_modules"), { recursive: true }); + writeFileSync(join(fakeRepo, "node_modules", ".ao-build-sha"), `${sha}\n`); + + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + const commandLog = join(tempRoot, "commands.log"); + + createFakeBinary( + binDir, + "git", + `printf 'git %s\\n' "$*" >> ${JSON.stringify(commandLog)} +case "$*" in + "remote get-url upstream") exit 1 ;; + "rev-parse --is-inside-work-tree") printf 'true\\n' ;; + "status --porcelain") ;; + "branch --show-current") printf 'main\\n' ;; + "fetch origin main") ;; + "rev-parse HEAD") printf '${sha}\\n' ;; + "rev-parse origin/main") printf '${sha}\\n' ;; + *) ;; +esac +exit 0`, + ); + createFakeBinary( + binDir, + "pnpm", + `printf 'pnpm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf '9.15.4\\n'\nfi\nexit 0`, + ); + createFakeBinary( + binDir, + "npm", + `printf 'npm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`, + ); + createFakeBinary( + binDir, + "node", + `printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf 'v20.11.1\\n'\nfi\nexit 0`, + ); + + const result = spawnSync("bash", [scriptPath, "--skip-smoke", "--force-rebuild"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); + + const commands = readFileSync(commandLog, "utf8"); + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("forced via --force-rebuild"); + expect(commands).toContain("pnpm install"); + expect(commands).toContain("pnpm build"); + expect(commands).toContain("npm link --force"); + }, + ); it("rejects conflicting smoke flags in the script", () => { const result = spawnSync("bash", [scriptPath, "--skip-smoke", "--smoke-only"], { diff --git a/packages/cli/src/assets/scripts/ao-update.ps1 b/packages/cli/src/assets/scripts/ao-update.ps1 index 99d52c8ce5..b3f3e4a832 100644 --- a/packages/cli/src/assets/scripts/ao-update.ps1 +++ b/packages/cli/src/assets/scripts/ao-update.ps1 @@ -6,15 +6,17 @@ $ErrorActionPreference = 'Stop' # Manual arg parsing — matches ao-update.sh's `--skip-smoke` / `--smoke-only` / # `-h` / `--help` flags rather than PowerShell's `-SkipSmoke` convention, so the # calling contract is identical on Linux/macOS/Windows. -$SkipSmoke = $false -$SmokeOnly = $false -$Help = $false +$SkipSmoke = $false +$SmokeOnly = $false +$ForceRebuild = $false +$Help = $false foreach ($a in $args) { switch ($a) { - '--skip-smoke' { $SkipSmoke = $true } - '--smoke-only' { $SmokeOnly = $true } - '-h' { $Help = $true } - '--help' { $Help = $true } + '--skip-smoke' { $SkipSmoke = $true } + '--smoke-only' { $SmokeOnly = $true } + '--force-rebuild' { $ForceRebuild = $true } + '-h' { $Help = $true } + '--help' { $Help = $true } default { Write-Error "Unknown option: $a" exit 1 @@ -24,14 +26,19 @@ foreach ($a in $args) { if ($Help) { @' -Usage: ao update [--skip-smoke] [--smoke-only] +Usage: ao update [--skip-smoke] [--smoke-only] [--force-rebuild] Fast-forwards the local Agent Orchestrator install repo to main, installs deps, clean-rebuilds all workspace packages, refreshes the ao launcher, and runs smoke tests. +The rebuild runs whenever the compiled output is out of sync with the source at +the current commit — not only when new commits are pulled. This catches a manual +`git pull`, a branch switch, an interrupted earlier build, or a manual clean. + Options: - --skip-smoke Skip smoke tests after rebuild - --smoke-only Run smoke tests without fetching or rebuilding + --skip-smoke Skip smoke tests after rebuild + --smoke-only Run smoke tests without fetching or rebuilding + --force-rebuild Rebuild even when the build is already up to date '@ | Write-Host exit 0 } @@ -71,6 +78,66 @@ function Resolve-RepoRoot { $RepoRoot = Resolve-RepoRoot +# Records the commit the compiled output was last built from. Lives under +# node_modules (gitignored) so it never dirties the working tree, and is rewritten +# only after a fully successful build + launcher refresh. Comparing it to HEAD is +# how we tell "dist is in sync with src at this commit" without fragile mtime checks. +$BuildShaFile = Join-Path $RepoRoot 'node_modules/.ao-build-sha' +$BuildOutputSentinels = @( + 'packages/core/dist/index.js', + 'packages/cli/dist/index.js', + 'packages/web/.next/BUILD_ID', + 'packages/plugins/agent-aider/dist/index.js', + 'packages/plugins/agent-claude-code/dist/index.js', + 'packages/plugins/agent-codex/dist/index.js', + 'packages/plugins/agent-cursor/dist/index.js', + 'packages/plugins/agent-grok/dist/index.js', + 'packages/plugins/agent-kimicode/dist/index.js', + 'packages/plugins/agent-opencode/dist/index.js', + 'packages/plugins/notifier-composio/dist/index.js', + 'packages/plugins/notifier-dashboard/dist/index.js', + 'packages/plugins/notifier-desktop/dist/index.js', + 'packages/plugins/notifier-discord/dist/index.js', + 'packages/plugins/notifier-openclaw/dist/index.js', + 'packages/plugins/notifier-slack/dist/index.js', + 'packages/plugins/notifier-webhook/dist/index.js', + 'packages/plugins/runtime-process/dist/index.js', + 'packages/plugins/runtime-tmux/dist/index.js', + 'packages/plugins/scm-github/dist/index.js', + 'packages/plugins/scm-gitlab/dist/index.js', + 'packages/plugins/terminal-iterm2/dist/index.js', + 'packages/plugins/terminal-web/dist/index.js', + 'packages/plugins/tracker-github/dist/index.js', + 'packages/plugins/tracker-gitlab/dist/index.js', + 'packages/plugins/tracker-linear/dist/index.js', + 'packages/plugins/workspace-clone/dist/index.js', + 'packages/plugins/workspace-worktree/dist/index.js' +) | ForEach-Object { Join-Path $RepoRoot $_ } + +function Read-BuiltSha { + if (Test-Path $BuildShaFile) { + return (Get-Content $BuildShaFile -Raw -ErrorAction SilentlyContinue).Trim() + } + return '' +} + +function Write-BuiltSha([string]$sha) { + try { + $dir = Split-Path -Parent $BuildShaFile + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + Set-Content -Path $BuildShaFile -Value $sha -NoNewline + } catch { + Write-Host "Warning: could not write build-sha marker: $_" + } +} + +function Get-MissingBuildOutput { + foreach ($sentinel in $BuildOutputSentinels) { + if (-not (Test-Path $sentinel)) { return $sentinel } + } + return '' +} + function Require-Command([string]$name, [string]$fixHint) { if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { Write-Error "Missing required command: $name. Fix: $fixHint" @@ -190,11 +257,34 @@ if (-not $SmokeOnly) { $localSha = (& git rev-parse HEAD).Trim() $remoteSha = (& git rev-parse "$UpdateRemote/$TargetBranch").Trim() - if ($localSha -eq $remoteSha) { + if ($localSha -ne $remoteSha) { + Run-Cmd git pull --ff-only $UpdateRemote $TargetBranch + # HEAD moved; rebuild decision below must compare against the new commit. + $localSha = (& git rev-parse HEAD).Trim() + } + + # Decide whether to rebuild. Gating purely on "did the SHA advance" misses the + # common case where dist is stale at the current commit — a manual git pull, a + # branch switch, an interrupted earlier build, or a manual clean. Rebuild when + # the user forces it, the output is missing, or it wasn't built from HEAD. + $builtSha = Read-BuiltSha + $missingBuildOutput = Get-MissingBuildOutput + $rebuildReason = '' + if ($ForceRebuild) { + $rebuildReason = 'forced via --force-rebuild' + } elseif ($missingBuildOutput) { + $rebuildReason = "build output missing ($missingBuildOutput)" + } elseif ($builtSha -ne $localSha) { + $lastBuilt = if ($builtSha) { $builtSha } else { 'unknown' } + $rebuildReason = "build is stale (last built $lastBuilt, HEAD is $localSha)" + } + + if (-not $rebuildReason) { Write-Host "" - Write-Host "Already on latest version." + Write-Host "Already on latest version; build is up to date." } else { - Run-Cmd git pull --ff-only $UpdateRemote $TargetBranch + Write-Host "" + Write-Host "Rebuilding: $rebuildReason" Run-Cmd pnpm install Run-Cmd pnpm -r --if-present clean @@ -212,6 +302,10 @@ if (-not $SmokeOnly) { } finally { Pop-Location } Ensure-RepoClean 'Update modified tracked files. Inspect git status, review the changes, and rerun after restoring a clean checkout if needed.' + + # Only reached on a fully successful build + launcher refresh + clean tree. + # Recording HEAD here lets the next run skip the rebuild when nothing changed. + Write-BuiltSha $localSha } } diff --git a/packages/cli/src/assets/scripts/ao-update.sh b/packages/cli/src/assets/scripts/ao-update.sh index 1509d2adac..9b930228cf 100755 --- a/packages/cli/src/assets/scripts/ao-update.sh +++ b/packages/cli/src/assets/scripts/ao-update.sh @@ -4,6 +4,7 @@ set -euo pipefail SKIP_SMOKE=false SMOKE_ONLY=false +FORCE_REBUILD=false TARGET_BRANCH="${AO_UPDATE_BRANCH:-main}" while [ $# -gt 0 ]; do @@ -14,16 +15,24 @@ while [ $# -gt 0 ]; do --smoke-only) SMOKE_ONLY=true ;; + --force-rebuild) + FORCE_REBUILD=true + ;; -h|--help) cat <<'EOF' -Usage: ao update [--skip-smoke] [--smoke-only] +Usage: ao update [--skip-smoke] [--smoke-only] [--force-rebuild] Fast-forwards the local Agent Orchestrator install repo to main, installs deps, clean-rebuilds all workspace packages, refreshes the ao launcher, and runs smoke tests. +The rebuild runs whenever the compiled output is out of sync with the source at +the current commit — not only when new commits are pulled. This catches a manual +`git pull`, a branch switch, an interrupted earlier build, or a manual clean. + Options: - --skip-smoke Skip smoke tests after rebuild - --smoke-only Run smoke tests without fetching or rebuilding + --skip-smoke Skip smoke tests after rebuild + --smoke-only Run smoke tests without fetching or rebuilding + --force-rebuild Rebuild even when the build is already up to date EOF exit 0 ;; @@ -73,6 +82,65 @@ if ! REPO_ROOT="$(resolve_repo_root)"; then exit 1 fi +# Records the commit the compiled output was last built from. Lives under +# node_modules (gitignored) so it never dirties the working tree, and is rewritten +# only after a fully successful build + launcher refresh. Comparing it to HEAD is +# how we tell "dist is in sync with src at this commit" without fragile mtime checks. +BUILD_SHA_FILE="$REPO_ROOT/node_modules/.ao-build-sha" +BUILD_OUTPUT_SENTINELS=( + "$REPO_ROOT/packages/core/dist/index.js" + "$REPO_ROOT/packages/cli/dist/index.js" + "$REPO_ROOT/packages/web/.next/BUILD_ID" + "$REPO_ROOT/packages/plugins/agent-aider/dist/index.js" + "$REPO_ROOT/packages/plugins/agent-claude-code/dist/index.js" + "$REPO_ROOT/packages/plugins/agent-codex/dist/index.js" + "$REPO_ROOT/packages/plugins/agent-cursor/dist/index.js" + "$REPO_ROOT/packages/plugins/agent-grok/dist/index.js" + "$REPO_ROOT/packages/plugins/agent-kimicode/dist/index.js" + "$REPO_ROOT/packages/plugins/agent-opencode/dist/index.js" + "$REPO_ROOT/packages/plugins/notifier-composio/dist/index.js" + "$REPO_ROOT/packages/plugins/notifier-dashboard/dist/index.js" + "$REPO_ROOT/packages/plugins/notifier-desktop/dist/index.js" + "$REPO_ROOT/packages/plugins/notifier-discord/dist/index.js" + "$REPO_ROOT/packages/plugins/notifier-openclaw/dist/index.js" + "$REPO_ROOT/packages/plugins/notifier-slack/dist/index.js" + "$REPO_ROOT/packages/plugins/notifier-webhook/dist/index.js" + "$REPO_ROOT/packages/plugins/runtime-process/dist/index.js" + "$REPO_ROOT/packages/plugins/runtime-tmux/dist/index.js" + "$REPO_ROOT/packages/plugins/scm-github/dist/index.js" + "$REPO_ROOT/packages/plugins/scm-gitlab/dist/index.js" + "$REPO_ROOT/packages/plugins/terminal-iterm2/dist/index.js" + "$REPO_ROOT/packages/plugins/terminal-web/dist/index.js" + "$REPO_ROOT/packages/plugins/tracker-github/dist/index.js" + "$REPO_ROOT/packages/plugins/tracker-gitlab/dist/index.js" + "$REPO_ROOT/packages/plugins/tracker-linear/dist/index.js" + "$REPO_ROOT/packages/plugins/workspace-clone/dist/index.js" + "$REPO_ROOT/packages/plugins/workspace-worktree/dist/index.js" +) + +read_built_sha() { + if [ -f "$BUILD_SHA_FILE" ]; then + cat "$BUILD_SHA_FILE" 2>/dev/null || true + fi +} + +write_built_sha() { + mkdir -p "$(dirname "$BUILD_SHA_FILE")" 2>/dev/null || true + printf '%s\n' "$1" > "$BUILD_SHA_FILE" 2>/dev/null || true +} + +all_build_outputs_present() { + local sentinel + for sentinel in "${BUILD_OUTPUT_SENTINELS[@]}"; do + if [ ! -f "$sentinel" ]; then + missing_build_output="$sentinel" + return 1 + fi + done + missing_build_output="" + return 0 +} + require_command() { local name="$1" local fix_hint="$2" @@ -211,10 +279,32 @@ if [ "$SMOKE_ONLY" = false ]; then local_sha="$(git rev-parse HEAD)" remote_sha="$(git rev-parse "$UPDATE_REMOTE/$TARGET_BRANCH")" - if [ "$local_sha" = "$remote_sha" ]; then - printf '\nAlready on latest version.\n' - else + if [ "$local_sha" != "$remote_sha" ]; then run_cmd git pull --ff-only "$UPDATE_REMOTE" "$TARGET_BRANCH" + # HEAD moved; rebuild decision below must compare against the new commit. + local_sha="$(git rev-parse HEAD)" + fi + + # Decide whether to rebuild. Gating purely on "did the SHA advance" misses the + # common case where dist is stale at the current commit — a manual git pull, a + # branch switch, an interrupted earlier build, or a manual clean. Rebuild when + # the user forces it, the output is missing, or it wasn't built from HEAD. + built_sha="$(read_built_sha)" + missing_build_output="" + all_build_outputs_present || true + rebuild_reason="" + if [ "$FORCE_REBUILD" = true ]; then + rebuild_reason="forced via --force-rebuild" + elif [ -n "$missing_build_output" ]; then + rebuild_reason="build output missing ($missing_build_output)" + elif [ "$built_sha" != "$local_sha" ]; then + rebuild_reason="build is stale (last built ${built_sha:-unknown}, HEAD is $local_sha)" + fi + + if [ -z "$rebuild_reason" ]; then + printf '\nAlready on latest version; build is up to date.\n' + else + printf '\nRebuilding: %s\n' "$rebuild_reason" run_cmd pnpm install run_cmd pnpm -r --if-present clean @@ -242,6 +332,10 @@ if [ "$SMOKE_ONLY" = false ]; then ) ensure_repo_clean "Update modified tracked files. Inspect git status, review the changes, and rerun after restoring a clean checkout if needed." + + # Only reached on a fully successful build + launcher refresh + clean tree. + # Recording HEAD here lets the next run skip the rebuild when nothing changed. + write_built_sha "$local_sha" fi fi diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index b181d43701..cc326cd916 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -67,12 +67,17 @@ export function registerUpdate(program: Command): void { .description("Check for updates and upgrade AO to the latest version") .option("--skip-smoke", "Skip smoke tests after rebuilding (git installs only)") .option("--smoke-only", "Run smoke tests without fetching or rebuilding (git installs only)") + .option( + "--force-rebuild", + "Rebuild even when the build is already up to date (git installs only)", + ) .option("--check", "Print version info as JSON without upgrading") .option("--no-restore", "Restart AO after updating but do not restore stopped sessions") .action( async (opts: { skipSmoke?: boolean; smokeOnly?: boolean; + forceRebuild?: boolean; check?: boolean; restore?: boolean; }) => { @@ -101,8 +106,12 @@ export function registerUpdate(program: Command): void { // docs would silently no-op on npm/pnpm/bun installs (the flag would be // accepted, ignored, and the user would never know why smoke tests // didn't run — because they never ran on these install methods anyway). - if ((opts.skipSmoke || opts.smokeOnly) && method !== "git") { - const flag = opts.skipSmoke ? "--skip-smoke" : "--smoke-only"; + if ((opts.skipSmoke || opts.smokeOnly || opts.forceRebuild) && method !== "git") { + const flag = opts.skipSmoke + ? "--skip-smoke" + : opts.smokeOnly + ? "--smoke-only" + : "--force-rebuild"; console.error(`${flag} only applies to git installs (current install: ${method}).`); process.exit(1); } @@ -341,6 +350,7 @@ function runAoLifecycleCommand( async function handleGitUpdate(opts: { skipSmoke?: boolean; smokeOnly?: boolean; + forceRebuild?: boolean; restore?: boolean; }): Promise { const lifecyclePlan = await getUpdateLifecyclePlan(); @@ -349,6 +359,7 @@ async function handleGitUpdate(opts: { const args: string[] = []; if (opts.skipSmoke) args.push("--skip-smoke"); if (opts.smokeOnly) args.push("--smoke-only"); + if (opts.forceRebuild) args.push("--force-rebuild"); try { const exitCode = await runRepoScript("ao-update.sh", args);