Add unified patch runner (scripts/run-patches.mjs)
Deploy Indiekit Server / deploy (push) Successful in 2m18s

Replaces the 60-command &&-chain in postinstall and serve with a single
node scripts/run-patches.mjs call.

- Discovers all patch-*.mjs dynamically via readdir
- Runs sequentially (8 files shared across multiple patches)
- Classifies output: already / applied / not-found / failed
- Prints summary: ✓ N already applied | M newly applied | K not found | J failed
- --check flag: exits 1 if any patch was newly applied (CI idempotence)
- Surfaced hidden issue: patch-actor-aliases-successor broken (ERR_PACKAGE_PATH_NOT_EXPORTED)

ISC-36 (npm run postinstall on server) deferred — verify after next deploy.
This commit is contained in:
Sven
2026-05-03 12:03:21 +02:00
parent 38a235c129
commit 800af630a1
2 changed files with 181 additions and 2 deletions
+2 -2
View File
@@ -5,8 +5,8 @@
"main": "index.js",
"scripts": {
"preinstall": "node scripts/setup-gitea-url-rewrite.mjs",
"postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-store-github-error-message.mjs && node scripts/patch-store-github-update-fallback.mjs && node scripts/patch-store-github-gitea-methods.mjs && node scripts/patch-store-github-put-fallback.mjs && node scripts/patch-store-github-content-type.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-content-null-guard.mjs && node scripts/patch-endpoint-posts-content-field-object.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-reset-stale.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-bluesky-og-own-post-title.mjs && node scripts/patch-bluesky-syndicator-delete.mjs && node scripts/patch-micropub-delete-propagation.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-microsub-no-bookmark-autofollow.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-batch-concurrency.mjs && node scripts/patch-session-maxage.mjs && node scripts/patch-ap-dm-bsky-source-content.mjs && node scripts/patch-ap-dm-send-serialize-id.mjs",
"serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/preflight-startup-gate.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-store-github-error-message.mjs && node scripts/patch-store-github-update-fallback.mjs && node scripts/patch-store-github-gitea-methods.mjs && node scripts/patch-store-github-put-fallback.mjs && node scripts/patch-store-github-content-type.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-microsub-no-bookmark-autofollow.mjs && node scripts/patch-microsub-batch-concurrency.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-content-null-guard.mjs && node scripts/patch-endpoint-posts-content-field-object.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-reset-stale.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-bluesky-og-own-post-title.mjs && node scripts/patch-bluesky-syndicator-delete.mjs && node scripts/patch-micropub-delete-propagation.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node scripts/patch-session-maxage.mjs && node scripts/patch-ap-dm-bsky-source-content.mjs && node scripts/patch-ap-dm-send-serialize-id.mjs && node --max-old-space-size=1024 --max-semi-space-size=32 --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs",
"postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/run-patches.mjs",
"serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/preflight-startup-gate.mjs && node scripts/run-patches.mjs && node --max-old-space-size=1024 --max-semi-space-size=32 --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
// scripts/run-patches.mjs
//
// Unified patch runner: discovers all sibling `patch-*.mjs` files, executes
// each one sequentially as a Node subprocess, classifies the result based on
// captured output, and prints a summary.
//
// Sequential by design — multiple patches may target the same file, and
// concurrent writes would corrupt them.
//
// Flags:
// --check After the summary, fail (exit 1) if any patch was *newly*
// applied. Used in CI to assert idempotence: a clean checkout
// of an already-patched node_modules tree must produce zero
// "applied" results.
//
// Exit codes:
// 0 — all patches succeeded; with --check, also requires zero new applies.
// 1 — at least one patch failed, OR --check was set and applies > 0.
import { readdir } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectRoot = join(__dirname, '..');
const args = new Set(process.argv.slice(2));
const checkMode = args.has('--check');
/**
* Discover all `patch-*.mjs` files alongside this script.
* Returns absolute paths sorted alphabetically for deterministic ordering.
*/
async function discoverPatches(scriptsDir) {
const entries = await readdir(scriptsDir, { withFileTypes: true });
const patches = entries
.filter((e) => e.isFile() && e.name.startsWith('patch-') && e.name.endsWith('.mjs'))
.map((e) => join(scriptsDir, e.name))
.sort();
return patches;
}
/**
* Run a single patch as a Node subprocess. Captures stdout+stderr together
* (merged in encounter order) and the exit code.
*
* Resolves with `{ output, exitCode }`. Rejects only on unrecoverable spawn
* failures (e.g. the Node binary itself cannot be invoked) — those bubble up
* to the caller and abort the run.
*/
function runPatch(patchPath) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [patchPath], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
});
const chunks = [];
child.stdout.on('data', (buf) => chunks.push(buf));
child.stderr.on('data', (buf) => chunks.push(buf));
child.once('error', (err) => reject(err));
child.once('close', (code) => {
const output = Buffer.concat(chunks).toString('utf8');
// `code` is null when the process was killed by a signal; treat that as
// a non-zero exit so it lands in the `failed` bucket.
const exitCode = typeof code === 'number' ? code : 1;
resolve({ output, exitCode });
});
});
}
/**
* Classify a patch result into one of:
* 'already' | 'applied' | 'notFound' | 'failed'
*
* Order matters:
* 1. Non-zero exit → 'failed' regardless of output content.
* 2. "already" anywhere in output (case-insensitive) → 'already'.
* This wins over "applied" because patches commonly emit phrases like
* "already applied" — we must not double-count those as new applies.
* 3. "patched" / "applied to" / leading "Applied" → 'applied'.
* 4. "no " / "not found" (case-insensitive) → 'notFound'.
* 5. Empty output with exit 0 → 'notFound' (silent no-op).
* 6. Anything else with exit 0 → 'applied' (conservative default: a
* successful run that produced output is treated as having done work).
*/
function classify(output, exitCode) {
if (exitCode !== 0) return 'failed';
const lower = output.toLowerCase();
const trimmed = output.trim();
if (lower.includes('already')) return 'already';
// "Applied" at the start of any line, or the substrings "patched" /
// "applied to" anywhere in the output.
const hasAppliedMarker =
lower.includes('patched') ||
lower.includes('applied to') ||
/(^|\n)\s*Applied\b/.test(output);
if (hasAppliedMarker) return 'applied';
if (lower.includes('no ') || lower.includes('not found') || lower.includes(': error')) return 'notFound';
if (trimmed.length === 0) return 'notFound';
// Successful exit, non-empty output, but no recognized marker: treat as
// applied so it surfaces in --check rather than being silently swallowed.
return 'applied';
}
async function main() {
const patches = await discoverPatches(__dirname);
if (patches.length === 0) {
console.log('\n✓ 0 already applied | 0 newly applied | 0 not found | 0 failed');
return;
}
const buckets = {
already: [],
applied: [],
notFound: [],
failed: [],
};
// Sequential: shared target files mean parallel runs would corrupt them.
for (const patchPath of patches) {
const name = basename(patchPath, '.mjs');
const { output, exitCode } = await runPatch(patchPath);
const verdict = classify(output, exitCode);
buckets[verdict].push({ name, output, exitCode });
if (verdict === 'applied') {
console.log(`\n=== ${name} ===`);
process.stdout.write(output.endsWith('\n') ? output : `${output}\n`);
} else if (verdict === 'failed') {
console.error(`\n=== ${name} (FAILED, exit ${exitCode}) ===`);
process.stderr.write(output.endsWith('\n') ? output : `${output}\n`);
}
}
const summary =
`\n${buckets.already.length} already applied` +
` | ${buckets.applied.length} newly applied` +
` | ${buckets.notFound.length} not found` +
` | ${buckets.failed.length} failed`;
console.log(summary);
let exitCode = 0;
if (checkMode && buckets.applied.length > 0) {
for (const { name } of buckets.applied) {
console.log(`[CHECK FAIL] ${name}`);
}
exitCode = 1;
}
if (buckets.failed.length > 0) {
exitCode = 1;
}
process.exit(exitCode);
}
main().catch((err) => {
console.error('run-patches: fatal error');
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});