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
8 changes: 8 additions & 0 deletions .github/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/reusable-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 40 additions & 0 deletions .github/workflows/upstream-drift.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
109 changes: 109 additions & 0 deletions scripts/ci/validate-upstream-sync.js
Original file line number Diff line number Diff line change
@@ -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 };
71 changes: 71 additions & 0 deletions scripts/lib/upstream-drift.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,82 @@ 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 <upstream-sync> --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,
shortSha,
formatIssueTitle,
formatIssueBody,
compareUrl,
extractCommitsForBody,
pickActiveIssue,
decideAction,
};
Loading
Loading