diff --git a/oven/papers-git-poller.mjs b/oven/papers-git-poller.mjs index ff56149e60..06ab22ee20 100644 --- a/oven/papers-git-poller.mjs +++ b/oven/papers-git-poller.mjs @@ -85,6 +85,7 @@ async function writeLastBuiltHash(hash) { } async function poll() { + if (process.env.PAPERS_POLLER_DISABLED === "1") return; if (polling) return; polling = true; @@ -133,6 +134,36 @@ async function poll() { return; } + // Self-commit skip: if every commit in lastBuilt..remoteHead was authored + // by the oven itself, this is just our own auto-build push echoing back. + // Don't re-trigger — that's the positive-feedback loop. We only reach this + // branch when isSourceChange() let some path through (e.g. a non-deterministic + // PDF that landed inside papers/ rather than the IGNORE_PATTERNS prefix). + if (lastBuilt) { + try { + const authorOutput = await git([ + "log", + `${lastBuilt}..${remoteHead}`, + "--format=%ae", + ]); + const authors = new Set( + authorOutput.split("\n").map((a) => a.trim()).filter(Boolean), + ); + if (authors.size > 0 && authors.size === 1 && authors.has("oven@aesthetic.computer")) { + logFn( + "info", + "⏭️", + `Commits ${lastBuilt.slice(0, 8)}..${remoteHead.slice(0, 8)} are all oven auto-builds — skipping`, + ); + await writeLastBuiltHash(remoteHead); + polling = false; + return; + } + } catch (authorErr) { + logFn("error", "⚠️", `Self-commit author check failed: ${authorErr.message}`); + } + } + // Pull the changes so cli.mjs works on up-to-date code await git(["checkout", BRANCH, "--quiet"]); await git(["merge", `origin/${BRANCH}`, "--ff-only", "--quiet"]); @@ -143,6 +174,12 @@ async function poll() { `Papers changes detected (${remoteHead.slice(0, 8)}): ${papersPaths.length} file(s) — triggering PDF build` ); + // Defensive: write the hash BEFORE starting the build so we don't retry + // the same commit if startBuildFn throws PAPERS_BUILD_BUSY or any other + // error path leaves writeLastBuiltHash unreached. A failed build can be + // re-triggered by pushing a new commit or deleting the hash file. + await writeLastBuiltHash(remoteHead); + // Trigger build const job = await startBuildFn({ ref: remoteHead, @@ -154,9 +191,6 @@ async function poll() { "🚀", `Papers build ${job.id} started` ); - - // Update hash after successfully starting the build - await writeLastBuiltHash(remoteHead); } catch (err) { if (err?.code === "PAPERS_BUILD_BUSY") { logFn("info", "⏳", "Papers build already running — will retry next poll"); @@ -178,6 +212,11 @@ export function startPoller({ startPapersBuild, addServerLog }) { startBuildFn = startPapersBuild; if (addServerLog) logFn = addServerLog; + if (process.env.PAPERS_POLLER_DISABLED === "1") { + logFn("info", "⏸️", "Papers git poller disabled via PAPERS_POLLER_DISABLED=1 — not starting"); + return; + } + // Check that GIT_REPO_DIR exists before starting fs.access(GIT_REPO_DIR) .then(() => { diff --git a/papers/cli.mjs b/papers/cli.mjs index 64f2a57875..567dabcbd6 100755 --- a/papers/cli.mjs +++ b/papers/cli.mjs @@ -391,12 +391,31 @@ function buildOne(entry) { const paperDir = join(PAPERS_DIR, entry.dir); const tex = texName(entry.base, entry.lang); console.log(` BUILD ${entry.dir}/${tex}.tex ...`); + + // Deterministic PDFs: derive SOURCE_DATE_EPOCH from the newest source mtime. + // xelatex + xdvipdfmx honor this for /CreationDate, /ModDate, and the /ID + // trailer, so identical input bytes produce identical output bytes — which + // stops the oven auto-build commit loop on the 2 PDFs whose only diff + // between runs was wall-clock metadata baked into the trailer. + const newestMs = sourcesMtime(entry); + const sourceDateEpoch = Number.isFinite(newestMs) + ? Math.floor(newestMs / 1000).toString() + : Math.floor(Date.now() / 1000).toString(); + try { // Run xelatex 3-pass with bibtex. Use semicolons (not &&) so bibtex // warnings don't kill the chain. Check for PDF existence, not exit code. execSync( `cd "${paperDir}" && xelatex -interaction=nonstopmode "${tex}.tex"; bibtex "${tex}" 2>/dev/null; xelatex -interaction=nonstopmode "${tex}.tex"; xelatex -interaction=nonstopmode "${tex}.tex"`, - { stdio: "pipe", timeout: 180000 }, + { + stdio: "pipe", + timeout: 180000, + env: { + ...process.env, + SOURCE_DATE_EPOCH: sourceDateEpoch, + FORCE_SOURCE_DATE: "1", + }, + }, ); } catch (e) { // xelatex may return non-zero on warnings but still produce a PDF.