Skip to content

Commit cf359a5

Browse files
committed
fix: address PR #58 review feedback (execFileSync + 404 disambiguation)
P2 — execSync command-injection surface (CodeRabbit, cubic): Replaced execSync with a template string by execFileSync('gh', ['api', endpoint], opts). The endpoint argument now bypasses shell parsing entirely. While the input is regex-validated upstream, the defense-in-depth change costs nothing and removes a class of risk from the audit-tool surface. P2 — 404 disambiguation (cubic): The previous code treated every 404 from `gh api` as a tolerable "upstream unreachable" warning. That silently masks the much more common failure mode of a malformed `lastSyncedSha` in upstream/.upstream-sync.json — the compare endpoint also returns 404 when the baseline ref doesn't exist in a repo that does. Split into two checks: 1. Pre-flight `gh api repos/<upstream>` — 404 here is genuine rename/archive/deletion. Warn, exit 0, weekly cron stays green. 2. After pre-flight succeeds, compare-endpoint 404 is a hard error. Print a specific "Check upstream/.upstream-sync.json" message and exit 1. ghApi() now surfaces 404 as a typed error (`err.is404 === true`) so callers can act on it. Added isUpstreamReachable() as the pre-flight helper. P2 — node: prefix suggestion (CodeRabbit): Not applying — repo convention is 23 plain `require('fs')` vs. 1 `require('node:fs')` (see scripts/hooks/run.js). Switching only the new files would create inconsistency; a repo-wide refactor is out of scope for this PR. Will reply on the thread. Local smoke run: - Happy path: ECC 1518 commits ahead, clean output, exit 0. - Bad-baseline path: forced lastSyncedSha to 'deadbeef...' x 5, pre-flight passes, compare returns 404, script prints "Baseline SHA ... is not reachable" and exits 1. Lint clean. 242/242 tests pass.
1 parent 1b637a5 commit cf359a5

1 file changed

Lines changed: 72 additions & 21 deletions

File tree

scripts/upstream/check-upstream-drift.js

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,18 @@
99
* in Phase 2.
1010
*
1111
* Designed to run in `.github/workflows/upstream-drift.yml`:
12-
* - Uses `gh api` for upstream reads (public repo, no PAT needed).
13-
* - Exits 0 on success and on tolerated upstream errors (rename,
14-
* archive, deletion) so weekly cron does not produce red runs that
15-
* nobody triages.
16-
* - Exits non-zero only on programmer errors (malformed state file).
12+
* - Uses `gh api` via execFileSync (argv array — no shell parsing).
13+
* - Exits 0 on success and on tolerated upstream errors (the repo
14+
* itself was renamed/archived/deleted) so weekly cron does not
15+
* produce red runs nobody triages.
16+
* - Exits non-zero on programmer/config errors: a malformed state
17+
* file, or a baseline SHA that doesn't exist in an otherwise
18+
* reachable upstream repo (almost always a typo).
1719
*/
1820

1921
const fs = require('fs');
2022
const path = require('path');
21-
const { execSync } = require('child_process');
23+
const { execFileSync } = require('child_process');
2224

2325
const lib = require('../lib/upstream-drift');
2426

@@ -44,16 +46,22 @@ function readState() {
4446
}
4547

4648
/**
47-
* Invoke `gh api` and return the parsed JSON response, or null when the
48-
* upstream is unreachable (rename/archive/deletion). Other errors throw.
49+
* Invoke `gh api` for the given endpoint and return the parsed JSON.
50+
* Uses execFileSync with an argv array so the endpoint never reaches
51+
* a shell — defense-in-depth against future inputs that might bypass
52+
* the JSON-schema validation upstream.
53+
*
54+
* 404s are surfaced as a typed error (`err.is404 === true`) so callers
55+
* can distinguish tolerable cases (upstream repo gone) from hard
56+
* failures (invalid baseline SHA against a repo that does exist).
4957
*
5058
* @param {string} endpoint - path like "repos/owner/repo/compare/A...B"
51-
* @returns {object|null}
59+
* @returns {object}
5260
*/
5361
function ghApi(endpoint) {
5462
let stdout;
5563
try {
56-
stdout = execSync(`gh api ${endpoint}`, {
64+
stdout = execFileSync('gh', ['api', endpoint], {
5765
encoding: 'utf8',
5866
stdio: ['ignore', 'pipe', 'pipe'],
5967
// The compare endpoint can return well over 1 MB of JSON when
@@ -63,11 +71,11 @@ function ghApi(endpoint) {
6371
});
6472
} catch (err) {
6573
const stderr = (err.stderr || '').toString();
66-
if (stderr.includes('HTTP 404') || stderr.includes('Not Found')) {
67-
warn(`Upstream unreachable for endpoint "${endpoint}". Treating as tolerated failure.`);
68-
return null;
69-
}
70-
throw new Error(`gh api failed for "${endpoint}": ${stderr || err.message}`, { cause: err });
74+
const is404 = stderr.includes('HTTP 404') || stderr.includes('Not Found');
75+
const apiError = new Error(`gh api failed for "${endpoint}": ${stderr || err.message}`, { cause: err });
76+
apiError.stderr = stderr;
77+
apiError.is404 = is404;
78+
throw apiError;
7179
}
7280
try {
7381
return JSON.parse(stdout);
@@ -76,6 +84,29 @@ function ghApi(endpoint) {
7684
}
7785
}
7886

87+
/**
88+
* Pre-flight: check whether the upstream repo itself is reachable. A
89+
* 404 here genuinely means "rename / archive / deletion" — a tolerable
90+
* config drift the workflow should warn about, not fail on. A 404 on
91+
* the *compare* endpoint after this check passes is a hard error
92+
* (almost certainly a malformed `lastSyncedSha`).
93+
*
94+
* @param {string} repo - "owner/repo"
95+
* @returns {boolean} true if reachable; false if the repo returns 404
96+
*/
97+
function isUpstreamReachable(repo) {
98+
try {
99+
ghApi(`repos/${repo}`);
100+
return true;
101+
} catch (err) {
102+
if (err.is404) {
103+
warn(`Upstream repo "${repo}" returned 404. Treating as tolerated upstream-unreachable (rename, archive, or deletion).`);
104+
return false;
105+
}
106+
throw err;
107+
}
108+
}
109+
79110
function summarize(state, compare) {
80111
const status = compare.status;
81112
const ahead = compare.ahead_by;
@@ -117,20 +148,40 @@ function main() {
117148
}
118149

119150
log(`Upstream: ${state.upstream}`);
120-
const endpoint = `repos/${state.upstream}/compare/${state.lastSyncedSha}...HEAD`;
121-
const compare = ghApi(endpoint);
122151

123-
if (compare === null) {
124-
// Tolerated: rename/archive/deletion. Phase 2 will optionally
125-
// post a comment on the existing tracking issue if there is one.
152+
// Pre-flight: if the repo itself is 404, treat it as tolerable
153+
// (rename/archive/deletion) and exit 0. Phase 2 will optionally post
154+
// a comment on the existing tracking issue in this branch.
155+
if (!isUpstreamReachable(state.upstream)) {
126156
process.exit(0);
127157
}
128158

159+
// Repo exists. A 404 on the compare endpoint now means the recorded
160+
// baseline SHA is not reachable in the upstream repo — almost always
161+
// a typo in upstream/.upstream-sync.json. Fail loudly so the
162+
// operator notices.
163+
const endpoint = `repos/${state.upstream}/compare/${state.lastSyncedSha}...HEAD`;
164+
let compare;
165+
try {
166+
compare = ghApi(endpoint);
167+
} catch (err) {
168+
if (err.is404) {
169+
console.error(
170+
`Baseline SHA "${state.lastSyncedSha}" is not reachable in ${state.upstream}. ` +
171+
`Check upstream/.upstream-sync.json — the SHA may be malformed or refer to a commit ` +
172+
`that was force-removed from the upstream history.`,
173+
);
174+
process.exit(1);
175+
}
176+
console.error(err.message);
177+
process.exit(1);
178+
}
179+
129180
summarize(state, compare);
130181
}
131182

132183
if (require.main === module) {
133184
main();
134185
}
135186

136-
module.exports = { readState, ghApi, summarize };
187+
module.exports = { readState, ghApi, isUpstreamReachable, summarize };

0 commit comments

Comments
 (0)