VSEARCH / scripts /refresh-catalogue.mjs
ml-intern's picture
refresh script fixes: STABLE_STYLE=app.css (build thực tế), strip query string khi verify index.html refs
17a669e
Raw History Blame Contribute Delete
17.5 kB
#!/usr/bin/env node
/**
* Refresh the vai-avatar product mirror inside the Space repo, build, and commit.
*
* The Space is `sdk: static`: there is no server that builds at request time,
* so the ONLY way a vai-avatar change reaches the browser is if the bytes under
* dist/ are regenerated and committed. That is this script's reason to exist.
*
* Intended to run from an HF scheduled job:
*
* npm run refresh:catalogue
*
* It is deliberately idempotent: if vai-avatar has not changed, nothing is
* committed or pushed, so the Space is not rebuilt pointlessly.
*
* Failure policy: every step is checked, and a failure aborts BEFORE pushing.
* The previously committed mirror therefore keeps serving. A half-written or
* empty catalogue is far worse than a slightly stale one.
*/
import { execFileSync } from 'node:child_process';
import { closeSync, existsSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs';
const REPO = process.env.SPACE_REPO || 'bep40/semantic-image-field';
const CLONE = process.env.CLONE_DIR || '/tmp/space';
/**
* The bundle names vite.config.js is configured to emit.
*
* Kept in step with `build.rollupOptions.output`, where entryFileNames and
* assetFileNames both produce "index". The build is what makes them exist, which
* is why the swap below happens only after it has run.
*/
const STABLE_BUNDLE = 'assets/index.js';
const STABLE_STYLE = 'assets/app.css';
/** The two mirrored files, in both the source and the served location. */
const MIRROR = ['products.json', 'promos.json'];
/**
* Everything this script is allowed to commit.
*
* An explicit path list rather than `git add -A`, and that is the single most
* important safety property of this file. `.gitattributes` routes
* products-index.json, products-gallery.json and dist/products-gallery.json
* through git-lfs. The clone is a shallow `--depth 1` fetch; if the environment
* has no git-lfs binary the smudge filter never runs and those files check out
* as ~130-byte pointer text. `git add -A` would then stage the pointers and the
* push would replace committed LFS objects with text — permanent, silent data
* loss on the Hub, committed by a script that reported success.
*
* None of the paths below can be an LFS pointer, so a missing git-lfs no longer
* has to be fatal: it only means the build output is unverifiable, not that the
* mirror is unsafe to publish.
*
* `dist/catalogue` is here and not merely `public/catalogue` because of WHAT THE
* BROWSER ACTUALLY FETCHES. Vite copies public/ into outDir/, so the bytes are
* served from dist/catalogue/products.json — and that is the only place they
* exist: there is no /catalogue/ at the Space root, because the Space serves
* its root index.html rather than app.html, so nothing lands outside dist/.
*
* catalogue.js candidateURLs() resolves against the bundle at dist/assets/, and
* the `../catalogue/…` candidate lands exactly on dist/catalogue/. (The
* `../../…` candidate that precedes it lands on /catalogue/, which does not
* exist, so it 404s first — a wasted request, then the dist/ form answers. The
* ordering is worth knowing but not worth depending on.)
*
* Committing only the source copy would stage the new mirror, commit the new
* bundle, print REFRESH_OK — and still 404 at runtime, because the copy that is
* served was never pushed. That is a silent failure with no error anywhere, and
* it is exactly the "sửa sp không đồng bộ" symptom.
*/
const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets', 'dist/share', 'public/share'];
const ok = message => console.log(`[refresh] ${message}`);
const warn = message => console.warn(`[refresh] WARNING: ${message}`);
/** Run a command in the clone. Throws with the captured stderr attached. */
function run(command, args, { cwd = CLONE } = {}) {
return execFileSync(command, args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 64 * 1024 * 1024,
});
}
const shell = (script, cwd = CLONE) => run('bash', ['-lc', script], { cwd });
/**
* The token goes into an Authorization header rather than the remote URL.
* Embedding it in https://user:TOKEN@huggingface.co persists it in
* .git/config and can leak it into reflogs and trace output.
*/
function git(...args) {
return run(
'git',
['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args],
);
}
/**
* Is git-lfs installed at all? (The smudge filter can only exist after
* `git lfs install`, so availability is checked on the binary first, then
* the clone-local filter is (re)wired with `git lfs install --local`.)
*/
function hasGitLfsBinary() {
try {
return Boolean(shell('git lfs version 2>/dev/null || true').trim());
} catch {
return false;
}
}
/** Is git-lfs BOTH installed AND wired as a smudge filter for this clone? */
function hasGitLfs() {
try {
if (!hasGitLfsBinary()) return false;
return Boolean(shell('git config --get filter.lfs.smudge || true').trim());
} catch {
return false;
}
}
/** Is this a git-lfs pointer rather than real content? */
function isLfsPointer(path) {
let handle;
try {
handle = openSync(path, 'r');
const buffer = Buffer.alloc(256);
const read = readSync(handle, buffer, 0, 256, 0);
return buffer.subarray(0, read).toString('utf8')
.startsWith('version https://git-lfs.github.com/spec/v1');
} catch {
return false;
} finally {
if (handle !== undefined) { try { closeSync(handle); } catch { /* already gone */ } }
}
}
/** Size of `relative`, or null if it is missing, tiny, or an LFS pointer. */
function realSize(relative) {
const path = `${CLONE}/${relative}`;
if (!existsSync(path)) return null;
const size = statSync(path).size;
if (size < 256 || isLfsPointer(path)) return null;
return size;
}
/**
* Fail unless `relative` (resolved against the clone root) holds real content.
*
* `relative` must be a path from CLONE, not from dist/ — passing a bare filename
* here silently checks the wrong location, which is how a missing committed data
* file can pass as present.
*/
function requireRealFile(relative, { why }) {
const path = `${CLONE}/${relative}`;
if (!existsSync(path)) throw new Error(`${relative} is missing — ${why}`);
if (realSize(relative) === null) {
throw new Error(
`${relative} is a git-lfs pointer or a truncated file, not real content. ${why}`,
);
}
}
/** Parse a mirror file and report how many records it holds. */
function recordCount(relative) {
const parsed = JSON.parse(readFileSync(`${CLONE}/${relative}`, 'utf8'));
if (Array.isArray(parsed)) return parsed.length;
return Object.keys(parsed?.productEdits ?? {}).length;
}
/** True when index.html already names both stable assets. */
function indexReferencesStable(html) {
return /\.\/dist\/assets\/index\.js\b/.test(html)
&& /\.\/dist\/assets\/app\.css\b/.test(html);
}
/**
* Point index.html at the freshly built bundle.
*
* This is load-bearing, not housekeeping. index.html is NOT a Vite input (the
* input is app.html), so `vite build` never rewrites it — yet the build emits
* the STABLE names while index.html still names a hashed bundle from an earlier
* build. Those hashed files survive, because emptyOutDir is false precisely so
* the build cannot delete committed data.
*
* The result was a refresh that could not fail loudly: the new bundle was
* committed, the mirror was committed, the stale-but-present asset reference
* passed verification, and the script reported REFRESH_OK — while the Space
* kept serving the pre-fix JavaScript. The catalogue would update and the code
* reading it would not, which looks exactly like "sửa sp không đồng bộ" and
* survives every rebuild.
*
* So the swap is part of the refresh. It is idempotent: once index.html names
* the stable files, the regex matches them, the write is a no-op, and git sees
* no change.
*/
function repointIndexHtml() {
const path = `${CLONE}/index.html`;
const html = readFileSync(path, 'utf8');
// Already correct: leave the file untouched so git sees no change.
if (indexReferencesStable(html)) return false;
// Only the asset reference is touched. Matching on the full "./dist/assets/"
// prefix means a hand-added link to something else in that directory (a
// vendored worker, say) is left alone rather than silently renamed.
let next = html.replace(
/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.js\b/g,
`$1${STABLE_BUNDLE.slice('assets/'.length)}`,
);
next = next.replace(
/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.css\b/g,
`$1${STABLE_STYLE.slice('assets/'.length)}`,
);
// The rewrite matched nothing AND index.html is not already correct. That is
// the case this guard exists for: index.html names its bundle by some other
// scheme (a query string, a different directory, a hand-written <script>), so
// the regex changes nothing. Treating that as "already fine" would publish a
// Space whose HTML points at a file the build never emits — a blank page that
// survives every rebuild, because the stale reference keeps resolving to a
// real, still-present file.
if (next === html) {
throw new Error(
'index.html names its bundle in a way repointIndexHtml() cannot rewrite — '
+ 'refusing to publish (would blank the Space)',
);
}
// A partial rewrite is just as broken as none: JS pointing at the stable name
// while the <link> still names a hashed CSS (or the reverse) loads a bundle
// without its stylesheet.
if (!indexReferencesStable(next)) {
throw new Error(
'only part of index.html could be repointed — it would load the bundle '
+ 'without its stylesheet, or vice versa',
);
}
// Confirm the build actually produced what we are now pointing at. Doing this
// after the rewrite rather than before would be circular; doing it before is
// what makes the rewrite safe.
for (const asset of [STABLE_BUNDLE, STABLE_STYLE]) {
requireRealFile(`dist/${asset}`, { why: 'the build did not produce it' });
}
writeFileSync(path, next, 'utf8');
ok(`index.html repointed to ${STABLE_BUNDLE} + ${STABLE_STYLE}`);
return true;
}
function main() {
if (!process.env.HF_TOKEN) {
throw new Error('HF_TOKEN is required to push the refreshed mirror.');
}
rmSync(CLONE, { recursive: true, force: true });
run('mkdir', ['-p', CLONE], { cwd: '/' });
git('init', '-q');
git('remote', 'add', 'origin', `https://huggingface.co/spaces/${REPO}`);
git('fetch', '-q', '--depth', '1', 'origin', 'main');
git('checkout', '-q', '-B', 'main', 'FETCH_HEAD');
ok(`cloned ${REPO}`);
// The og-image is LFS-tracked, and the pre-receive hook rejects raw binaries,
// so the clone MUST be able to smudge/clean via git-lfs. `git lfs install
// --local` inside the clone (a) loads the repository-local filters and (b)
// fails visibly when git-lfs is not installed, instead of silently committing
// a raw PNG pointer that a later push gets rejected for.
if (hasGitLfsBinary()) {
shell('git lfs install --local');
if (!hasGitLfs()) {
throw new Error('git lfs install --local did not wire the smudge filter');
}
ok('git-lfs wired for this clone');
} else {
throw new Error('git-lfs is required (og-image.png is LFS-tracked) — install git-lfs in the job');
}
// Informational only. COMMITTABLE excludes every LFS-tracked path, so a
// missing git-lfs cannot lead to pointers being committed; it just means the
// committed dist data cannot be verified from this clone.
const lfs = hasGitLfs();
if (!lfs) {
warn('git-lfs unavailable — dist data will be left unstaged and unverified (safe: it is LFS-tracked)');
}
shell('set -euo pipefail; npm install --silent --no-audit --no-fund');
ok('dependencies installed');
// Copies vai-avatar's live product sources into public/catalogue/.
shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
// Regenerate the social-share thumbnail (12 newest images) BEFORE the build,
// so Vite copies public/share/ into dist/share/ and the committed og-image
// is refreshed on every sync. Non-fatal: the previous thumbnail keeps
// serving if the Hub is unreachable, but a HARD failure is not silent.
shell('set -euo pipefail; python3 scripts/gen-thumbnail.py');
for (const share of ['public/share/og-image.png', 'dist/share/og-image.png']) {
requireRealFile(share, { why: 'the social-share thumbnail would be missing' });
}
ok('social-share thumbnail regenerated');
// Guard before building: an empty or truncated mirror must never ship.
for (const file of MIRROR) {
requireRealFile(`public/catalogue/${file}`, { why: 'the mirror would ship empty' });
const count = recordCount(`public/catalogue/${file}`);
if (!count) throw new Error(`public/catalogue/${file} is empty — refusing to publish`);
ok(`public/catalogue/${file}: ${count} entries`);
}
shell('set -euo pipefail; npm run build');
requireRealFile(`dist/${STABLE_BUNDLE}`, { why: 'the build did not produce it' });
ok('built');
/**
* The build copies public/ into dist/, and that copy is what the browser
* fetches. Checked AFTER the build: if Vite's publicDir handling, outDir or a
* plugin config ever changed, this is the point where it surfaces — as a loud
* abort before any push, instead of a Space whose product panel 404s while
* the refresh reports success.
*
* The counts are compared too, not just presence. A stale dist/catalogue left
* over from an earlier build is byte-valid and non-empty, so presence alone
* would happily pass while the Space serves products the sync just replaced.
*/
for (const file of MIRROR) {
const served = `dist/catalogue/${file}`;
requireRealFile(served, {
why: 'the build did not copy public/ into dist/, so the browser would 404',
});
const source = recordCount(`public/catalogue/${file}`);
const built = recordCount(served);
if (source !== built) {
throw new Error(`${served} has ${built} entries but the mirror has ${source} — the served copy is stale`);
}
ok(`served mirror verified: dist/catalogue/${file} (${built} entries)`);
}
// After the build, so the stable names are known to exist.
repointIndexHtml();
// Verify the served page BEFORE committing, and only AFTER repointing — the
// original order checked the stale reference against the stale file and passed.
const html = readFileSync(`${CLONE}/index.html`, 'utf8');
// Query strings (`?v=...`) are cache busters, not part of the filename —
// strip them so the existence check tests the real file on disk.
const referenced = [...html.matchAll(/(?:src|href)="\.\/dist\/([^"]+)"/g)]
.map(match => match[1].split('?')[0]);
if (!referenced.length) {
throw new Error('index.html references no dist/assets bundle — refusing to publish');
}
for (const name of referenced) {
requireRealFile(`dist/${name}`, { why: 'index.html points at it' });
}
ok(`index.html references verified: ${referenced.join(', ')}`);
// Confirm the build did not delete the committed data under dist/.
// vite.config.js sets emptyOutDir: false for exactly this reason; a revert of
// that setting would delete ~6,400 files with no error anywhere, and the app
// would keep working because src/ reads embeddings from the dataset repo and
// products from the mirror.
if (lfs) {
if (!existsSync(`${CLONE}/dist/prodimgs`)) {
throw new Error('dist/prodimgs is missing — the build deleted committed data; check emptyOutDir');
}
for (const file of ['dist/products-gallery.json', 'dist/products-index.json']) {
requireRealFile(file, { why: 'the build deleted committed data, or it is an LFS pointer' });
}
ok('committed dist data intact');
}
// Superseded hashed bundles are inert — index.html names exactly one — but they
// accumulate on every build. Left alone on purpose: pruning them is a separate,
// reviewable change, not something a sync job should do on its own.
// Explicit paths, never `git add -A` (see COMMITTABLE). Whether a commit
// happened must come from the commit step itself: testing it afterwards with
// `git diff --cached --quiet` would always report clean, because a successful
// commit clears the staging area — the push would then be skipped and the
// refresh would silently do nothing.
const commit = shell(`
set -euo pipefail
git config user.name "semantic-image-field refresh"
git config user.email "refresh@hf.space"
git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')}
if git diff --cached --quiet; then
echo NOCHANGE
else
git commit -q -m "chore: refresh vai-avatar catalogue mirror"
echo COMMITTED
fi
`).trim();
if (commit.includes('COMMITTED')) {
git('push', '-q', 'origin', 'main');
ok('pushed — the static Space will rebuild');
} else {
const head = shell('git rev-parse --short HEAD').trim();
ok(`catalogue unchanged (HEAD ${head})`);
}
console.log('REFRESH_OK');
}
try {
main();
} catch (error) {
const detail = (error.stderr && String(error.stderr).trim()) || error.message;
console.error(`REFRESH_FAILED: ${detail}`);
process.exitCode = 1;
}