mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(ci): run the root-directory guard on stock macOS bash 3.2 (#12879)
* fix(ci): run the root-directory guard on stock macOS bash 3.2 The guard script builds its base-tree lookup with `declare -A`, which needs bash 4+. Its test spawns plain `bash` from PATH, and stock macOS has shipped /bin/bash 3.2 since 2007, so on any Mac without a Homebrew bash the script exits 2 before asserting anything and the default `pnpm test` suite fails 3 of the guard's 4 cases. Machines with a Homebrew bash on PATH never see it, which is why it went unnoticed. Replace the associative array with a plain-array linear scan. Root directories number in the dozens, so the O(n^2) membership check is negligible, and the NUL-delimited reads that protect unusual filenames stay as they were. The empty-array expansion is guarded for `set -u` under bash 3.2. All four guard tests now pass with /bin/bash 3.2; behavior under CI's bash 5 is unchanged. * fix(ci): run the root-directory guard under node instead of bash The guard is the only check in the repo written in shell, and it used `declare -A`, which stock macOS `/bin/bash` 3.2 does not have — so the guard's own test suite failed 3 of 4 cases on any Mac without a Homebrew bash. CI never noticed because runners ship bash 5. Porting it to node removes the interpreter-version variable instead of working around one construct: node is what the sibling script in this directory already uses, it is the runtime that runs the test, and the NUL-delimited read is the same shape as check-changed-code-quality.mjs. It also drops a latent false pass — a failing `git ls-tree` inside the shell's `< <(...)` was not caught by `pipefail`, so the read loop saw nothing and the guard reported success. `execFileSync` throws instead, which is why the two `git rev-parse --verify` probes are no longer needed. Output and exit codes are otherwise unchanged; the usage line now prints node's script path where the shell printed `$0`. Tests pin each guarantee and fail when it is reverted: NUL-delimited reads so odd paths are reported unmangled, exit 2 on bad usage, and git's own 128 with no node stack trace when a sha does not resolve. * fix(ci): keep root entry bytes intact and fence guard output git pathnames are arbitrary bytes, but the guard read ls-tree with encoding 'utf8', so every invalid sequence collapsed to U+FFFD. That mangled the reported name and, because the replacement is not injective, let two different entries compare equal — a genuinely new root entry could be waved through as pre-existing. Read the bytes as latin1 and write them back unchanged. The blocked-entry list is also attacker-controlled and went straight to stdout. The runner trims leading whitespace before matching '::', so an indented entry name still parses as a workflow command, and a pathname may embed a newline. Wrap the list in ::stop-commands:: with a random resume token so only the guard's own annotation is acted on. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
co-authored by
Brennan Benson
parent
9a6c5cddc9
commit
850342a3e0
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
function readRootEntries(sha) {
|
||||
// Why: a git pathname is arbitrary bytes, and 'utf8' folds every invalid
|
||||
// sequence to U+FFFD — that mangles the reported name and makes two different
|
||||
// entries compare equal, so a genuinely new one can slip past the Set below.
|
||||
// latin1 maps each byte to one code unit, so the bytes survive the round trip.
|
||||
const stdout = execFileSync('git', ['ls-tree', '-z', '--name-only', sha], {
|
||||
encoding: 'latin1',
|
||||
stdio: ['ignore', 'pipe', 'inherit']
|
||||
})
|
||||
return stdout.split('\0').filter(Boolean)
|
||||
}
|
||||
|
||||
function checkRootDirectoryEntries(argv) {
|
||||
if (argv.length !== 2) {
|
||||
console.error(`Usage: ${process.argv[1]} <base-sha> <head-sha>`)
|
||||
return 2
|
||||
}
|
||||
|
||||
const [baseSha, headSha] = argv
|
||||
const baseEntries = new Set(readRootEntries(baseSha))
|
||||
const blockedEntries = readRootEntries(headSha).filter((entry) => !baseEntries.has(entry))
|
||||
|
||||
if (blockedEntries.length === 0) {
|
||||
console.log('Root directory guard passed: no new root-level files or folders.')
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(
|
||||
'::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page.'
|
||||
)
|
||||
console.log('Root directory guard failed.')
|
||||
console.log(
|
||||
'New root-level files or folders are not allowed because they bloat the GitHub landing page.'
|
||||
)
|
||||
console.log('Move each new entry under an existing top-level directory.')
|
||||
console.log('Blocked entries:')
|
||||
// Why: an entry name is attacker-controlled and may start with '::' (the runner
|
||||
// trims leading spaces before matching) or embed a newline, so printing it bare
|
||||
// lets a PR forge annotations. Fence the untrusted list with an unguessable
|
||||
// stop-commands token, and write the raw bytes rather than a re-encoded string.
|
||||
const resumeToken = randomUUID()
|
||||
console.log(`::stop-commands::${resumeToken}`)
|
||||
for (const entry of blockedEntries) {
|
||||
process.stdout.write(Buffer.from(` ${entry}\n`, 'latin1'))
|
||||
}
|
||||
console.log(`::${resumeToken}::`)
|
||||
return 1
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: process.exit truncates a piped write part-way through on macOS, so set
|
||||
// exitCode and let node flush the blocked-entry list before it exits.
|
||||
process.exitCode = checkRootDirectoryEntries(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
// Why: git already reported the failure on the inherited stderr, so surface its
|
||||
// status rather than a node stack trace. Anything else is a real bug — rethrow.
|
||||
if (typeof error.status !== 'number') {
|
||||
throw error
|
||||
}
|
||||
process.exitCode = error.status
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 <base-sha> <head-sha>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
base_sha=$1
|
||||
head_sha=$2
|
||||
git rev-parse --verify "${base_sha}^{tree}" >/dev/null
|
||||
git rev-parse --verify "${head_sha}^{tree}" >/dev/null
|
||||
|
||||
declare -A base_entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
base_entries["$entry"]=1
|
||||
done < <(git ls-tree -z --name-only "$base_sha")
|
||||
|
||||
blocked_entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
if [[ -z "${base_entries[$entry]+present}" ]]; then
|
||||
blocked_entries+=("$entry")
|
||||
fi
|
||||
done < <(git ls-tree -z --name-only "$head_sha")
|
||||
|
||||
if (( ${#blocked_entries[@]} == 0 )); then
|
||||
echo "Root directory guard passed: no new root-level files or folders."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page."
|
||||
echo "Root directory guard failed."
|
||||
echo "New root-level files or folders are not allowed because they bloat the GitHub landing page."
|
||||
echo "Move each new entry under an existing top-level directory."
|
||||
printf 'Blocked entries:\n'
|
||||
printf ' %s\n' "${blocked_entries[@]}"
|
||||
exit 1
|
||||
Reference in New Issue
Block a user