fix(scripts): run gh without terminal formatting

This commit is contained in:
Vincent Koc 2026-06-22 18:44:17 +08:00
parent 2ba9d6eabe
commit f13a10c798
No known key found for this signature in database
12 changed files with 321 additions and 23 deletions

View file

@ -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] <github-login> [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")

View file

@ -4,12 +4,12 @@
* Usage: node secret-scanning.mjs <command> [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()}`);
}

View file

@ -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,

View file

@ -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",

84
scripts/lib/plain-gh.mjs Normal file
View file

@ -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,
});
}

70
scripts/lib/plain-gh.sh Normal file
View file

@ -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" "$@"
}

View file

@ -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 <<USAGE
Usage:
@ -48,11 +51,16 @@ USAGE
require_cmds() {
local missing=()
local cmd
for cmd in git gh jq rg pnpm node; do
for cmd in git jq rg pnpm node; do
if ! command -v "$cmd" >/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

View file

@ -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"] },
);

View file

@ -60,6 +60,7 @@ exit 64
env: {
...process.env,
FAKE_GH_LOG: logPath,
OPENCLAW_GH_BIN: ghPath,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
},
});

View file

@ -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");
});
});

View file

@ -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 <PR>");
expect(script).toContain("scripts/pr prepare-run <PR>");
expect(script).toContain("scripts/pr merge-run <PR>");

View file

@ -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 ?? ""}`,
},
},