From 3d06bbd1abfd86f162941f2851e2fd3ee172dbc1 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Mon, 25 May 2026 17:06:06 +0530 Subject: [PATCH 1/4] fix(cli): rebuild on stale dist, not just SHA advance, in ao update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ao update` only rebuilt when `git fetch` advanced the local SHA. If dist fell out of sync with src at the *same* commit — a manual `git pull`, a branch switch, an interrupted earlier build, or a manual `pnpm clean` — the script printed "Already on latest version" and skipped the rebuild, leaving the running binary built from stale source. Gate the rebuild on whether the compiled output is actually in sync with HEAD instead. A gitignored `node_modules/.ao-build-sha` marker records the commit dist was last built from (written only after a fully successful build + launcher refresh), and a build-output existence check catches a wiped dist even when the marker still matches. Add `ao update --force-rebuild` to rebuild on demand. Mirrored in the PowerShell port and wired through update.ts (rejected on non-git installs, like the other build-only flags). Updates the already-latest test to require a fresh build and adds stale-marker and --force-rebuild coverage. Co-Authored-By: Claude Opus 4.7 --- .changeset/ao-update-rebuild-staleness.md | 5 + .../__tests__/scripts/update-script.test.ts | 435 ++++++++++++------ packages/cli/src/assets/scripts/ao-update.ps1 | 81 +++- packages/cli/src/assets/scripts/ao-update.sh | 65 ++- packages/cli/src/commands/update.ts | 15 +- 5 files changed, 447 insertions(+), 154 deletions(-) create mode 100644 .changeset/ao-update-rebuild-staleness.md 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/packages/cli/__tests__/scripts/update-script.test.ts b/packages/cli/__tests__/scripts/update-script.test.ts index 03b6d25233..271d4f8a73 100644 --- a/packages/cli/__tests__/scripts/update-script.test.ts +++ b/packages/cli/__tests__/scripts/update-script.test.ts @@ -162,20 +162,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(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 }); - 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,88 +188,92 @@ 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(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"); - 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")( "resolves the source checkout root when AO_REPO_ROOT is unset", @@ -352,23 +358,33 @@ 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(process.platform === "win32")( + "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. + mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); + writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), ""); + 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 +396,204 @@ 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(process.platform === "win32")( + "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). + mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); + writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), ""); + 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", + }); + + const commands = readFileSync(commandLog, "utf8"); + const markerAfter = readFileSync(join(fakeRepo, "node_modules", ".ao-build-sha"), "utf8"); + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(0); + // 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); + }, + ); + + it.skipIf(process.platform === "win32")( + "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. + mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); + writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), ""); + 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..905dbd63a5 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,28 @@ 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' +# A representative build artifact. Its absence means dist was wiped (e.g. a manual +# `pnpm clean`) even if the marker still matches HEAD, so we rebuild regardless. +$BuildOutputSentinel = Join-Path $RepoRoot 'packages/core/dist/index.js' + +function Read-BuiltSha { + if (Test-Path $BuildShaFile) { + return ((Get-Content $BuildShaFile -Raw -ErrorAction SilentlyContinue) | Out-String).Trim() + } + return '' +} + +function Write-BuiltSha([string]$sha) { + $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 +} + function Require-Command([string]$name, [string]$fixHint) { if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { Write-Error "Missing required command: $name. Fix: $fixHint" @@ -190,11 +219,33 @@ 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 + $rebuildReason = '' + if ($ForceRebuild) { + $rebuildReason = 'forced via --force-rebuild' + } elseif (-not (Test-Path $BuildOutputSentinel)) { + $rebuildReason = 'build output missing' + } 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 +263,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..2649e5a8ce 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,26 @@ 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" +# A representative build artifact. Its absence means dist was wiped (e.g. a manual +# `pnpm clean`) even if the marker still matches HEAD, so we rebuild regardless. +BUILD_OUTPUT_SENTINEL="$REPO_ROOT/packages/core/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 +} + require_command() { local name="$1" local fix_hint="$2" @@ -211,10 +240,30 @@ 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)" + rebuild_reason="" + if [ "$FORCE_REBUILD" = true ]; then + rebuild_reason="forced via --force-rebuild" + elif [ ! -f "$BUILD_OUTPUT_SENTINEL" ]; then + rebuild_reason="build output missing" + 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 +291,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); From 1c43afaa42b296813adb332e6a776174211eba4b Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 26 May 2026 22:56:04 +0530 Subject: [PATCH 2/4] Address ao update rebuild review --- .../__tests__/scripts/update-script.test.ts | 91 +++++++++++++------ packages/cli/src/assets/scripts/ao-update.ps1 | 45 ++++++++- packages/cli/src/assets/scripts/ao-update.sh | 52 ++++++++++- 3 files changed, 152 insertions(+), 36 deletions(-) diff --git a/packages/cli/__tests__/scripts/update-script.test.ts b/packages/cli/__tests__/scripts/update-script.test.ts index 271d4f8a73..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,7 +201,7 @@ esac\nexit 0`, }, ); - it.skipIf(process.platform === "win32")( + it.skipIf(isWindows())( "uses forced npm link so stale global ao shims are overwritten", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-stale-shim-")); @@ -231,7 +270,7 @@ exit 0`, }, ); - it.skipIf(process.platform === "win32")( + it.skipIf(isWindows())( "runs the built-in smoke commands in smoke-only mode", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-smoke-")); @@ -275,7 +314,7 @@ exit 0`, }, ); - 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-")); @@ -358,7 +397,7 @@ exit 0`, expect(result.stderr).toContain("commit or stash"); }); - it.skipIf(process.platform === "win32")( + 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-")); @@ -376,8 +415,7 @@ exit 0`, // 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. - mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); - writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), ""); + createBuildOutputs(fakeRepo); mkdirSync(join(fakeRepo, "node_modules"), { recursive: true }); writeFileSync(join(fakeRepo, "node_modules", ".ao-build-sha"), `${sha}\n`); @@ -443,7 +481,7 @@ exit 0`, }, ); - it.skipIf(process.platform === "win32")( + it.skipIf(isWindows())( "rebuilds when HEAD matches remote but the build is stale (marker mismatch)", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-stale-build-")); @@ -456,8 +494,7 @@ exit 0`, // 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). - mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); - writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), ""); + createBuildOutputs(fakeRepo); mkdirSync(join(fakeRepo, "node_modules"), { recursive: true }); writeFileSync(join(fakeRepo, "node_modules", ".ao-build-sha"), "stale000stale000\n"); @@ -506,23 +543,26 @@ exit 0`, encoding: "utf8", }); - const commands = readFileSync(commandLog, "utf8"); - const markerAfter = readFileSync(join(fakeRepo, "node_modules", ".ao-build-sha"), "utf8"); - rmSync(tempRoot, { recursive: true, force: true }); - - expect(result.status).toBe(0); - // 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); + 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(process.platform === "win32")( + 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-")); @@ -534,8 +574,7 @@ exit 0`, const sha = "abc123def456abc123def456abc123def456abc123"; // Build is fresh — without --force-rebuild this would be a no-op. - mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); - writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), ""); + createBuildOutputs(fakeRepo); mkdirSync(join(fakeRepo, "node_modules"), { recursive: true }); writeFileSync(join(fakeRepo, "node_modules", ".ao-build-sha"), `${sha}\n`); diff --git a/packages/cli/src/assets/scripts/ao-update.ps1 b/packages/cli/src/assets/scripts/ao-update.ps1 index 905dbd63a5..adee255beb 100644 --- a/packages/cli/src/assets/scripts/ao-update.ps1 +++ b/packages/cli/src/assets/scripts/ao-update.ps1 @@ -83,9 +83,36 @@ $RepoRoot = Resolve-RepoRoot # 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' -# A representative build artifact. Its absence means dist was wiped (e.g. a manual -# `pnpm clean`) even if the marker still matches HEAD, so we rebuild regardless. -$BuildOutputSentinel = Join-Path $RepoRoot 'packages/core/dist/index.js' +$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) { @@ -100,6 +127,13 @@ function Write-BuiltSha([string]$sha) { Set-Content -Path $BuildShaFile -Value $sha -NoNewline } +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" @@ -230,11 +264,12 @@ if (-not $SmokeOnly) { # 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 (-not (Test-Path $BuildOutputSentinel)) { - $rebuildReason = 'build output missing' + } 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)" diff --git a/packages/cli/src/assets/scripts/ao-update.sh b/packages/cli/src/assets/scripts/ao-update.sh index 2649e5a8ce..7cf9da5bb7 100755 --- a/packages/cli/src/assets/scripts/ao-update.sh +++ b/packages/cli/src/assets/scripts/ao-update.sh @@ -87,9 +87,36 @@ fi # 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" -# A representative build artifact. Its absence means dist was wiped (e.g. a manual -# `pnpm clean`) even if the marker still matches HEAD, so we rebuild regardless. -BUILD_OUTPUT_SENTINEL="$REPO_ROOT/packages/core/dist/index.js" +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 @@ -102,6 +129,17 @@ write_built_sha() { printf '%s\n' "$1" > "$BUILD_SHA_FILE" 2>/dev/null || true } +first_missing_build_output() { + local sentinel + for sentinel in "${BUILD_OUTPUT_SENTINELS[@]}"; do + if [ ! -f "$sentinel" ]; then + printf '%s\n' "$sentinel" + return 0 + fi + done + return 1 +} + require_command() { local name="$1" local fix_hint="$2" @@ -251,11 +289,15 @@ if [ "$SMOKE_ONLY" = false ]; then # 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="" + if ! missing_build_output="$(first_missing_build_output)"; then + missing_build_output="" + fi rebuild_reason="" if [ "$FORCE_REBUILD" = true ]; then rebuild_reason="forced via --force-rebuild" - elif [ ! -f "$BUILD_OUTPUT_SENTINEL" ]; then - rebuild_reason="build output missing" + 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 From 25c482d296c23cca2f258ac7ac52e076fe096212 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Wed, 27 May 2026 00:47:14 +0530 Subject: [PATCH 3/4] Pin GitHub Actions to commit SHAs --- .github/workflows/canary.yml | 6 +++--- .github/workflows/ci.yml | 24 ++++++++++++------------ .github/workflows/coverage.yml | 6 +++--- .github/workflows/deploy-vps.yml | 2 +- .github/workflows/integration-tests.yml | 6 +++--- .github/workflows/onboarding-test.yml | 6 +++--- .github/workflows/release.yml | 8 ++++---- .github/workflows/security.yml | 12 ++++++------ 8 files changed, 35 insertions(+), 35 deletions(-) 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 From 604e63f9c666707d48a448b2fd0682cfb367910f Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Wed, 27 May 2026 00:49:21 +0530 Subject: [PATCH 4/4] Address ao update script review nits --- packages/cli/src/assets/scripts/ao-update.ps1 | 12 ++++++++---- packages/cli/src/assets/scripts/ao-update.sh | 13 ++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/assets/scripts/ao-update.ps1 b/packages/cli/src/assets/scripts/ao-update.ps1 index adee255beb..b3f3e4a832 100644 --- a/packages/cli/src/assets/scripts/ao-update.ps1 +++ b/packages/cli/src/assets/scripts/ao-update.ps1 @@ -116,15 +116,19 @@ $BuildOutputSentinels = @( function Read-BuiltSha { if (Test-Path $BuildShaFile) { - return ((Get-Content $BuildShaFile -Raw -ErrorAction SilentlyContinue) | Out-String).Trim() + return (Get-Content $BuildShaFile -Raw -ErrorAction SilentlyContinue).Trim() } return '' } function Write-BuiltSha([string]$sha) { - $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 + 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 { diff --git a/packages/cli/src/assets/scripts/ao-update.sh b/packages/cli/src/assets/scripts/ao-update.sh index 7cf9da5bb7..9b930228cf 100755 --- a/packages/cli/src/assets/scripts/ao-update.sh +++ b/packages/cli/src/assets/scripts/ao-update.sh @@ -129,15 +129,16 @@ write_built_sha() { printf '%s\n' "$1" > "$BUILD_SHA_FILE" 2>/dev/null || true } -first_missing_build_output() { +all_build_outputs_present() { local sentinel for sentinel in "${BUILD_OUTPUT_SENTINELS[@]}"; do if [ ! -f "$sentinel" ]; then - printf '%s\n' "$sentinel" - return 0 + missing_build_output="$sentinel" + return 1 fi done - return 1 + missing_build_output="" + return 0 } require_command() { @@ -290,9 +291,7 @@ if [ "$SMOKE_ONLY" = false ]; then # the user forces it, the output is missing, or it wasn't built from HEAD. built_sha="$(read_built_sha)" missing_build_output="" - if ! missing_build_output="$(first_missing_build_output)"; then - missing_build_output="" - fi + all_build_outputs_present || true rebuild_reason="" if [ "$FORCE_REBUILD" = true ]; then rebuild_reason="forced via --force-rebuild"