Skip to content

Commit 724341e

Browse files
authored
Merge pull request #58 from Jamkris/feat/upstream-drift-tracker
feat: upstream drift detector
2 parents 1c25fb9 + cf359a5 commit 724341e

5 files changed

Lines changed: 618 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Upstream Drift
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
check:
11+
name: Check upstream drift
12+
runs-on: ubuntu-latest
13+
timeout-minutes: 5
14+
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: Setup Node.js
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: '20.x'
23+
24+
- name: Check upstream drift
25+
env:
26+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27+
run: node scripts/upstream/check-upstream-drift.js

scripts/lib/upstream-drift.js

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* Upstream drift engine: pure functions used by the upstream-drift Action.
3+
*
4+
* `scripts/upstream/check-upstream-drift.js` calls these helpers and drives
5+
* the `gh` CLI. Functions here have no I/O so they can be unit-tested in
6+
* isolation against `tests/lib/upstream-drift.test.js`.
7+
*
8+
* State of record:
9+
* - Human: upstream/README.md (Upstream baseline section)
10+
* - Machine: upstream/.upstream-sync.json
11+
*
12+
* Schema for the JSON state file:
13+
* {
14+
* "upstream": "owner/repo",
15+
* "lastSyncedSha": "<40-char SHA>",
16+
* "lastSyncedAt": "<YYYY-MM-DD or ISO 8601>",
17+
* "notes": "optional free text"
18+
* }
19+
*/
20+
21+
const SHA_RE = /^[0-9a-f]{40}$/;
22+
const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
23+
const COMMIT_BODY_LIMIT = 50;
24+
25+
/**
26+
* Parse and validate the contents of `upstream/.upstream-sync.json`.
27+
*
28+
* Throws on any structural problem — the workflow should fail fast rather
29+
* than silently treat a malformed state file as "no drift".
30+
*
31+
* @param {string} jsonString
32+
* @returns {{ upstream: string, lastSyncedSha: string, lastSyncedAt: string, notes?: string }}
33+
*/
34+
function parseUpstreamState(jsonString) {
35+
let parsed;
36+
try {
37+
parsed = JSON.parse(jsonString);
38+
} catch (err) {
39+
throw new Error(`upstream-sync state is not valid JSON: ${err.message}`, { cause: err });
40+
}
41+
42+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
43+
throw new Error('upstream-sync state must be a JSON object');
44+
}
45+
46+
const { upstream, lastSyncedSha, lastSyncedAt, notes } = parsed;
47+
48+
if (typeof upstream !== 'string' || !REPO_RE.test(upstream)) {
49+
throw new Error(`upstream must be a "owner/repo" string (got: ${JSON.stringify(upstream)})`);
50+
}
51+
52+
if (typeof lastSyncedSha !== 'string' || !SHA_RE.test(lastSyncedSha)) {
53+
throw new Error(`lastSyncedSha must be a 40-char hex string (got: ${JSON.stringify(lastSyncedSha)})`);
54+
}
55+
56+
if (typeof lastSyncedAt !== 'string' || lastSyncedAt.length === 0) {
57+
throw new Error(`lastSyncedAt must be a non-empty string (got: ${JSON.stringify(lastSyncedAt)})`);
58+
}
59+
60+
if (notes !== undefined && typeof notes !== 'string') {
61+
throw new Error(`notes, when present, must be a string (got: ${typeof notes})`);
62+
}
63+
64+
return { upstream, lastSyncedSha, lastSyncedAt, notes };
65+
}
66+
67+
/**
68+
* Short SHA (first 7 chars) for display in titles and URLs.
69+
*
70+
* @param {string} sha
71+
* @returns {string}
72+
*/
73+
function shortSha(sha) {
74+
if (typeof sha !== 'string') return '';
75+
return sha.slice(0, 7);
76+
}
77+
78+
/**
79+
* Title shown on the rolling tracking issue. Recomputed every run so the
80+
* title itself encodes the current delta count.
81+
*
82+
* @param {{ count: number, lastSyncedShaShort: string }} args
83+
* @returns {string}
84+
*/
85+
function formatIssueTitle({ count, lastSyncedShaShort }) {
86+
if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) {
87+
throw new Error(`formatIssueTitle: count must be a non-negative integer (got: ${count})`);
88+
}
89+
if (typeof lastSyncedShaShort !== 'string' || lastSyncedShaShort.length === 0) {
90+
throw new Error('formatIssueTitle: lastSyncedShaShort must be a non-empty string');
91+
}
92+
const noun = count === 1 ? 'commit' : 'commits';
93+
return `Upstream sync: ${count} new ${noun} in ECC since ${lastSyncedShaShort}`;
94+
}
95+
96+
/**
97+
* Body for the rolling tracking issue. Truncates the commit list at
98+
* COMMIT_BODY_LIMIT entries and links to the compare URL for the full diff.
99+
*
100+
* @param {{
101+
* commits: Array<{ sha: string, author: string, message: string }>,
102+
* lastSyncedSha: string,
103+
* upstreamHeadSha: string,
104+
* upstreamRepo: string,
105+
* }} args
106+
* @returns {string}
107+
*/
108+
function formatIssueBody({ commits, lastSyncedSha, upstreamHeadSha, upstreamRepo }) {
109+
if (!Array.isArray(commits)) {
110+
throw new Error('formatIssueBody: commits must be an array');
111+
}
112+
if (!REPO_RE.test(upstreamRepo)) {
113+
throw new Error(`formatIssueBody: upstreamRepo must be "owner/repo" (got: ${upstreamRepo})`);
114+
}
115+
116+
const compareUrl = `https://github.com/${upstreamRepo}/compare/${lastSyncedSha}...${upstreamHeadSha}`;
117+
const lines = [];
118+
119+
lines.push(`Upstream \`${upstreamRepo}\` has **${commits.length}** commit(s) ahead of the recorded baseline.`);
120+
lines.push('');
121+
lines.push(`- **Baseline**: \`${shortSha(lastSyncedSha)}\` (recorded in [\`upstream/.upstream-sync.json\`](../blob/main/upstream/.upstream-sync.json))`);
122+
lines.push(`- **Upstream HEAD**: \`${shortSha(upstreamHeadSha)}\``);
123+
lines.push(`- **Full diff**: ${compareUrl}`);
124+
lines.push('');
125+
126+
if (commits.length === 0) {
127+
lines.push('_No commits to show._');
128+
lines.push('');
129+
} else {
130+
const visible = commits.slice(0, COMMIT_BODY_LIMIT);
131+
const truncated = commits.length - visible.length;
132+
133+
lines.push('<details>');
134+
lines.push(`<summary>Commits (${commits.length} total${truncated > 0 ? `, showing first ${visible.length}` : ''})</summary>`);
135+
lines.push('');
136+
for (const c of visible) {
137+
const firstLine = (c.message || '').split('\n')[0].trim();
138+
lines.push(`- \`${shortSha(c.sha)}\` ${c.author ? `(@${c.author}) ` : ''}${firstLine}`);
139+
}
140+
if (truncated > 0) {
141+
lines.push(`- _… and ${truncated} more. See the [full diff](${compareUrl})._`);
142+
}
143+
lines.push('');
144+
lines.push('</details>');
145+
lines.push('');
146+
}
147+
148+
lines.push('---');
149+
lines.push('');
150+
lines.push('To close this issue, advance the baseline by following the procedure in [`upstream/README.md`](../blob/main/upstream/README.md#recording-a-sync). This issue is maintained automatically by `.github/workflows/upstream-drift.yml`.');
151+
152+
return lines.join('\n');
153+
}
154+
155+
/**
156+
* Compare URL for the GitHub UI. Convenience export — also used by the
157+
* entry script for the Phase 1 log output.
158+
*
159+
* @param {string} upstreamRepo
160+
* @param {string} baseSha
161+
* @param {string} headSha
162+
* @returns {string}
163+
*/
164+
function compareUrl(upstreamRepo, baseSha, headSha) {
165+
if (!REPO_RE.test(upstreamRepo)) {
166+
throw new Error(`compareUrl: upstreamRepo must be "owner/repo" (got: ${upstreamRepo})`);
167+
}
168+
return `https://github.com/${upstreamRepo}/compare/${baseSha}...${headSha}`;
169+
}
170+
171+
module.exports = {
172+
COMMIT_BODY_LIMIT,
173+
parseUpstreamState,
174+
shortSha,
175+
formatIssueTitle,
176+
formatIssueBody,
177+
compareUrl,
178+
};
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Upstream drift detector — Phase 1 (log-only).
5+
*
6+
* Reads `upstream/.upstream-sync.json`, asks the GitHub API how many
7+
* commits the upstream repo is ahead of the recorded baseline, and logs
8+
* the result. Does NOT create or update GitHub issues yet — that lands
9+
* in Phase 2.
10+
*
11+
* Designed to run in `.github/workflows/upstream-drift.yml`:
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).
19+
*/
20+
21+
const fs = require('fs');
22+
const path = require('path');
23+
const { execFileSync } = require('child_process');
24+
25+
const lib = require('../lib/upstream-drift');
26+
27+
const REPO_ROOT = path.join(__dirname, '..', '..');
28+
const STATE_PATH = path.join(REPO_ROOT, 'upstream', '.upstream-sync.json');
29+
30+
function log(message) {
31+
console.log(`[upstream-drift] ${message}`);
32+
}
33+
34+
function warn(message) {
35+
console.error(`[upstream-drift] ${message}`);
36+
}
37+
38+
function readState() {
39+
let raw;
40+
try {
41+
raw = fs.readFileSync(STATE_PATH, 'utf8');
42+
} catch (err) {
43+
throw new Error(`Could not read state file at ${STATE_PATH}: ${err.message}`, { cause: err });
44+
}
45+
return lib.parseUpstreamState(raw);
46+
}
47+
48+
/**
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).
57+
*
58+
* @param {string} endpoint - path like "repos/owner/repo/compare/A...B"
59+
* @returns {object}
60+
*/
61+
function ghApi(endpoint) {
62+
let stdout;
63+
try {
64+
stdout = execFileSync('gh', ['api', endpoint], {
65+
encoding: 'utf8',
66+
stdio: ['ignore', 'pipe', 'pipe'],
67+
// The compare endpoint can return well over 1 MB of JSON when
68+
// the delta includes hundreds of commits. Default maxBuffer is
69+
// 1 MB and the process is killed with SIGTERM on overflow.
70+
maxBuffer: 50 * 1024 * 1024,
71+
});
72+
} catch (err) {
73+
const stderr = (err.stderr || '').toString();
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;
79+
}
80+
try {
81+
return JSON.parse(stdout);
82+
} catch (err) {
83+
throw new Error(`gh api returned non-JSON for "${endpoint}": ${err.message}`, { cause: err });
84+
}
85+
}
86+
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+
110+
function summarize(state, compare) {
111+
const status = compare.status;
112+
const ahead = compare.ahead_by;
113+
const behind = compare.behind_by;
114+
const headSha = compare.commits && compare.commits.length > 0
115+
? compare.commits[compare.commits.length - 1].sha
116+
: state.lastSyncedSha;
117+
118+
log(`Status: ${status} (ahead_by=${ahead}, behind_by=${behind})`);
119+
log(`Baseline: ${lib.shortSha(state.lastSyncedSha)} (recorded ${state.lastSyncedAt})`);
120+
log(`Upstream HEAD: ${lib.shortSha(headSha)}`);
121+
log(`Compare: ${lib.compareUrl(state.upstream, state.lastSyncedSha, headSha)}`);
122+
123+
if (status === 'identical' || ahead === 0) {
124+
log('No drift — baseline matches upstream HEAD.');
125+
return;
126+
}
127+
128+
if (status === 'ahead' || status === 'diverged') {
129+
log(`Drift detected: upstream is ${ahead} commit(s) ahead.`);
130+
return;
131+
}
132+
133+
if (status === 'behind') {
134+
log('Baseline is ahead of upstream HEAD — likely a manual cherry-pick or upstream reset.');
135+
return;
136+
}
137+
138+
log(`Unknown compare status "${status}" — surfacing as drift for review.`);
139+
}
140+
141+
function main() {
142+
let state;
143+
try {
144+
state = readState();
145+
} catch (err) {
146+
console.error(err.message);
147+
process.exit(1);
148+
}
149+
150+
log(`Upstream: ${state.upstream}`);
151+
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)) {
156+
process.exit(0);
157+
}
158+
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+
180+
summarize(state, compare);
181+
}
182+
183+
if (require.main === module) {
184+
main();
185+
}
186+
187+
module.exports = { readState, ghApi, isUpstreamReachable, summarize };

0 commit comments

Comments
 (0)