|
| 1 | +/** |
| 2 | + * Reports and bumps the commit each manifest pack is pinned at. |
| 3 | + * |
| 4 | + * The suite installs custom-node packs at fixed commits so no git surface |
| 5 | + * moves underneath a PR. That is the right call - unpinned, every pack author |
| 6 | + * becomes a committer to this repo's CI, and a bug pushed to any of them reds |
| 7 | + * the next unrelated PR. The cost is the mirror image: pack breakage is |
| 8 | + * invisible until someone bumps, and nothing was telling anyone when to. |
| 9 | + * |
| 10 | + * pnpm custom-node-pins report age and whether upstream moved |
| 11 | + * pnpm custom-node-pins:update rewrite the pins to upstream HEAD |
| 12 | + * |
| 13 | + * A bump is expected to red the suite. `expectedNodeCount` and |
| 14 | + * `expectedExtensions` are calibrated against the pinned source, and the |
| 15 | + * manifest is explicit that any delta fails until it is deliberately |
| 16 | + * recalibrated. That failure is the suite telling you what changed in the |
| 17 | + * ecosystem, which is the whole reason to bump on purpose rather than drift. |
| 18 | + */ |
| 19 | +import { execFile } from 'node:child_process' |
| 20 | +import { readFileSync, writeFileSync } from 'node:fs' |
| 21 | +import { fileURLToPath } from 'node:url' |
| 22 | +import { promisify } from 'node:util' |
| 23 | + |
| 24 | +const run = promisify(execFile) |
| 25 | + |
| 26 | +// Lazy: import.meta.url is not a file: URL under vitest, so resolving this at |
| 27 | +// module scope makes the file unimportable by its own test. |
| 28 | +function manifestPath(): string { |
| 29 | + return fileURLToPath( |
| 30 | + new URL( |
| 31 | + '../browser_tests/fixtures/data/customNodeManifest.core.json', |
| 32 | + import.meta.url |
| 33 | + ) |
| 34 | + ) |
| 35 | +} |
| 36 | + |
| 37 | +export const MAX_AGE_DAYS = 30 |
| 38 | +const UPDATE_WORKFLOW = |
| 39 | + 'https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/update-custom-node-pins.yaml' |
| 40 | + |
| 41 | +interface PinnedPack { |
| 42 | + pack: string |
| 43 | + repo: string |
| 44 | + pin: string |
| 45 | + pinnedAt?: string |
| 46 | +} |
| 47 | + |
| 48 | +/** Whole days elapsed, or null when the pin carries no date yet. */ |
| 49 | +export function ageInDays( |
| 50 | + pinnedAt: string | undefined, |
| 51 | + today: Date |
| 52 | +): number | null { |
| 53 | + if (!pinnedAt) return null |
| 54 | + const then = Date.parse(`${pinnedAt}T00:00:00Z`) |
| 55 | + if (Number.isNaN(then)) return null |
| 56 | + return Math.floor((today.getTime() - then) / 86_400_000) |
| 57 | +} |
| 58 | + |
| 59 | +/** The oldest pin is what the suite's freshness is actually worth. */ |
| 60 | +export function stalest(packs: PinnedPack[], today: Date): number | null { |
| 61 | + const ages = packs.map((p) => ageInDays(p.pinnedAt, today)) |
| 62 | + if (ages.some((a) => a === null)) return null |
| 63 | + return Math.max(...(ages as number[])) |
| 64 | +} |
| 65 | + |
| 66 | +async function headSha(repo: string): Promise<string> { |
| 67 | + try { |
| 68 | + const { stdout } = await run('git', ['ls-remote', repo, 'HEAD'], { |
| 69 | + timeout: 60_000 |
| 70 | + }) |
| 71 | + return stdout.split(/\s/)[0] ?? '' |
| 72 | + } catch { |
| 73 | + return '' |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +function load(): PinnedPack[] { |
| 78 | + return JSON.parse(readFileSync(manifestPath(), 'utf8')) as PinnedPack[] |
| 79 | +} |
| 80 | + |
| 81 | +function today(): string { |
| 82 | + return new Date().toISOString().slice(0, 10) |
| 83 | +} |
| 84 | + |
| 85 | +function say(line: string): void { |
| 86 | + process.stdout.write(`${line}\n`) |
| 87 | +} |
| 88 | + |
| 89 | +async function report(): Promise<number> { |
| 90 | + const packs = load() |
| 91 | + const now = new Date() |
| 92 | + const heads = await Promise.all(packs.map((p) => headSha(p.repo))) |
| 93 | + |
| 94 | + say('='.repeat(72)) |
| 95 | + const age = stalest(packs, now) |
| 96 | + say( |
| 97 | + age === null |
| 98 | + ? 'CUSTOM-NODE PINS: no pin dates recorded - freshness unknown' |
| 99 | + : `custom-node pins: oldest is ${age} day(s) old (limit ${MAX_AGE_DAYS})` |
| 100 | + ) |
| 101 | + say(` update via ${UPDATE_WORKFLOW}`) |
| 102 | + say(' or locally pnpm custom-node-pins:update') |
| 103 | + say('='.repeat(72)) |
| 104 | + |
| 105 | + for (const [i, p] of packs.entries()) { |
| 106 | + const days = ageInDays(p.pinnedAt, now) |
| 107 | + const moved = heads[i] && heads[i] !== p.pin |
| 108 | + say( |
| 109 | + ` ${p.pack.padEnd(28)} ${p.pin.slice(0, 10)}` + |
| 110 | + ` ${p.pinnedAt ?? 'undated'}` + |
| 111 | + `${days === null ? '' : ` (${days}d)`}` + |
| 112 | + ` ${!heads[i] ? 'upstream unreachable' : moved ? 'UPSTREAM MOVED' : 'at upstream HEAD'}` |
| 113 | + ) |
| 114 | + } |
| 115 | + |
| 116 | + if (age !== null && age <= MAX_AGE_DAYS) return 0 |
| 117 | + const summary = |
| 118 | + age === null |
| 119 | + ? `custom-node pins carry no date - bump them at ${UPDATE_WORKFLOW}` |
| 120 | + : `custom-node pins are ${age} days old - bump them at ${UPDATE_WORKFLOW}` |
| 121 | + say(`::warning title=Custom-node pins are stale::${summary}`) |
| 122 | + return 0 |
| 123 | +} |
| 124 | + |
| 125 | +async function update(): Promise<number> { |
| 126 | + const packs = load() |
| 127 | + const stamp = today() |
| 128 | + const heads = await Promise.all(packs.map((p) => headSha(p.repo))) |
| 129 | + |
| 130 | + const unreachable = packs.filter((_, i) => !heads[i]).map((p) => p.pack) |
| 131 | + if (unreachable.length) { |
| 132 | + process.stderr.write(`could not resolve: ${unreachable.join(', ')}\n`) |
| 133 | + return 1 |
| 134 | + } |
| 135 | + |
| 136 | + const moved = packs.filter((p, i) => heads[i] !== p.pin) |
| 137 | + const next = packs.map((p, i) => ({ |
| 138 | + ...p, |
| 139 | + pin: heads[i], |
| 140 | + pinnedAt: heads[i] === p.pin ? (p.pinnedAt ?? stamp) : stamp |
| 141 | + })) |
| 142 | + writeFileSync(manifestPath(), `${JSON.stringify(next, null, 2)}\n`) |
| 143 | + |
| 144 | + for (const p of moved) { |
| 145 | + const to = heads[packs.indexOf(p)] |
| 146 | + say(` ${p.pack.padEnd(28)} ${p.pin.slice(0, 10)} -> ${to.slice(0, 10)}`) |
| 147 | + } |
| 148 | + say( |
| 149 | + `${moved.length} of ${packs.length} pins moved; recalibrate` + |
| 150 | + ' expectedNodeCount / expectedExtensions if the suite reds' |
| 151 | + ) |
| 152 | + return 0 |
| 153 | +} |
| 154 | + |
| 155 | +const invokedDirectly = |
| 156 | + process.argv[1] !== undefined && |
| 157 | + import.meta.url === new URL(`file://${process.argv[1]}`).href |
| 158 | + |
| 159 | +if (invokedDirectly) { |
| 160 | + process.exitCode = await (process.argv.includes('--write') |
| 161 | + ? update() |
| 162 | + : report()) |
| 163 | +} |
0 commit comments