From f13a10c798d758412d696b0c86faa309a8a8e129 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 22 Jun 2026 18:44:17 +0800 Subject: [PATCH] fix(scripts): run gh without terminal formatting --- .../scripts/github-activity.sh | 15 ++- .../scripts/secret-scanning.mjs | 4 +- .../scripts/release-ci-summary.mjs | 9 +- scripts/ci-run-timings.mjs | 27 +++-- scripts/lib/plain-gh.mjs | 84 ++++++++++++++ scripts/lib/plain-gh.sh | 70 ++++++++++++ scripts/pr | 14 ++- scripts/verify-pr-hosted-gates.mjs | 5 +- test/scripts/github-activity-helper.test.ts | 1 + test/scripts/plain-gh.test.ts | 108 ++++++++++++++++++ test/scripts/pr-wrappers.test.ts | 3 + .../secret-scanning-maintainer.test.ts | 4 +- 12 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 scripts/lib/plain-gh.mjs create mode 100644 scripts/lib/plain-gh.sh create mode 100644 test/scripts/plain-gh.test.ts diff --git a/.agents/skills/openclaw-pr-maintainer/scripts/github-activity.sh b/.agents/skills/openclaw-pr-maintainer/scripts/github-activity.sh index 9ec362558609..b29210ac7ca3 100755 --- a/.agents/skills/openclaw-pr-maintainer/scripts/github-activity.sh +++ b/.agents/skills/openclaw-pr-maintainer/scripts/github-activity.sh @@ -4,6 +4,14 @@ set -euo pipefail repo="openclaw/openclaw" months="12" include_global="0" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(git -C "$script_dir/../../../.." rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$repo_root" ]; then + repo_root="$(cd "$script_dir/../../../.." && pwd)" +fi + +# shellcheck disable=SC1091 +source "$repo_root/scripts/lib/plain-gh.sh" usage() { printf 'Usage: %s [--repo owner/repo] [--months N] [--global] [login...]\n' "$0" @@ -18,6 +26,10 @@ need() { command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" } +gh() { + gh_plain "$@" +} + date_utc_relative_months() { local count="$1" if date -u -v-"${count}"m +%Y-%m-%dT00:00:00Z >/dev/null 2>&1; then @@ -131,7 +143,8 @@ done exit 2 } -need gh +OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh" +export OPENCLAW_GH_BIN need jq since_ts=$(date_utc_relative_months "$months") diff --git a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs index 69b4f34922b8..f208f34fb787 100644 --- a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs +++ b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs @@ -4,12 +4,12 @@ * Usage: node secret-scanning.mjs [options] */ -import { spawnSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs"; const REPO = "openclaw/openclaw"; const REPO_URL = `https://github.com/${REPO}`; @@ -29,7 +29,7 @@ function tmpFile(purpose) { } function gh(args, { json = true, allowFailure = false } = {}) { - const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }); + const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }); if (proc.status !== 0 && !allowFailure) { fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`); } diff --git a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs b/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs index 18fd316c0b34..79c2caebe1f0 100755 --- a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs +++ b/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs @@ -5,6 +5,7 @@ */ import { execFileSync } from "node:child_process"; import process from "node:process"; +import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs"; const runId = process.argv[2]; const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw"; @@ -15,8 +16,9 @@ if (!runId) { } function gh(args) { - return execFileSync("gh", args, { + return execFileSync(resolvePlainGhBin(), args, { encoding: "utf8", + env: plainGhEnv(), stdio: ["ignore", "pipe", "pipe"], }); } @@ -32,14 +34,15 @@ function githubRestJson(pathSuffix) { "-lc", [ "set -euo pipefail", - 'token="$(gh auth token)"', + 'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"', 'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"', ].join("\n"), ], { encoding: "utf8", env: { - ...process.env, + ...plainGhEnv(), + OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(), OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`, }, maxBuffer: 16 * 1024 * 1024, diff --git a/scripts/ci-run-timings.mjs b/scripts/ci-run-timings.mjs index a945784809bc..cb756ae215a6 100644 --- a/scripts/ci-run-timings.mjs +++ b/scripts/ci-run-timings.mjs @@ -3,6 +3,7 @@ // Summarizes GitHub Actions run/job timings for CI analysis. import { execFileSync } from "node:child_process"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; +import { execPlainGh } from "./lib/plain-gh.mjs"; const DEFAULT_GITHUB_REPOSITORY = "openclaw/openclaw"; const RUN_JOBS_PAGE_SIZE = 20; @@ -17,12 +18,17 @@ function parseJsonCommand(command, args, options = {}) { let lastError; for (let attempt = 0; attempt <= GH_JSON_RETRY_DELAYS_MS.length; attempt += 1) { try { - return JSON.parse( - execFileSync(command, args, { - encoding: "utf8", - ...options, - }), - ); + const stdout = + command === "gh" + ? execPlainGh(args, { + encoding: "utf8", + ...options, + }) + : execFileSync(command, args, { + encoding: "utf8", + ...options, + }); + return JSON.parse(stdout); } catch (error) { lastError = error; const message = error instanceof Error ? error.message : String(error); @@ -216,8 +222,7 @@ export function selectLatestMainPushCiRun(runs, headSha = null) { } function getLatestCiRunId() { - const raw = execFileSync( - "gh", + const raw = execPlainGh( ["run", "list", "--branch", "main", "--workflow", "CI", "--limit", "1", "--json", "databaseId"], { encoding: "utf8" }, ); @@ -240,8 +245,7 @@ function getRemoteMainSha() { function getLatestMainPushCiRunId() { const headSha = getRemoteMainSha(); - const raw = execFileSync( - "gh", + const raw = execPlainGh( [ "run", "list", @@ -264,8 +268,7 @@ function getLatestMainPushCiRunId() { } function listRecentSuccessfulCiRuns(limit) { - const raw = execFileSync( - "gh", + const raw = execPlainGh( [ "run", "list", diff --git a/scripts/lib/plain-gh.mjs b/scripts/lib/plain-gh.mjs new file mode 100644 index 000000000000..da3773668e78 --- /dev/null +++ b/scripts/lib/plain-gh.mjs @@ -0,0 +1,84 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +function isExecutable(filePath) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function pathEntries(env) { + return String(env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean); +} + +export function plainGhEnv(env = process.env) { + const next = { ...env }; + delete next.CLICOLOR; + delete next.CLICOLOR_FORCE; + delete next.COLORTERM; + delete next.GH_FORCE_TTY; + next.NO_COLOR = "1"; + next.FORCE_COLOR = "0"; + next.CLICOLOR = "0"; + next.CLICOLOR_FORCE = "0"; + return next; +} + +export function resolvePlainGhBin(env = process.env) { + if (env.OPENCLAW_GH_BIN) { + if (isExecutable(env.OPENCLAW_GH_BIN)) { + return env.OPENCLAW_GH_BIN; + } + throw new Error(`OPENCLAW_GH_BIN is not executable: ${env.OPENCLAW_GH_BIN}`); + } + + for (const candidate of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh"]) { + if (isExecutable(candidate)) { + return candidate; + } + } + + const homeBin = env.HOME ? path.join(env.HOME, "bin") : ""; + for (const entry of pathEntries(env)) { + if (homeBin && entry === homeBin) { + continue; + } + const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh"); + if (isExecutable(candidate)) { + return candidate; + } + } + + for (const entry of pathEntries(env)) { + const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh"); + if (isExecutable(candidate)) { + return candidate; + } + } + + throw new Error("missing required command: gh"); +} + +export function execPlainGh(args, options = {}) { + const env = plainGhEnv(options.env ?? process.env); + const ghBin = resolvePlainGhBin(env); + return execFileSync(ghBin, args, { + ...options, + env, + }); +} + +export function spawnPlainGh(args, options = {}) { + const env = plainGhEnv(options.env ?? process.env); + const ghBin = resolvePlainGhBin(env); + return spawnSync(ghBin, args, { + ...options, + env, + }); +} diff --git a/scripts/lib/plain-gh.sh b/scripts/lib/plain-gh.sh new file mode 100644 index 000000000000..232ad760757e --- /dev/null +++ b/scripts/lib/plain-gh.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +plain_gh_env() { + env \ + -u CLICOLOR \ + -u CLICOLOR_FORCE \ + -u COLORTERM \ + -u GH_FORCE_TTY \ + NO_COLOR=1 \ + FORCE_COLOR=0 \ + CLICOLOR=0 \ + CLICOLOR_FORCE=0 \ + "$@" +} + +resolve_plain_gh_bin() { + if [ -n "${OPENCLAW_GH_BIN:-}" ]; then + if [ -x "$OPENCLAW_GH_BIN" ]; then + printf '%s\n' "$OPENCLAW_GH_BIN" + return 0 + fi + printf 'OPENCLAW_GH_BIN is not executable: %s\n' "$OPENCLAW_GH_BIN" >&2 + return 1 + fi + + local candidate + for candidate in /opt/homebrew/bin/gh /usr/local/bin/gh; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + if candidate=$(PATH="$(plain_gh_search_path)" type -P gh 2>/dev/null); then + printf '%s\n' "$candidate" + return 0 + fi + + type -P gh 2>/dev/null +} + +plain_gh_search_path() { + local path_value="${PATH:-}" + local home_bin="${HOME:-}/bin" + local item + local output="" + local first=true + local path_parts=() + + IFS=':' read -r -a path_parts <<<"$path_value" + for item in "${path_parts[@]}"; do + if [ -n "${HOME:-}" ] && [ "$item" = "$home_bin" ]; then + continue + fi + if [ "$first" = "true" ]; then + output="$item" + first=false + else + output="${output}:$item" + fi + done + + printf '%s\n' "$output" +} + +gh_plain() { + local gh_bin + gh_bin=$(resolve_plain_gh_bin) || return 1 + plain_gh_env "$gh_bin" "$@" +} diff --git a/scripts/pr b/scripts/pr index 5b8ee011fb2d..1f38df3b2e08 100755 --- a/scripts/pr +++ b/scripts/pr @@ -21,6 +21,9 @@ if common_git_dir=$(git -C "$script_parent_dir" rev-parse --path-format=absolute fi fi +# shellcheck disable=SC1091 +source "$script_parent_dir/lib/plain-gh.sh" + usage() { cat </dev/null 2>&1; then missing+=("$cmd") fi done + if ! OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)"; then + missing+=("gh") + else + export OPENCLAW_GH_BIN + fi if [ "${#missing[@]}" -gt 0 ]; then echo "Missing required command(s): ${missing[*]}" @@ -60,6 +68,10 @@ require_cmds() { fi } +gh() { + gh_plain "$@" +} + # shellcheck disable=SC1091 source "$script_parent_dir/pr-lib/worktree.sh" # shellcheck disable=SC1091 diff --git a/scripts/verify-pr-hosted-gates.mjs b/scripts/verify-pr-hosted-gates.mjs index 832fc9aed873..ff4a9065adbd 100644 --- a/scripts/verify-pr-hosted-gates.mjs +++ b/scripts/verify-pr-hosted-gates.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node -import { execFileSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; +import { execPlainGh } from "./lib/plain-gh.mjs"; export const SCHEDULED_HOSTED_WORKFLOWS = [ "Blacksmith Testbox", @@ -209,8 +209,7 @@ export function collectHostedGateEvidence({ sha, workflowRuns, changelogOnly = f } function loadWorkflowRuns(repo, sha) { - const raw = execFileSync( - "gh", + const raw = execPlainGh( ["api", `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`, "--paginate", "--slurp"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ); diff --git a/test/scripts/github-activity-helper.test.ts b/test/scripts/github-activity-helper.test.ts index 0272600b218c..4be42222ab6b 100644 --- a/test/scripts/github-activity-helper.test.ts +++ b/test/scripts/github-activity-helper.test.ts @@ -60,6 +60,7 @@ exit 64 env: { ...process.env, FAKE_GH_LOG: logPath, + OPENCLAW_GH_BIN: ghPath, PATH: `${binDir}:${process.env.PATH ?? ""}`, }, }); diff --git a/test/scripts/plain-gh.test.ts b/test/scripts/plain-gh.test.ts new file mode 100644 index 000000000000..7874380dc481 --- /dev/null +++ b/test/scripts/plain-gh.test.ts @@ -0,0 +1,108 @@ +// Plain GitHub CLI helper tests cover wrapper-safe gh execution for maintainer scripts. +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { plainGhEnv, resolvePlainGhBin } from "../../scripts/lib/plain-gh.mjs"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeFakeGh(): string { + const dir = mkdtempSync(path.join(tmpdir(), "plain-gh-")); + tempDirs.push(dir); + const binDir = path.join(dir, "bin"); + mkdirSync(binDir); + const ghPath = path.join(binDir, "gh"); + writeFileSync( + ghPath, + `#!/usr/bin/env bash +printf 'argv=%s\\n' "$*" +printf 'NO_COLOR=%s\\n' "\${NO_COLOR-}" +printf 'GH_FORCE_TTY=%s\\n' "\${GH_FORCE_TTY-}" +printf 'FORCE_COLOR=%s\\n' "\${FORCE_COLOR-}" +printf 'CLICOLOR=%s\\n' "\${CLICOLOR-}" +printf 'CLICOLOR_FORCE=%s\\n' "\${CLICOLOR_FORCE-}" +printf 'COLORTERM_SET=%s\\n' "\${COLORTERM+x}" +`, + ); + chmodSync(ghPath, 0o755); + return ghPath; +} + +describe("plain gh helpers", () => { + it("prefers OPENCLAW_GH_BIN over PATH shims", () => { + const ghPath = makeFakeGh(); + + expect( + resolvePlainGhBin({ + HOME: path.dirname(path.dirname(ghPath)), + OPENCLAW_GH_BIN: ghPath, + PATH: "", + }), + ).toBe(ghPath); + }); + + it("normalizes color environment for JSON-safe gh output", () => { + expect( + plainGhEnv({ + CLICOLOR: "1", + CLICOLOR_FORCE: "1", + COLORTERM: "truecolor", + FORCE_COLOR: "3", + }), + ).toMatchObject({ + NO_COLOR: "1", + FORCE_COLOR: "0", + CLICOLOR: "0", + CLICOLOR_FORCE: "0", + }); + expect(plainGhEnv({ COLORTERM: "truecolor" })).not.toHaveProperty("COLORTERM"); + expect(plainGhEnv({ GH_FORCE_TTY: "120" })).not.toHaveProperty("GH_FORCE_TTY"); + }); + + it("runs the shell helper with color disabled", () => { + const ghPath = makeFakeGh(); + const outputPath = path.join(path.dirname(path.dirname(ghPath)), "output.txt"); + const script = [ + "set -euo pipefail", + "source scripts/lib/plain-gh.sh", + `OPENCLAW_GH_BIN=${JSON.stringify(ghPath)}`, + "export OPENCLAW_GH_BIN", + `gh_plain api rate_limit > ${JSON.stringify(outputPath)}`, + ].join("\n"); + + const result = spawnSync("bash", ["-lc", script], { + encoding: "utf8", + env: { + ...process.env, + CLICOLOR: "1", + CLICOLOR_FORCE: "1", + COLORTERM: "truecolor", + FORCE_COLOR: "3", + }, + }); + + expect(result.status).toBe(0); + expect(readFileSync(outputPath, "utf8")).toContain("argv=api rate_limit"); + expect(readFileSync(outputPath, "utf8")).toContain("NO_COLOR=1"); + expect(readFileSync(outputPath, "utf8")).toContain("GH_FORCE_TTY="); + expect(readFileSync(outputPath, "utf8")).toContain("FORCE_COLOR=0"); + expect(readFileSync(outputPath, "utf8")).toContain("CLICOLOR=0"); + expect(readFileSync(outputPath, "utf8")).toContain("CLICOLOR_FORCE=0"); + expect(readFileSync(outputPath, "utf8")).toContain("COLORTERM_SET="); + }); + + it("keeps the shell resolver on external gh binaries", () => { + const helper = readFileSync("scripts/lib/plain-gh.sh", "utf8"); + + expect(helper).toContain("type -P gh"); + expect(helper).not.toContain("command -v gh"); + }); +}); diff --git a/test/scripts/pr-wrappers.test.ts b/test/scripts/pr-wrappers.test.ts index 44b0104fea07..14f7f25a0a72 100644 --- a/test/scripts/pr-wrappers.test.ts +++ b/test/scripts/pr-wrappers.test.ts @@ -12,6 +12,9 @@ describe("scripts/pr wrappers", () => { expect(script).toContain("export NO_COLOR=1"); expect(script).toContain("unset COLORTERM"); + expect(script).toContain('source "$script_parent_dir/lib/plain-gh.sh"'); + expect(script).toContain("OPENCLAW_GH_BIN="); + expect(script).toContain("gh_plain"); expect(script).toContain("scripts/pr review-init "); expect(script).toContain("scripts/pr prepare-run "); expect(script).toContain("scripts/pr merge-run "); diff --git a/test/scripts/secret-scanning-maintainer.test.ts b/test/scripts/secret-scanning-maintainer.test.ts index f86d7db191fa..56ea411d870f 100644 --- a/test/scripts/secret-scanning-maintainer.test.ts +++ b/test/scripts/secret-scanning-maintainer.test.ts @@ -38,9 +38,10 @@ describe("secret scanning maintainer script", () => { const tempDir = createTempDir("openclaw-secret-scan-"); const binDir = path.join(tempDir, "bin"); const ghLog = path.join(tempDir, "gh.log"); + const ghPath = path.join(binDir, "gh"); fs.mkdirSync(binDir); fs.writeFileSync( - path.join(binDir, "gh"), + ghPath, `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "${ghLog}"\nprintf '{}\\n'\n`, { mode: 0o755 }, ); @@ -58,6 +59,7 @@ describe("secret scanning maintainer script", () => { encoding: "utf8", env: { ...process.env, + OPENCLAW_GH_BIN: ghPath, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, }, },