From 581dafe66c008d4eed7d407c38681d178aa12cc7 Mon Sep 17 00:00:00 2001 From: Jamkris Date: Tue, 12 May 2026 10:47:07 +0900 Subject: [PATCH 1/2] feat: upstream drift tracker Phases 2 + 3 (issue mgmt, cron, SHA validator) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled Phase 2 (rolling issue + weekly cron + failure tracking) and Phase 3 (SHA consistency validator) in one PR so the version bump covers a feature-complete drift tracker. Phase 2 — issue management - scripts/lib/upstream-drift.js: pure helpers pickActiveIssue (defensive 0/1/N handling) decideAction ({deltaCount, openIssue} → noop|close|create|update) extractCommitsForBody (flattens compare API shape) - scripts/upstream/check-upstream-drift.js: rewritten main flow list open 🔄 Upstream Sync issues decide action execute via gh issue create|edit|close (execFileSync, no shell) - .github/workflows/upstream-drift.yml schedule: cron '0 21 * * 0' (Sun 21:00 UTC = Mon 06:00 KST) permissions: contents: read + issues: write if: failure() step that maintains a separate "⚠ Upstream Sync Failure" rolling issue so the action's own health is visible the same way drift is - .github/labels.yml 🔄 Upstream Sync (green) ⚠ Upstream Sync Failure (red) Sync to GitHub via scripts/setup-labels.sh. - tests/lib/upstream-drift.test.js: +11 unit tests for the new pure functions (30 total in this file). Phase 3 — SHA consistency validator - scripts/ci/validate-upstream-sync.js Parses the "Last-synced upstream commit" SHA out of upstream/README.md, compares with upstream/.upstream-sync.json, exits 1 with a specific diff message on mismatch. Paths are env-overridable (EGC_UPSTREAM_JSON_PATH / EGC_UPSTREAM_README_PATH) so tests can point at fixtures. - .github/workflows/reusable-validate.yml: wires the validator into the existing 5-step validation chain (now 6). - tests/ci/validate-upstream-sync.test.js: 9 tests extractReadmeSha (happy, link wrapper, missing phrase, far-away SHA), end-to-end via subprocess (match, mismatch, missing line, malformed JSON, malformed SHA). - tests/run-all.js: registers the new test file. Docs - upstream/{README,ko-KR/README,zh-CN/README}.md: updated to present-tense for both the Action and the validator (was "will add" / "planned" — now both have landed). Local verification - npm run lint clean - npm test 261/261 pass (+19 since Phase 1) - node scripts/ci/validate-upstream-sync.js → "OK — both files reference 9db9867" Out of scope (separate concern, larger context) - Bumping actions/checkout@v4 + actions/setup-node@v4 to a Node 24 variant. The deprecation cutoff is 2026-09-16. Better as a repo-wide refactor than a per-workflow change. --- .github/labels.yml | 8 + .github/workflows/reusable-validate.yml | 3 + .github/workflows/upstream-drift.yml | 35 +++++ scripts/ci/validate-upstream-sync.js | 109 ++++++++++++++ scripts/lib/upstream-drift.js | 69 +++++++++ scripts/upstream/check-upstream-drift.js | 177 ++++++++++++++++------- tests/ci/validate-upstream-sync.test.js | 154 ++++++++++++++++++++ tests/lib/upstream-drift.test.js | 80 ++++++++++ tests/run-all.js | 1 + upstream/README.md | 10 +- upstream/ko-KR/README.md | 8 +- upstream/zh-CN/README.md | 8 +- 12 files changed, 597 insertions(+), 65 deletions(-) create mode 100644 scripts/ci/validate-upstream-sync.js create mode 100644 tests/ci/validate-upstream-sync.test.js 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..0e9d2f7 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,27 @@ 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 + existing=$(gh issue list --label "$FAILURE_LABEL" --state open --json number --jq '.[0].number // empty') + 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" + 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..1b05a22 100644 --- a/scripts/lib/upstream-drift.js +++ b/scripts/lib/upstream-drift.js @@ -168,6 +168,72 @@ 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 preference: `author.login` (GitHub username) → `commit.author.name` + * (raw git author name) → empty string. + * + * @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) + || (c.commit && c.commit.author && typeof c.commit.author.name === 'string' && c.commit.author.name) + || '', + 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 +241,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..71bcd71 100644 --- a/tests/lib/upstream-drift.test.js +++ b/tests/lib/upstream-drift.test.js @@ -204,6 +204,86 @@ 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'); + assert.strictEqual(out[1].author, 'Raw Name'); + 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 将被自动阻拦。 From 2f451e046a336d44f78784a2ffe0e9600166a9e7 Mon Sep 17 00:00:00 2001 From: Jamkris Date: Tue, 12 May 2026 10:56:47 +0900 Subject: [PATCH 2/2] fix: address cubic review on PR #59 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 — extractCommitsForBody: drop raw-name fallback (cubic, line 186) When a commit's author has no GitHub login, the previous code fell back to `commit.author.name` (raw git author name). The issue body prefixes `@`, so that produced misleading pseudo-mentions like `(@Jane Doe)`. Author is now populated ONLY from `c.author.login`; without it, the body skips the `(@...)` segment entirely. P2 — failure tracker: clean up extras (cubic, line 52) The `if: failure()` step looked at only the first open `⚠ Upstream Sync Failure` issue. If two ever ended up open (race, manual creation, etc.), the second would linger forever. The step now lists ALL open ones, keeps the first as the rolling tracker, edits it, and closes the rest with a "Superseded by #N" comment. Matches cubic's suggested diff. Tests: updated `extractCommitsForBody flattens the compare API shape` to assert the new behavior (commits with only a raw author name now yield `author === ''`). Lint clean. 261/261. --- .github/workflows/upstream-drift.yml | 7 ++++++- scripts/lib/upstream-drift.js | 12 +++++++----- tests/lib/upstream-drift.test.js | 5 ++++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/upstream-drift.yml b/.github/workflows/upstream-drift.yml index 0e9d2f7..3621fa0 100644 --- a/.github/workflows/upstream-drift.yml +++ b/.github/workflows/upstream-drift.yml @@ -49,7 +49,11 @@ jobs: FAILURE_LABEL: "⚠ Upstream Sync Failure" run: | set -euo pipefail - existing=$(gh issue list --label "$FAILURE_LABEL" --state open --json number --jq '.[0].number // empty') + # 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}." \ @@ -59,4 +63,5 @@ jobs: 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/lib/upstream-drift.js b/scripts/lib/upstream-drift.js index 1b05a22..fede71a 100644 --- a/scripts/lib/upstream-drift.js +++ b/scripts/lib/upstream-drift.js @@ -172,8 +172,12 @@ function compareUrl(upstreamRepo, baseSha, headSha) { * Translate the compare-API `commits` array into the simplified shape * `formatIssueBody` expects: `{ sha, author, message }`. * - * Author preference: `author.login` (GitHub username) → `commit.author.name` - * (raw git author name) → empty string. + * 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 }>} @@ -182,9 +186,7 @@ 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) - || (c.commit && c.commit.author && typeof c.commit.author.name === 'string' && c.commit.author.name) - || '', + author: (c.author && typeof c.author.login === 'string') ? c.author.login : '', message: (c.commit && typeof c.commit.message === 'string') ? c.commit.message : '', })); } diff --git a/tests/lib/upstream-drift.test.js b/tests/lib/upstream-drift.test.js index 71bcd71..bd0ba5d 100644 --- a/tests/lib/upstream-drift.test.js +++ b/tests/lib/upstream-drift.test.js @@ -229,7 +229,10 @@ test('extractCommitsForBody flattens the compare API shape', () => { const out = lib.extractCommitsForBody(compare); assert.strictEqual(out.length, 3); assert.strictEqual(out[0].author, 'octocat'); - assert.strictEqual(out[1].author, 'Raw Name'); + // 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'); });