Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/upstream-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Upstream Drift

on:
workflow_dispatch:

permissions:
contents: read

jobs:
check:
name: Check upstream drift
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'

- name: Check upstream drift
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/upstream/check-upstream-drift.js
178 changes: 178 additions & 0 deletions scripts/lib/upstream-drift.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Upstream drift engine: pure functions used by the upstream-drift Action.
*
* `scripts/upstream/check-upstream-drift.js` calls these helpers and drives
* the `gh` CLI. Functions here have no I/O so they can be unit-tested in
* isolation against `tests/lib/upstream-drift.test.js`.
*
* State of record:
* - Human: upstream/README.md (Upstream baseline section)
* - Machine: upstream/.upstream-sync.json
*
* Schema for the JSON state file:
* {
* "upstream": "owner/repo",
* "lastSyncedSha": "<40-char SHA>",
* "lastSyncedAt": "<YYYY-MM-DD or ISO 8601>",
* "notes": "optional free text"
* }
*/

const SHA_RE = /^[0-9a-f]{40}$/;
const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
const COMMIT_BODY_LIMIT = 50;

/**
* Parse and validate the contents of `upstream/.upstream-sync.json`.
*
* Throws on any structural problem — the workflow should fail fast rather
* than silently treat a malformed state file as "no drift".
*
* @param {string} jsonString
* @returns {{ upstream: string, lastSyncedSha: string, lastSyncedAt: string, notes?: string }}
*/
function parseUpstreamState(jsonString) {
let parsed;
try {
parsed = JSON.parse(jsonString);
} catch (err) {
throw new Error(`upstream-sync state is not valid JSON: ${err.message}`, { cause: err });
}

if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('upstream-sync state must be a JSON object');
}

const { upstream, lastSyncedSha, lastSyncedAt, notes } = parsed;

if (typeof upstream !== 'string' || !REPO_RE.test(upstream)) {
throw new Error(`upstream must be a "owner/repo" string (got: ${JSON.stringify(upstream)})`);
}

if (typeof lastSyncedSha !== 'string' || !SHA_RE.test(lastSyncedSha)) {
throw new Error(`lastSyncedSha must be a 40-char hex string (got: ${JSON.stringify(lastSyncedSha)})`);
}

if (typeof lastSyncedAt !== 'string' || lastSyncedAt.length === 0) {
throw new Error(`lastSyncedAt must be a non-empty string (got: ${JSON.stringify(lastSyncedAt)})`);
}

if (notes !== undefined && typeof notes !== 'string') {
throw new Error(`notes, when present, must be a string (got: ${typeof notes})`);
}

return { upstream, lastSyncedSha, lastSyncedAt, notes };
}

/**
* Short SHA (first 7 chars) for display in titles and URLs.
*
* @param {string} sha
* @returns {string}
*/
function shortSha(sha) {
if (typeof sha !== 'string') return '';
return sha.slice(0, 7);
}

/**
* Title shown on the rolling tracking issue. Recomputed every run so the
* title itself encodes the current delta count.
*
* @param {{ count: number, lastSyncedShaShort: string }} args
* @returns {string}
*/
function formatIssueTitle({ count, lastSyncedShaShort }) {
if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) {
throw new Error(`formatIssueTitle: count must be a non-negative integer (got: ${count})`);
}
if (typeof lastSyncedShaShort !== 'string' || lastSyncedShaShort.length === 0) {
throw new Error('formatIssueTitle: lastSyncedShaShort must be a non-empty string');
}
const noun = count === 1 ? 'commit' : 'commits';
return `Upstream sync: ${count} new ${noun} in ECC since ${lastSyncedShaShort}`;
}

/**
* Body for the rolling tracking issue. Truncates the commit list at
* COMMIT_BODY_LIMIT entries and links to the compare URL for the full diff.
*
* @param {{
* commits: Array<{ sha: string, author: string, message: string }>,
* lastSyncedSha: string,
* upstreamHeadSha: string,
* upstreamRepo: string,
* }} args
* @returns {string}
*/
function formatIssueBody({ commits, lastSyncedSha, upstreamHeadSha, upstreamRepo }) {
if (!Array.isArray(commits)) {
throw new Error('formatIssueBody: commits must be an array');
}
if (!REPO_RE.test(upstreamRepo)) {
throw new Error(`formatIssueBody: upstreamRepo must be "owner/repo" (got: ${upstreamRepo})`);
}

const compareUrl = `https://github.com/${upstreamRepo}/compare/${lastSyncedSha}...${upstreamHeadSha}`;
const lines = [];

lines.push(`Upstream \`${upstreamRepo}\` has **${commits.length}** commit(s) ahead of the recorded baseline.`);
lines.push('');
lines.push(`- **Baseline**: \`${shortSha(lastSyncedSha)}\` (recorded in [\`upstream/.upstream-sync.json\`](../blob/main/upstream/.upstream-sync.json))`);
lines.push(`- **Upstream HEAD**: \`${shortSha(upstreamHeadSha)}\``);
lines.push(`- **Full diff**: ${compareUrl}`);
lines.push('');

if (commits.length === 0) {
lines.push('_No commits to show._');
lines.push('');
} else {
const visible = commits.slice(0, COMMIT_BODY_LIMIT);
const truncated = commits.length - visible.length;

lines.push('<details>');
lines.push(`<summary>Commits (${commits.length} total${truncated > 0 ? `, showing first ${visible.length}` : ''})</summary>`);
lines.push('');
for (const c of visible) {
const firstLine = (c.message || '').split('\n')[0].trim();
lines.push(`- \`${shortSha(c.sha)}\` ${c.author ? `(@${c.author}) ` : ''}${firstLine}`);
}
if (truncated > 0) {
lines.push(`- _… and ${truncated} more. See the [full diff](${compareUrl})._`);
}
lines.push('');
lines.push('</details>');
lines.push('');
}

lines.push('---');
lines.push('');
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`.');

return lines.join('\n');
}

/**
* Compare URL for the GitHub UI. Convenience export — also used by the
* entry script for the Phase 1 log output.
*
* @param {string} upstreamRepo
* @param {string} baseSha
* @param {string} headSha
* @returns {string}
*/
function compareUrl(upstreamRepo, baseSha, headSha) {
if (!REPO_RE.test(upstreamRepo)) {
throw new Error(`compareUrl: upstreamRepo must be "owner/repo" (got: ${upstreamRepo})`);
}
return `https://github.com/${upstreamRepo}/compare/${baseSha}...${headSha}`;
}

module.exports = {
COMMIT_BODY_LIMIT,
parseUpstreamState,
shortSha,
formatIssueTitle,
formatIssueBody,
compareUrl,
};
187 changes: 187 additions & 0 deletions scripts/upstream/check-upstream-drift.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
#!/usr/bin/env node

/**
* Upstream drift detector — Phase 1 (log-only).
*
* Reads `upstream/.upstream-sync.json`, asks the GitHub API how many
* commits the upstream repo is ahead of the recorded baseline, and logs
* the result. Does NOT create or update GitHub issues yet — that lands
* in Phase 2.
*
* Designed to run in `.github/workflows/upstream-drift.yml`:
* - Uses `gh api` via execFileSync (argv array — no shell parsing).
* - Exits 0 on success and on tolerated upstream errors (the repo
* itself was renamed/archived/deleted) so weekly cron does not
* produce red runs nobody triages.
* - Exits non-zero on programmer/config errors: a malformed state
* file, or a baseline SHA that doesn't exist in an otherwise
* reachable upstream repo (almost always a typo).
*/

const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

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

const REPO_ROOT = path.join(__dirname, '..', '..');
const STATE_PATH = path.join(REPO_ROOT, 'upstream', '.upstream-sync.json');

function log(message) {
console.log(`[upstream-drift] ${message}`);
}

function warn(message) {
console.error(`[upstream-drift] ${message}`);
}

function readState() {
let raw;
try {
raw = fs.readFileSync(STATE_PATH, 'utf8');
} catch (err) {
throw new Error(`Could not read state file at ${STATE_PATH}: ${err.message}`, { cause: err });
}
return lib.parseUpstreamState(raw);
}

/**
* Invoke `gh api` for the given endpoint and return the parsed JSON.
* Uses execFileSync with an argv array so the endpoint never reaches
* a shell — defense-in-depth against future inputs that might bypass
* the JSON-schema validation upstream.
*
* 404s are surfaced as a typed error (`err.is404 === true`) so callers
* can distinguish tolerable cases (upstream repo gone) from hard
* failures (invalid baseline SHA against a repo that does exist).
*
* @param {string} endpoint - path like "repos/owner/repo/compare/A...B"
* @returns {object}
*/
function ghApi(endpoint) {
let stdout;
try {
stdout = execFileSync('gh', ['api', endpoint], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
// The compare endpoint can return well over 1 MB of JSON when
// the delta includes hundreds of commits. Default maxBuffer is
// 1 MB and the process is killed with SIGTERM on overflow.
maxBuffer: 50 * 1024 * 1024,
});
} catch (err) {
const stderr = (err.stderr || '').toString();
const is404 = stderr.includes('HTTP 404') || stderr.includes('Not Found');
const apiError = new Error(`gh api failed for "${endpoint}": ${stderr || err.message}`, { cause: err });
apiError.stderr = stderr;
apiError.is404 = is404;
throw apiError;
}
try {
return JSON.parse(stdout);
} catch (err) {
throw new Error(`gh api returned non-JSON for "${endpoint}": ${err.message}`, { cause: err });
}
}

/**
* Pre-flight: check whether the upstream repo itself is reachable. A
* 404 here genuinely means "rename / archive / deletion" — a tolerable
* config drift the workflow should warn about, not fail on. A 404 on
* the *compare* endpoint after this check passes is a hard error
* (almost certainly a malformed `lastSyncedSha`).
*
* @param {string} repo - "owner/repo"
* @returns {boolean} true if reachable; false if the repo returns 404
*/
function isUpstreamReachable(repo) {
try {
ghApi(`repos/${repo}`);
return true;
} catch (err) {
if (err.is404) {
warn(`Upstream repo "${repo}" returned 404. Treating as tolerated upstream-unreachable (rename, archive, or deletion).`);
return false;
}
throw err;
}
}

function summarize(state, compare) {
const status = compare.status;
const ahead = compare.ahead_by;
const behind = compare.behind_by;
const headSha = compare.commits && compare.commits.length > 0
? compare.commits[compare.commits.length - 1].sha
: state.lastSyncedSha;

log(`Status: ${status} (ahead_by=${ahead}, behind_by=${behind})`);
log(`Baseline: ${lib.shortSha(state.lastSyncedSha)} (recorded ${state.lastSyncedAt})`);
log(`Upstream HEAD: ${lib.shortSha(headSha)}`);
log(`Compare: ${lib.compareUrl(state.upstream, state.lastSyncedSha, headSha)}`);

if (status === 'identical' || ahead === 0) {
log('No drift — baseline matches upstream HEAD.');
return;
}

if (status === 'ahead' || status === 'diverged') {
log(`Drift detected: upstream is ${ahead} commit(s) ahead.`);
return;
}

if (status === 'behind') {
log('Baseline is ahead of upstream HEAD — likely a manual cherry-pick or upstream reset.');
return;
}

log(`Unknown compare status "${status}" — surfacing as drift for review.`);
}

function main() {
let state;
try {
state = readState();
} catch (err) {
console.error(err.message);
process.exit(1);
}

log(`Upstream: ${state.upstream}`);

// Pre-flight: if the repo itself is 404, treat it as tolerable
// (rename/archive/deletion) and exit 0. Phase 2 will optionally post
// a comment on the existing tracking issue in this branch.
if (!isUpstreamReachable(state.upstream)) {
process.exit(0);
}

// Repo exists. A 404 on the compare endpoint now means the recorded
// baseline SHA is not reachable in the upstream repo — almost always
// a typo in upstream/.upstream-sync.json. Fail loudly so the
// operator notices.
const endpoint = `repos/${state.upstream}/compare/${state.lastSyncedSha}...HEAD`;
let compare;
try {
compare = ghApi(endpoint);
} catch (err) {
if (err.is404) {
console.error(
`Baseline SHA "${state.lastSyncedSha}" is not reachable in ${state.upstream}. ` +
`Check upstream/.upstream-sync.json — the SHA may be malformed or refer to a commit ` +
`that was force-removed from the upstream history.`,
);
process.exit(1);
}
console.error(err.message);
process.exit(1);
}

summarize(state, compare);
}

if (require.main === module) {
main();
}

module.exports = { readState, ghApi, isUpstreamReachable, summarize };
Loading
Loading