diff --git a/.github/labels.yml b/.github/labels.yml index 91e5f27..a384629 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -52,6 +52,14 @@ color: d93f0b description: Breaks command names, tool lists, or hook contracts +# --- Automation --------------------------------------------------------- +- name: 🔄 Upstream Sync + color: 0E8A16 + description: Tracking upstream ECC drift — maintained by .github/workflows/upstream-drift.yml +- name: ⚠ Upstream Sync Failure + color: B60205 + description: The upstream-drift workflow itself is failing — investigate the run logs + # --- Triage / flow ------------------------------------------------------ - name: 🔍 Triage color: ededed diff --git a/.github/workflows/reusable-validate.yml b/.github/workflows/reusable-validate.yml index 4f2a965..e255d1f 100644 --- a/.github/workflows/reusable-validate.yml +++ b/.github/workflows/reusable-validate.yml @@ -38,3 +38,6 @@ jobs: - name: Validate workflow security run: node scripts/ci/validate-workflow-security.js + + - name: Validate upstream sync baseline + run: node scripts/ci/validate-upstream-sync.js diff --git a/.github/workflows/upstream-drift.yml b/.github/workflows/upstream-drift.yml index 99fa4b1..3621fa0 100644 --- a/.github/workflows/upstream-drift.yml +++ b/.github/workflows/upstream-drift.yml @@ -1,10 +1,21 @@ name: Upstream Drift +# Detects whether the upstream ECC repo has new commits beyond the +# baseline recorded in upstream/.upstream-sync.json and maintains a +# single rolling tracking issue labelled "🔄 Upstream Sync" that +# reflects the current drift state. +# +# Schedule: weekly Sunday 21:00 UTC = Monday 06:00 KST (UTC+9). +# Also runs on manual dispatch from the Actions tab. + on: + schedule: + - cron: '0 21 * * 0' workflow_dispatch: permissions: contents: read + issues: write jobs: check: @@ -25,3 +36,32 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node scripts/upstream/check-upstream-drift.js + + # Recursive failure-tracking: if the step above (or any prior + # step) failed, ensure a single rolling "⚠ Upstream Sync Failure" + # issue points at the failing run. Same idea as the drift tracker + # — make the action's own health a number on an open issue. + - name: Report action failure + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + FAILURE_LABEL: "⚠ Upstream Sync Failure" + run: | + set -euo pipefail + # List every open failure issue, keep the first as the + # rolling tracker, and close any others as superseded — the + # intent is one and only one open issue at any time. + numbers=$(gh issue list --label "$FAILURE_LABEL" --state open --json number --jq '.[].number') + existing=$(printf '%s\n' "$numbers" | head -n 1) + title="Upstream-drift workflow failing" + body=$(printf '%s\n\n%s\n\n%s' \ + "The \`upstream-drift\` workflow most recently failed on run ${RUN_URL}." \ + "Investigate the run logs, fix the underlying cause, and close this issue once a subsequent run succeeds." \ + "_Maintained automatically by \`.github/workflows/upstream-drift.yml\`._") + if [ -z "$existing" ]; then + gh issue create --label "$FAILURE_LABEL" --title "$title" --body "$body" + else + gh issue edit "$existing" --title "$title" --body "$body" + printf '%s\n' "$numbers" | tail -n +2 | xargs -r -I{} gh issue close {} --comment "Superseded by #$existing" + fi diff --git a/scripts/ci/validate-upstream-sync.js b/scripts/ci/validate-upstream-sync.js new file mode 100644 index 0000000..fb2a5ac --- /dev/null +++ b/scripts/ci/validate-upstream-sync.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +/** + * Validate that the upstream baseline SHA referenced in + * `upstream/README.md` agrees with `upstream/.upstream-sync.json`. + * + * Exists because the two files are intentionally kept by hand at sync + * time — the prose form is human-readable, the JSON is machine-read + * by the upstream-drift Action. If they fall out of sync, the Action + * silently tracks a SHA that no longer matches the docs and reviewers + * looking at `upstream/README.md` see a different baseline than the + * tracker uses. + * + * Exits 0 when the SHAs match, 1 with a specific error when they do + * not. Wired into `.github/workflows/reusable-validate.yml`. + */ + +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); +// Paths are env-overridable so tests can point them at fixtures without +// touching the real upstream/ folder. +const JSON_PATH = process.env.EGC_UPSTREAM_JSON_PATH + || path.join(REPO_ROOT, 'upstream', '.upstream-sync.json'); +const README_PATH = process.env.EGC_UPSTREAM_README_PATH + || path.join(REPO_ROOT, 'upstream', 'README.md'); + +const SHA_RE = /\b[0-9a-f]{40}\b/g; +const LAST_SYNCED_PHRASE = 'Last-synced upstream commit'; + +function fail(message) { + console.error(`[validate-upstream-sync] ${message}`); + process.exit(1); +} + +/** + * Extract the SHA cited as the "Last-synced upstream commit" in + * upstream/README.md. We do not parse the whole document — we look + * for the labelled bullet near the top of the Upstream baseline + * section and pull the first 40-char hex token on that line and the + * lines that immediately follow (the SHA can appear inside a + * markdown link, so we tolerate it being on the same or next line). + * + * @param {string} readmeSource + * @returns {string|null} + */ +function extractReadmeSha(readmeSource) { + const lines = readmeSource.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + if (!lines[i].includes(LAST_SYNCED_PHRASE)) continue; + const window = lines.slice(i, i + 3).join(' '); + const match = window.match(SHA_RE); + if (match && match.length > 0) { + return match[0]; + } + return null; + } + return null; +} + +function main() { + let stateRaw; + try { + stateRaw = fs.readFileSync(JSON_PATH, 'utf8'); + } catch (err) { + fail(`Could not read ${JSON_PATH}: ${err.message}`); + } + + let state; + try { + state = JSON.parse(stateRaw); + } catch (err) { + fail(`${JSON_PATH} is not valid JSON: ${err.message}`); + } + + if (typeof state.lastSyncedSha !== 'string' || !/^[0-9a-f]{40}$/.test(state.lastSyncedSha)) { + fail(`${JSON_PATH}: lastSyncedSha is not a 40-char hex string (got: ${JSON.stringify(state.lastSyncedSha)})`); + } + + let readmeSource; + try { + readmeSource = fs.readFileSync(README_PATH, 'utf8'); + } catch (err) { + fail(`Could not read ${README_PATH}: ${err.message}`); + } + + const readmeSha = extractReadmeSha(readmeSource); + if (!readmeSha) { + fail(`${README_PATH}: could not find a SHA near "${LAST_SYNCED_PHRASE}". The README must cite the same commit as upstream/.upstream-sync.json.`); + } + + if (readmeSha !== state.lastSyncedSha) { + fail( + `Baseline SHA mismatch between README and JSON.\n` + + ` upstream/README.md → ${readmeSha}\n` + + ` upstream/.upstream-sync.json → ${state.lastSyncedSha}\n` + + `\nWhen recording a sync, update both files (see upstream/README.md "Recording a sync").`, + ); + } + + console.log(`[validate-upstream-sync] OK — both files reference ${state.lastSyncedSha.slice(0, 7)}`); +} + +if (require.main === module) { + main(); +} + +module.exports = { extractReadmeSha }; diff --git a/scripts/lib/upstream-drift.js b/scripts/lib/upstream-drift.js index b1ddd59..fede71a 100644 --- a/scripts/lib/upstream-drift.js +++ b/scripts/lib/upstream-drift.js @@ -168,6 +168,74 @@ function compareUrl(upstreamRepo, baseSha, headSha) { return `https://github.com/${upstreamRepo}/compare/${baseSha}...${headSha}`; } +/** + * Translate the compare-API `commits` array into the simplified shape + * `formatIssueBody` expects: `{ sha, author, message }`. + * + * Author is ONLY populated from `c.author.login` (a real GitHub + * handle). Raw `commit.author.name` is intentionally not used as a + * fallback — the issue body prefixes `@`, so a raw name like + * "Jane Doe" would render as a misleading pseudo-mention `(@Jane Doe)`. + * When there is no login, `author` stays empty and `formatIssueBody` + * skips the `(@...)` prefix entirely. + * + * @param {object} compareResponse - the parsed `gh api .../compare/...` body + * @returns {Array<{ sha: string, author: string, message: string }>} + */ +function extractCommitsForBody(compareResponse) { + if (!compareResponse || !Array.isArray(compareResponse.commits)) return []; + return compareResponse.commits.map((c) => ({ + sha: typeof c.sha === 'string' ? c.sha : '', + author: (c.author && typeof c.author.login === 'string') ? c.author.login : '', + message: (c.commit && typeof c.commit.message === 'string') ? c.commit.message : '', + })); +} + +/** + * Given the result of `gh issue list --label --state open + * --json number,title,createdAt`, pick the issue we should treat as the + * rolling tracker. + * + * - 0 open → null (caller will create) + * - 1 open → that one + * - 2+ open → defensive: pick the most recently created so we update a + * live discussion rather than an abandoned one. This branch should + * not occur in normal operation but the script handles it instead of + * crashing. + * + * @param {Array<{ number: number, title: string, createdAt: string }>} issues + * @returns {{ number: number, title: string, createdAt: string }|null} + */ +function pickActiveIssue(issues) { + if (!Array.isArray(issues) || issues.length === 0) return null; + if (issues.length === 1) return issues[0]; + return issues + .slice() + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; +} + +/** + * Decide what to do this run based on the current delta and whether a + * rolling tracking issue is already open. + * + * delta=0, no issue → 'noop' (already in sync, nothing to manage) + * delta=0, open issue → 'close' (sync happened, close the tracker) + * delta>0, no issue → 'create' (new drift, open the tracker) + * delta>0, open issue → 'update' (drift continues, update the tracker) + * + * @param {{ deltaCount: number, openIssue: object|null }} args + * @returns {'noop'|'close'|'create'|'update'} + */ +function decideAction({ deltaCount, openIssue }) { + if (typeof deltaCount !== 'number' || deltaCount < 0 || !Number.isInteger(deltaCount)) { + throw new Error(`decideAction: deltaCount must be a non-negative integer (got: ${deltaCount})`); + } + if (deltaCount === 0) { + return openIssue ? 'close' : 'noop'; + } + return openIssue ? 'update' : 'create'; +} + module.exports = { COMMIT_BODY_LIMIT, parseUpstreamState, @@ -175,4 +243,7 @@ module.exports = { formatIssueTitle, formatIssueBody, compareUrl, + extractCommitsForBody, + pickActiveIssue, + decideAction, }; diff --git a/scripts/upstream/check-upstream-drift.js b/scripts/upstream/check-upstream-drift.js index 7b708bb..6baabce 100644 --- a/scripts/upstream/check-upstream-drift.js +++ b/scripts/upstream/check-upstream-drift.js @@ -1,21 +1,32 @@ #!/usr/bin/env node /** - * Upstream drift detector — Phase 1 (log-only). + * Upstream drift detector — Phase 2 (rolling tracking issue). * * 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. + * commits the upstream repo is ahead of the recorded baseline, and + * maintains a single rolling tracking issue labelled `🔄 Upstream Sync` + * that reflects the current drift state. + * + * Action matrix: + * delta=0, no issue → noop + * delta=0, open issue → close (sync happened upstream-side) + * delta>0, no issue → create + * delta>0, open issue → update title/body to reflect current count * * Designed to run in `.github/workflows/upstream-drift.yml`: - * - Uses `gh api` via execFileSync (argv array — no shell parsing). + * - Uses `gh api` and `gh issue ...` 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). + * + * The workflow also runs an `if: failure()` step that maintains a + * separate `⚠ Upstream Sync Failure` issue so the action's own health + * is visible the same way drift is. */ const fs = require('fs'); @@ -26,6 +37,7 @@ const lib = require('../lib/upstream-drift'); const REPO_ROOT = path.join(__dirname, '..', '..'); const STATE_PATH = path.join(REPO_ROOT, 'upstream', '.upstream-sync.json'); +const UPSTREAM_SYNC_LABEL = '🔄 Upstream Sync'; function log(message) { console.log(`[upstream-drift] ${message}`); @@ -47,13 +59,7 @@ function readState() { /** * 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). + * 404s are surfaced as a typed error (`err.is404 === true`). * * @param {string} endpoint - path like "repos/owner/repo/compare/A...B" * @returns {object} @@ -64,9 +70,8 @@ function ghApi(endpoint) { 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. + // Compare responses can exceed Node's default 1 MB buffer when + // the delta is large; SIGTERMs the child on overflow. maxBuffer: 50 * 1024 * 1024, }); } catch (err) { @@ -84,16 +89,6 @@ function ghApi(endpoint) { } } -/** - * 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}`); @@ -107,35 +102,85 @@ function isUpstreamReachable(repo) { } } +/** + * Resolve the upstream HEAD SHA from a compare response. The compare + * API returns the *new* commits in `commits[]`; the last one is HEAD. + * If there is no delta, fall back to the recorded baseline. + * + * @param {object} compare + * @param {string} fallbackSha + * @returns {string} + */ +function getHeadSha(compare, fallbackSha) { + if (compare && Array.isArray(compare.commits) && compare.commits.length > 0) { + return compare.commits[compare.commits.length - 1].sha; + } + return fallbackSha; +} + 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; + const headSha = getHeadSha(compare, 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; - } +function listUpstreamSyncIssues() { + const stdout = execFileSync( + 'gh', + ['issue', 'list', '--label', UPSTREAM_SYNC_LABEL, '--state', 'open', '--json', 'number,title,createdAt'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + return JSON.parse(stdout); +} - if (status === 'ahead' || status === 'diverged') { - log(`Drift detected: upstream is ${ahead} commit(s) ahead.`); - return; - } +function createTrackingIssue(title, body) { + log(`Creating tracking issue: "${title}"`); + execFileSync( + 'gh', + ['issue', 'create', '--label', UPSTREAM_SYNC_LABEL, '--title', title, '--body', body], + { stdio: 'inherit' }, + ); +} - if (status === 'behind') { - log('Baseline is ahead of upstream HEAD — likely a manual cherry-pick or upstream reset.'); - return; - } +function updateTrackingIssue(number, title, body) { + log(`Updating tracking issue #${number}: "${title}"`); + execFileSync( + 'gh', + ['issue', 'edit', String(number), '--title', title, '--body', body], + { stdio: 'inherit' }, + ); +} + +function closeTrackingIssue(number, upstreamRepo) { + const comment = `Closed automatically: \`upstream/.upstream-sync.json\` is now in sync with \`${upstreamRepo}\` HEAD.`; + log(`Closing tracking issue #${number}`); + execFileSync( + 'gh', + ['issue', 'close', String(number), '--comment', comment], + { stdio: 'inherit' }, + ); +} - log(`Unknown compare status "${status}" — surfacing as drift for review.`); +function buildIssueContent(state, compare) { + const headSha = getHeadSha(compare, state.lastSyncedSha); + const commits = lib.extractCommitsForBody(compare); + const title = lib.formatIssueTitle({ + count: compare.ahead_by, + lastSyncedShaShort: lib.shortSha(state.lastSyncedSha), + }); + const body = lib.formatIssueBody({ + commits, + lastSyncedSha: state.lastSyncedSha, + upstreamHeadSha: headSha, + upstreamRepo: state.upstream, + }); + return { title, body }; } function main() { @@ -149,17 +194,10 @@ function main() { 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 { @@ -178,10 +216,51 @@ function main() { } summarize(state, compare); + + const openIssues = listUpstreamSyncIssues(); + const activeIssue = lib.pickActiveIssue(openIssues); + if (openIssues.length > 1) { + warn(`Found ${openIssues.length} open "${UPSTREAM_SYNC_LABEL}" issues — operating on #${activeIssue.number} (most recent).`); + } + + const action = lib.decideAction({ deltaCount: compare.ahead_by, openIssue: activeIssue }); + log(`Action: ${action}`); + + switch (action) { + case 'noop': + log('In sync, no tracking issue to manage.'); + break; + case 'close': + closeTrackingIssue(activeIssue.number, state.upstream); + break; + case 'create': { + const { title, body } = buildIssueContent(state, compare); + createTrackingIssue(title, body); + break; + } + case 'update': { + const { title, body } = buildIssueContent(state, compare); + updateTrackingIssue(activeIssue.number, title, body); + break; + } + default: + throw new Error(`Unknown action: ${action}`); + } } if (require.main === module) { main(); } -module.exports = { readState, ghApi, isUpstreamReachable, summarize }; +module.exports = { + UPSTREAM_SYNC_LABEL, + readState, + ghApi, + isUpstreamReachable, + summarize, + buildIssueContent, + listUpstreamSyncIssues, + createTrackingIssue, + updateTrackingIssue, + closeTrackingIssue, +}; diff --git a/tests/ci/validate-upstream-sync.test.js b/tests/ci/validate-upstream-sync.test.js new file mode 100644 index 0000000..b74f6f3 --- /dev/null +++ b/tests/ci/validate-upstream-sync.test.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * Tests for scripts/ci/validate-upstream-sync.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'ci', 'validate-upstream-sync.js'); +const { extractReadmeSha } = require('../../scripts/ci/validate-upstream-sync'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed += 1; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + failed += 1; + } +} + +const VALID_SHA = '9db98673d054f5ed0991ba9d67ff4c883c81a42f'; +const OTHER_SHA = 'a8836d7bbd8b4b4745bb00608203887a8267d630'; + +const README_TEMPLATE = (sha) => `# Upstream Sync Policy + +Some intro prose here. + +## Upstream baseline + +- **Upstream repository**: [\`affaan-m/everything-claude-code\`](https://github.com/affaan-m/everything-claude-code) +- **Last-synced upstream commit**: [\`${sha}\`](https://github.com/affaan-m/everything-claude-code/commit/${sha}) +- **Last-synced date**: 2026-02-09 + +Trailing prose. +`; + +const JSON_TEMPLATE = (sha) => JSON.stringify({ + upstream: 'affaan-m/everything-claude-code', + lastSyncedSha: sha, + lastSyncedAt: '2026-02-09', +}, null, 2); + +function runValidator({ json, readme }) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'egc-validate-upstream-sync-')); + const jsonPath = path.join(tempDir, '.upstream-sync.json'); + const readmePath = path.join(tempDir, 'README.md'); + fs.writeFileSync(jsonPath, json); + fs.writeFileSync(readmePath, readme); + + try { + return spawnSync('node', [SCRIPT_PATH], { + encoding: 'utf8', + env: { + ...process.env, + EGC_UPSTREAM_JSON_PATH: jsonPath, + EGC_UPSTREAM_README_PATH: readmePath, + }, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +console.log('\n=== Testing validate-upstream-sync ===\n'); + +// extractReadmeSha (pure) + +test('extractReadmeSha finds the SHA on the Last-synced line', () => { + const sha = extractReadmeSha(README_TEMPLATE(VALID_SHA)); + assert.strictEqual(sha, VALID_SHA); +}); + +test('extractReadmeSha tolerates link wrapper', () => { + const src = `## Upstream baseline\n- **Last-synced upstream commit**:\n [\`${VALID_SHA}\`](https://x/y/commit/${VALID_SHA})\n`; + assert.strictEqual(extractReadmeSha(src), VALID_SHA); +}); + +test('extractReadmeSha returns null when the phrase is missing', () => { + assert.strictEqual(extractReadmeSha('# README\nno baseline here'), null); +}); + +test('extractReadmeSha returns null when phrase is present but no SHA nearby', () => { + const src = 'Last-synced upstream commit\n\n(no sha for a few lines)\n\n\n\nthen-a-bare-sha 9db98673d054f5ed0991ba9d67ff4c883c81a42f'; + assert.strictEqual(extractReadmeSha(src), null); +}); + +// Integration: spawn the script with env-overridden paths + +test('exits 0 when SHAs match', () => { + const result = runValidator({ + json: JSON_TEMPLATE(VALID_SHA), + readme: README_TEMPLATE(VALID_SHA), + }); + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /OK/); +}); + +test('exits 1 when SHAs disagree', () => { + const result = runValidator({ + json: JSON_TEMPLATE(VALID_SHA), + readme: README_TEMPLATE(OTHER_SHA), + }); + assert.strictEqual(result.status, 1); + assert.match(result.stderr, /mismatch/i); + assert.match(result.stderr, new RegExp(VALID_SHA)); + assert.match(result.stderr, new RegExp(OTHER_SHA)); +}); + +test('exits 1 when README has no baseline line', () => { + const result = runValidator({ + json: JSON_TEMPLATE(VALID_SHA), + readme: '# No baseline here\n', + }); + assert.strictEqual(result.status, 1); + assert.match(result.stderr, /could not find/i); +}); + +test('exits 1 when JSON is malformed', () => { + const result = runValidator({ + json: '{not json', + readme: README_TEMPLATE(VALID_SHA), + }); + assert.strictEqual(result.status, 1); + assert.match(result.stderr, /not valid JSON/); +}); + +test('exits 1 when JSON lastSyncedSha is malformed', () => { + const result = runValidator({ + json: JSON.stringify({ + upstream: 'a/b', + lastSyncedSha: 'too-short', + lastSyncedAt: '2026-01-01', + }), + readme: README_TEMPLATE(VALID_SHA), + }); + assert.strictEqual(result.status, 1); + assert.match(result.stderr, /40-char hex/); +}); + +console.log('\n=== Test Results ==='); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}\n`); + +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/lib/upstream-drift.test.js b/tests/lib/upstream-drift.test.js index f962bc1..bd0ba5d 100644 --- a/tests/lib/upstream-drift.test.js +++ b/tests/lib/upstream-drift.test.js @@ -204,6 +204,89 @@ test('formatIssueBody rejects malformed upstreamRepo', () => { ); }); +// extractCommitsForBody + +test('extractCommitsForBody flattens the compare API shape', () => { + const compare = { + commits: [ + { + sha: 'aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111', + author: { login: 'octocat' }, + commit: { author: { name: 'Real Name' }, message: 'fix: thing\n\nbody' }, + }, + { + sha: 'bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222', + author: null, + commit: { author: { name: 'Raw Name' }, message: 'feat: thing 2' }, + }, + { + sha: 'cccc3333cccc3333cccc3333cccc3333cccc3333', + author: null, + commit: { message: 'orphaned author' }, + }, + ], + }; + const out = lib.extractCommitsForBody(compare); + assert.strictEqual(out.length, 3); + assert.strictEqual(out[0].author, 'octocat'); + // Commits without a GitHub login (only a raw git author name) must + // leave author empty — otherwise the body would render misleading + // pseudo-mentions like "(@Jane Doe)". + assert.strictEqual(out[1].author, ''); + assert.strictEqual(out[2].author, ''); + assert.strictEqual(out[0].message, 'fix: thing\n\nbody'); +}); + +test('extractCommitsForBody returns [] for missing or malformed input', () => { + assert.deepStrictEqual(lib.extractCommitsForBody(null), []); + assert.deepStrictEqual(lib.extractCommitsForBody({}), []); + assert.deepStrictEqual(lib.extractCommitsForBody({ commits: 'not array' }), []); +}); + +// pickActiveIssue + +test('pickActiveIssue returns null when no issues are open', () => { + assert.strictEqual(lib.pickActiveIssue([]), null); + assert.strictEqual(lib.pickActiveIssue(null), null); + assert.strictEqual(lib.pickActiveIssue(undefined), null); +}); + +test('pickActiveIssue returns the single open issue', () => { + const only = { number: 42, title: 't', createdAt: '2026-05-01T00:00:00Z' }; + assert.strictEqual(lib.pickActiveIssue([only]), only); +}); + +test('pickActiveIssue picks the most recently created when multiple are open', () => { + const older = { number: 1, title: 'older', createdAt: '2026-04-01T00:00:00Z' }; + const newer = { number: 2, title: 'newer', createdAt: '2026-05-01T00:00:00Z' }; + assert.strictEqual(lib.pickActiveIssue([older, newer]).number, 2); + assert.strictEqual(lib.pickActiveIssue([newer, older]).number, 2); +}); + +// decideAction + +test('decideAction: delta=0 + no issue → noop', () => { + assert.strictEqual(lib.decideAction({ deltaCount: 0, openIssue: null }), 'noop'); +}); + +test('decideAction: delta=0 + open issue → close', () => { + assert.strictEqual(lib.decideAction({ deltaCount: 0, openIssue: { number: 1 } }), 'close'); +}); + +test('decideAction: delta>0 + no issue → create', () => { + assert.strictEqual(lib.decideAction({ deltaCount: 3, openIssue: null }), 'create'); +}); + +test('decideAction: delta>0 + open issue → update', () => { + assert.strictEqual(lib.decideAction({ deltaCount: 3, openIssue: { number: 1 } }), 'update'); +}); + +test('decideAction rejects non-integer or negative deltaCount', () => { + assert.throws(() => lib.decideAction({ deltaCount: -1, openIssue: null }), /non-negative integer/); + assert.throws(() => lib.decideAction({ deltaCount: 1.5, openIssue: null }), /non-negative integer/); + assert.throws(() => lib.decideAction({ deltaCount: '3', openIssue: null }), /non-negative integer/); +}); + // compareUrl test('compareUrl builds the expected GitHub URL', () => { diff --git a/tests/run-all.js b/tests/run-all.js index f7e71c1..c904229 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -22,6 +22,7 @@ const testFiles = [ 'hooks/hooks.test.js', 'hooks/block-no-verify.test.js', 'ci/validate-workflow-security.test.js', + 'ci/validate-upstream-sync.test.js', 'lint/validators.test.js' ]; diff --git a/upstream/README.md b/upstream/README.md index 5cfd874..eecde47 100644 --- a/upstream/README.md +++ b/upstream/README.md @@ -19,7 +19,7 @@ This document records how EGC tracks the upstream ECC repository and what was ch - **Last-synced date**: 2026-02-09 - **Initial EGC commit**: [`ff331996a061c2bbd17ffaa23d4eed2dcdd6ad35`](https://github.com/Jamkris/everything-gemini-code/commit/ff331996a061c2bbd17ffaa23d4eed2dcdd6ad35) (2026-02-09) -A machine-readable copy of this state lives at [`.upstream-sync.json`](./.upstream-sync.json). A follow-up CI validator will assert the two files agree on the SHA; until then the maintainer keeps them in sync by hand at sync time. +A machine-readable copy of this state lives at [`.upstream-sync.json`](./.upstream-sync.json). A CI validator (`scripts/ci/validate-upstream-sync.js`) asserts that the two files agree on the SHA, so a mismatch blocks the PR automatically. --- @@ -30,8 +30,8 @@ EGC syncs with ECC on a **best-effort basis**. There is no committed sync cadenc Instead, drift is made **mechanically visible**: - The last-synced commit SHA is recorded above and in `.upstream-sync.json`. -- A scheduled GitHub Action (planned) compares the recorded SHA against `affaan-m/everything-claude-code` HEAD on a weekly cadence and maintains a single rolling tracking issue labelled `upstream-sync` when there is drift. -- When a sync round happens, the maintainer advances the SHA, the tracker closes the issue, and the cycle repeats. +- A scheduled GitHub Action (`.github/workflows/upstream-drift.yml`) compares the recorded SHA against `affaan-m/everything-claude-code` HEAD weekly (Monday 06:00 KST) and maintains a single rolling tracking issue labelled `🔄 Upstream Sync` when there is drift. +- When a sync round happens, the maintainer advances the SHA, the tracker closes the issue automatically, and the cycle repeats. This way the *commitment* is to surface drift, not to fix it on a schedule. @@ -130,6 +130,4 @@ When upstream is reviewed and the baseline advances: 4. Run `npm run lint && npm test`. 5. Commit with a `docs: sync upstream baseline to ` message. -**This procedure is open to community contributors** — anyone can propose a sync update via a PR, not just the maintainer. The validator (once landed) plus normal code review will check that the SHAs and the recorded date are consistent. - -A follow-up PR will add a CI validator that asserts the SHAs in this document and in `.upstream-sync.json` agree; once that lands, a mismatch will block the PR automatically. +**This procedure is open to community contributors** — anyone can propose a sync update via a PR, not just the maintainer. The CI validator (`scripts/ci/validate-upstream-sync.js`) plus normal code review will check that the SHAs and the recorded date are consistent; a mismatch between this document and `.upstream-sync.json` blocks the PR automatically. diff --git a/upstream/ko-KR/README.md b/upstream/ko-KR/README.md index 4f8db72..9541af9 100644 --- a/upstream/ko-KR/README.md +++ b/upstream/ko-KR/README.md @@ -19,7 +19,7 @@ EGC (Everything Gemini Code) 는 [@affaan-m](https://github.com/affaan-m) 의 [E - **마지막 동기화 일자**: 2026-02-09 - **EGC 최초 커밋**: [`ff331996a061c2bbd17ffaa23d4eed2dcdd6ad35`](https://github.com/Jamkris/everything-gemini-code/commit/ff331996a061c2bbd17ffaa23d4eed2dcdd6ad35) (2026-02-09) -이 상태의 기계 판독용 사본은 [`.upstream-sync.json`](../.upstream-sync.json) 에 있습니다. 후속 PR에서 두 파일의 SHA 일치를 검사하는 CI validator가 추가될 예정이며, 그 전까지는 메인테이너가 동기화 시점에 직접 두 파일을 맞춥니다. +이 상태의 기계 판독용 사본은 [`.upstream-sync.json`](../.upstream-sync.json) 에 있습니다. CI validator (`scripts/ci/validate-upstream-sync.js`) 가 두 파일의 SHA 일치를 강제하며, 불일치가 발생한 PR은 자동으로 차단됩니다. --- @@ -30,7 +30,7 @@ EGC는 ECC를 **best-effort 방식**으로 동기화합니다. 정해진 주기 대신 **drift를 기계적으로 가시화**합니다: - 마지막 동기화 SHA는 위 섹션과 `.upstream-sync.json` 두 곳에 기록됩니다. -- 예정된 GitHub Action이 매주 한 번 `affaan-m/everything-claude-code` HEAD를 기록된 SHA와 비교하며, drift가 있을 때 `upstream-sync` 라벨이 붙은 단일 추적 이슈를 갱신합니다. +- GitHub Action (`.github/workflows/upstream-drift.yml`) 이 매주 (월요일 06:00 KST) `affaan-m/everything-claude-code` HEAD를 기록된 SHA와 비교하며, drift가 있을 때 `🔄 Upstream Sync` 라벨이 붙은 단일 추적 이슈를 갱신합니다. - 동기화 라운드가 진행되면 메인테이너가 SHA를 갱신하고, 추적기는 이슈를 자동으로 닫으며, 사이클이 반복됩니다. 따라서 *약속*은 drift를 일정대로 고치는 것이 아니라, **drift를 노출하는 것**입니다. @@ -130,6 +130,4 @@ EGC 메인테이너는 ECC 메인테이너와 무관합니다 — 백포트는 4. `npm run lint && npm test` 를 실행합니다. 5. `docs: sync upstream baseline to ` 메시지로 커밋합니다. -**이 절차는 커뮤니티 기여자에게 열려 있습니다** — 메인테이너만이 아니라 누구든 PR로 동기화 업데이트를 제안할 수 있습니다. validator(추후 추가 예정)와 일반 코드 리뷰가 SHA와 기록된 날짜의 일관성을 확인합니다. - -후속 PR에서 이 문서와 `.upstream-sync.json` 의 SHA 일치를 강제하는 CI validator가 추가될 예정이며, 그 시점부터는 불일치 PR은 자동으로 차단됩니다. +**이 절차는 커뮤니티 기여자에게 열려 있습니다** — 메인테이너만이 아니라 누구든 PR로 동기화 업데이트를 제안할 수 있습니다. CI validator (`scripts/ci/validate-upstream-sync.js`) 와 일반 코드 리뷰가 SHA와 기록된 날짜의 일관성을 확인하며, 이 문서와 `.upstream-sync.json` 의 SHA가 어긋난 PR은 자동으로 차단됩니다. diff --git a/upstream/zh-CN/README.md b/upstream/zh-CN/README.md index 5632eab..19fe3d8 100644 --- a/upstream/zh-CN/README.md +++ b/upstream/zh-CN/README.md @@ -19,7 +19,7 @@ EGC (Everything Gemini Code) 是 [@affaan-m](https://github.com/affaan-m) 的 [E - **最近一次同步的日期**: 2026-02-09 - **EGC 的首次提交**: [`ff331996a061c2bbd17ffaa23d4eed2dcdd6ad35`](https://github.com/Jamkris/everything-gemini-code/commit/ff331996a061c2bbd17ffaa23d4eed2dcdd6ad35) (2026-02-09) -该状态的机器可读副本位于 [`.upstream-sync.json`](../.upstream-sync.json)。后续 PR 将引入一个 CI 校验器,断言两个文件中的 SHA 一致;在那之前,由维护者在同步时手工保持两者同步。 +该状态的机器可读副本位于 [`.upstream-sync.json`](../.upstream-sync.json)。CI 校验器 (`scripts/ci/validate-upstream-sync.js`) 会断言两个文件中的 SHA 一致;不一致的 PR 将被自动阻拦。 --- @@ -30,7 +30,7 @@ EGC 与 ECC 的同步采用 **best-effort 方式**。我们不承诺固定的同 取而代之,drift 被**机械地暴露出来**: - 最近一次同步的 SHA 同时记录在上面的章节与 `.upstream-sync.json` 中。 -- 计划中的一个 GitHub Action 会按周比较 `affaan-m/everything-claude-code` 的 HEAD 与记录的 SHA,并在存在 drift 时维护一个带有 `upstream-sync` 标签的单一滚动追踪 issue。 +- GitHub Action (`.github/workflows/upstream-drift.yml`) 每周(韩国时间星期一 06:00)比较 `affaan-m/everything-claude-code` 的 HEAD 与记录的 SHA,并在存在 drift 时维护一个带有 `🔄 Upstream Sync` 标签的单一滚动追踪 issue。 - 当一次同步动作完成时,维护者推进 SHA,追踪器自动关闭该 issue,循环继续。 也就是说,*承诺* 不在于按计划修复 drift,而在于 **将 drift 暴露出来**。 @@ -130,6 +130,4 @@ EGC 的维护者与 ECC 的维护者并无隶属关系;回移植是由贡献 4. 运行 `npm run lint && npm test`。 5. 用 `docs: sync upstream baseline to ` 这样的提交信息提交。 -**这一流程对社区贡献者开放** —— 任何人都可以通过 PR 提出一次同步更新,不限于维护者。validator(落地后)加上常规代码评审会负责检查 SHA 与记录日期的一致性。 - -后续 PR 将引入一个 CI validator,断言本文件与 `.upstream-sync.json` 中的 SHA 一致;该 validator 落地后,不一致的 PR 将被自动阻拦。 +**这一流程对社区贡献者开放** —— 任何人都可以通过 PR 提出一次同步更新,不限于维护者。CI validator (`scripts/ci/validate-upstream-sync.js`) 加上常规代码评审会负责检查 SHA 与记录日期的一致性;本文件与 `.upstream-sync.json` 之间 SHA 不一致的 PR 将被自动阻拦。