fhirsmith 0.9.7 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [v0.10.1] - 2026-06-27
10
+
11
+ ### Fixed
12
+
13
+ - Terminology: the version-less value set lookup now resolves to the latest version when several versions of the same value set are present (e.g. VSAC date versions), instead of an arbitrary one determined by database row order
14
+ - Rendering: fixed value set links in `renderLinkComma` (read the resolver's `description`/`link` fields and fall back to the raw URI)
15
+
16
+ ### Tx Conformance Statement
17
+
18
+ FHIRsmith passed all 2503 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.11)
19
+
20
+ ## [v0.10.0] - 2026-06-27
21
+
22
+ ### Added
23
+
24
+ - Publisher: track the IG Publisher version used for each task, and show it on the tasks list and task detail pages
25
+ - Packages: new `POST /packages/update-package` endpoint to force-refresh specific packages in the registry, bypassing the feed crawler's de-duplication (used to push out a corrected package after a bad publish)
26
+ - Publisher: automatically update SUSHI to the latest release before each draft build and publication run, installed into a FHIRsmith-owned prefix so it needs no root privileges
27
+
28
+ ### Changed
29
+
30
+ - Rebuilt how caching works (see the tools IG for documentation)
31
+ - Renamed the compare worker/operation
32
+
33
+ ### Fixed
34
+
35
+ - Publisher: the publication run now verifies that the package about to be published is a real publication build (not a draft) before committing it to the web tree, so a failed publication build can no longer silently ship a `notForPublication` draft package
36
+ - Packages: the crawler now rejects any package flagged `notForPublication` at ingest, instead of only checking the feed entry
37
+ - Registry: better handling of versions on manual queries against the external registry
38
+ - Fixed handling of the cache-control header
39
+ - Corrections to generated test cases
40
+
41
+ ### Security
42
+
43
+ - SSRF protection: outbound fetches in the packages and registry crawlers now reject any host that resolves to a non-public address (private, loopback, link-local including cloud-metadata, CGNAT, unique-local, etc.). Enforced at connection time, so it also covers redirect targets and DNS rebinding, and prevents leaking registry API keys to a redirected host
44
+ - Path-injection hardening: local-file feed reads in the packages crawler are confined to explicitly-allowed directories; the `update-package` endpoint only accepts http(s) URLs
45
+ - Dependency updates (npm audit)
46
+
47
+ ### Tx Conformance Statement
48
+
49
+ FHIRsmith passed all 2503 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.11)
50
+
9
51
  ## [v0.9.7] - 2026-06-12
10
52
 
11
53
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.9.7",
3
+ "version": "0.10.1",
4
4
  "txVersion": "1.9.1",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -9,8 +9,27 @@ const {XMLParser} = require('fast-xml-parser');
9
9
  const crypto = require('crypto');
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const dns = require('dns');
13
+ const http = require('http');
14
+ const https = require('https');
15
+ const ipaddr = require('ipaddr.js');
12
16
  const {debugLog} = require("../tx/operation-context");
13
17
 
18
+ // True if an IP literal is anything other than a normal public (unicast) address:
19
+ // loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
20
+ // CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
21
+ function isNonPublicAddress(ip) {
22
+ try {
23
+ let addr = ipaddr.parse(ip);
24
+ if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
25
+ addr = addr.toIPv4Address();
26
+ }
27
+ return addr.range() !== 'unicast';
28
+ } catch (e) {
29
+ return true; // unparseable - treat as unsafe
30
+ }
31
+ }
32
+
14
33
  class PackageCrawler {
15
34
  log;
16
35
  packages = new Set();
@@ -139,10 +158,96 @@ class PackageCrawler {
139
158
  return url.replace(/^http:/, 'https:');
140
159
  }
141
160
 
161
+ // Roots under which local-file feed reads are permitted. Reading local files supports
162
+ // locally-configured feeds (e.g. for testing); allowed roots come from
163
+ // config.localFeedDirs, plus the directory of a local master feed url. Anything else
164
+ // is rejected so a third-party feed can't point a read at an arbitrary server file.
165
+ allowedLocalRoots() {
166
+ const roots = [];
167
+ const cfg = this.config && this.config.localFeedDirs;
168
+ if (Array.isArray(cfg)) {
169
+ roots.push(...cfg);
170
+ } else if (typeof cfg === 'string' && cfg.length > 0) {
171
+ roots.push(cfg);
172
+ }
173
+ if (this.config && typeof this.config.masterUrl === 'string' && this.config.masterUrl.startsWith('/')) {
174
+ roots.push(path.dirname(this.config.masterUrl));
175
+ }
176
+ return roots.map((r) => path.resolve(r));
177
+ }
178
+
179
+ // A DNS lookup wrapper that rejects any host resolving to a non-public address.
180
+ // Enforced at connection time (so it also covers redirect targets and defeats
181
+ // DNS-rebinding), this is the SSRF guard for all outbound http(s) fetches.
182
+ // Set config.allowPrivateAddresses = true to disable (e.g. for local test registries).
183
+ ssrfLookup() {
184
+ const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
185
+ return (hostname, options, callback) => {
186
+ if (typeof options === 'function') {
187
+ callback = options;
188
+ options = {};
189
+ }
190
+ const wantAll = !!(options && options.all);
191
+ dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
192
+ if (err) {
193
+ callback(err);
194
+ return;
195
+ }
196
+ if (!allowPrivate) {
197
+ for (const a of addresses) {
198
+ if (isNonPublicAddress(a.address)) {
199
+ callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
200
+ return;
201
+ }
202
+ }
203
+ }
204
+ if (wantAll) {
205
+ callback(null, addresses);
206
+ } else {
207
+ callback(null, addresses[0].address, addresses[0].family);
208
+ }
209
+ });
210
+ };
211
+ }
212
+
213
+ // http/https agents that route every connection through the SSRF lookup. Cached so
214
+ // connections can be pooled across requests.
215
+ guardedAgents() {
216
+ if (!this._guardedAgents) {
217
+ const lookup = this.ssrfLookup();
218
+ class GuardedHttpAgent extends http.Agent {
219
+ createConnection(options, cb) {
220
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
221
+ }
222
+ }
223
+ class GuardedHttpsAgent extends https.Agent {
224
+ createConnection(options, cb) {
225
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
226
+ }
227
+ }
228
+ this._guardedAgents = {
229
+ httpAgent: new GuardedHttpAgent({ keepAlive: true }),
230
+ httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
231
+ };
232
+ }
233
+ return this._guardedAgents;
234
+ }
235
+
236
+ // Resolve a local feed path and confine it to an allowed root (path-injection guard).
237
+ resolveLocalReadPath(url) {
238
+ const resolved = path.resolve(url);
239
+ const roots = this.allowedLocalRoots();
240
+ const allowed = roots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
241
+ if (!allowed) {
242
+ throw new Error('Refusing to read local file outside the allowed feed directories: ' + url);
243
+ }
244
+ return resolved;
245
+ }
246
+
142
247
  async fetchJson(url) {
143
248
  try {
144
249
  if (url.startsWith("/")) {
145
- const content = await fs.promises.readFile(url, "utf8");
250
+ const content = await fs.promises.readFile(this.resolveLocalReadPath(url), "utf8");
146
251
  return JSON.parse(content);
147
252
  } else {
148
253
  const response = await axios.get(url, {
@@ -150,7 +255,9 @@ class PackageCrawler {
150
255
  signal: this.abortController?.signal,
151
256
  headers: {
152
257
  'User-Agent': 'FHIR Package Crawler/1.0'
153
- }
258
+ },
259
+ httpAgent: this.guardedAgents().httpAgent,
260
+ httpsAgent: this.guardedAgents().httpsAgent
154
261
  });
155
262
  return response.data;
156
263
  }
@@ -166,7 +273,7 @@ class PackageCrawler {
166
273
  async fetchXml(url) {
167
274
  try {
168
275
  if (url.startsWith("/")) {
169
- const content = await fs.promises.readFile(url, 'utf8');
276
+ const content = await fs.promises.readFile(this.resolveLocalReadPath(url), 'utf8');
170
277
  const parser = new XMLParser({
171
278
  ignoreAttributes: false,
172
279
  attributeNamePrefix: '@_',
@@ -180,7 +287,9 @@ class PackageCrawler {
180
287
  signal: this.abortController?.signal,
181
288
  headers: {
182
289
  'User-Agent': 'FHIR Package Crawler/1.0'
183
- }
290
+ },
291
+ httpAgent: this.guardedAgents().httpAgent,
292
+ httpsAgent: this.guardedAgents().httpsAgent
184
293
  });
185
294
 
186
295
  const parser = new XMLParser({
@@ -203,7 +312,7 @@ class PackageCrawler {
203
312
  async fetchUrl(url) {
204
313
  try {
205
314
  if (url.startsWith("/")) {
206
- const buffer = await fs.promises.readFile(url);
315
+ const buffer = await fs.promises.readFile(this.resolveLocalReadPath(url));
207
316
  this.totalBytes += buffer.byteLength;
208
317
  return buffer;
209
318
  } else {
@@ -213,7 +322,9 @@ class PackageCrawler {
213
322
  signal: this.abortController?.signal,
214
323
  headers: {
215
324
  'User-Agent': 'FHIR Package Crawler/1.0'
216
- }
325
+ },
326
+ httpAgent: this.guardedAgents().httpAgent,
327
+ httpsAgent: this.guardedAgents().httpsAgent
217
328
  });
218
329
 
219
330
  this.totalBytes += response.data.byteLength;
@@ -558,6 +669,12 @@ class PackageCrawler {
558
669
  if (npmPackage.hasJavaScript && !isTemplate && id !== 'hl7.fhir.pubpack') {
559
670
  throw new Error(`Package ${idver} rejected: contains JavaScript files but is not a template package`);
560
671
  }
672
+ // The feed gate (item.notForPublication) only sees the RSS entry. A package whose
673
+ // feed entry is clean can still carry notForPublication inside the tarball - that is
674
+ // a draft build that must never enter the registry. Reject it here too.
675
+ if (npmPackage.notForPublication) {
676
+ throw new Error(`Package ${idver} rejected: tarball is flagged notForPublication (draft build, not suitable for publication)`);
677
+ }
561
678
 
562
679
  // Extract URLs from package
563
680
  const urls = this.processPackageUrls(npmPackage);
@@ -592,6 +592,64 @@ class PackagesModule {
592
592
  }
593
593
  }
594
594
 
595
+ // Re-fetch a single package tarball and replace whatever is stored for it, bypassing
596
+ // the crawler's GUID dedup and notForPublication feed gate. Used to push out a
597
+ // corrected package that was already (mis)published.
598
+ async forceUpdatePackage(link) {
599
+ // Only allow fetching over http(s). This endpoint must never be usable to read
600
+ // local server files (path injection) - tarballs are always published web URLs.
601
+ if (!link || !/^https?:\/\//i.test(link)) {
602
+ throw new Error('Invalid package link (must be an http(s) URL): ' + link);
603
+ }
604
+ if (!this.crawler) {
605
+ this.crawler = new PackageCrawler(this.config, this.db, this.stats);
606
+ }
607
+
608
+ // The feed uses the versioned package.tgz url as the (permalink) GUID, so reusing
609
+ // the link as the GUID keeps a later crawl from inserting a duplicate.
610
+ const guid = link;
611
+
612
+ const buffer = await this.crawler.fetchUrl(link);
613
+ const npm = await this.crawler.extractNpmPackage(buffer, link);
614
+
615
+ // Refuse to re-store a still-broken package - that would just re-publish the bug.
616
+ if (npm.notForPublication) {
617
+ throw new Error('Refusing to store ' + npm.id + '#' + npm.version + ': fetched tarball is still flagged notForPublication');
618
+ }
619
+
620
+ const idver = npm.id + '#' + npm.version;
621
+ const replaced = await this.deleteVersionsByGuid(guid);
622
+
623
+ const itemLog = { status: '??' };
624
+ await this.crawler.store(link, link, guid, new Date(), buffer, idver, itemLog);
625
+
626
+ pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' + replaced + ' existing row(s))');
627
+ return { status: 'updated', id: npm.id, version: npm.version, replaced };
628
+ }
629
+
630
+ // Delete a stored package version and all of its child rows, by GUID.
631
+ deleteVersionsByGuid(guid) {
632
+ return new Promise((resolve, reject) => {
633
+ this.db.all('SELECT PackageVersionKey FROM PackageVersions WHERE GUID = ?', [guid], (err, rows) => {
634
+ if (err) return reject(err);
635
+ const keys = (rows || []).map(r => r.PackageVersionKey);
636
+ if (keys.length === 0) return resolve(0);
637
+ const ph = keys.map(() => '?').join(',');
638
+ const stmts = [
639
+ 'DELETE FROM PackageFHIRVersions WHERE PackageVersionKey IN (' + ph + ')',
640
+ 'DELETE FROM PackageDependencies WHERE PackageVersionKey IN (' + ph + ')',
641
+ 'DELETE FROM PackageURLs WHERE PackageVersionKey IN (' + ph + ')',
642
+ 'DELETE FROM PackageVersions WHERE PackageVersionKey IN (' + ph + ')'
643
+ ];
644
+ const runNext = (i) => {
645
+ if (i >= stmts.length) return resolve(keys.length);
646
+ this.db.run(stmts[i], keys, (e) => e ? reject(e) : runNext(i + 1));
647
+ };
648
+ runNext(0);
649
+ });
650
+ });
651
+ }
652
+
595
653
  async initializeDatabase() {
596
654
  return new Promise((resolve, reject) => {
597
655
  // Use config path if absolute, otherwise resolve relative to data dir
@@ -1223,6 +1281,52 @@ class PackagesModule {
1223
1281
  }
1224
1282
  });
1225
1283
 
1284
+ // Force-refresh specific packages, bypassing the feed. The crawler only fetches a
1285
+ // package once (it dedupes on the feed GUID) and skips anything flagged
1286
+ // notForPublication, so there is normally no way to make it re-pick-up a package
1287
+ // that was published incorrectly and later corrected. This endpoint re-fetches the
1288
+ // tarball(s) directly and replaces whatever is stored.
1289
+ //
1290
+ // POST /update-package { "links": ["http://hl7.org/fhir/uv/ips/2.0.1/package.tgz", ...] }
1291
+ //
1292
+ // The link is the versioned package.tgz url, which is exactly the GUID the feed uses,
1293
+ // so the replacement keeps the same GUID and a later crawl won't create a duplicate.
1294
+ // If config.updateToken is set, the request must carry it in the x-update-token header.
1295
+ this.router.post('/update-package', async (req, res) => {
1296
+ const start = Date.now();
1297
+ try {
1298
+ if (this.config.updateToken && req.headers['x-update-token'] !== this.config.updateToken) {
1299
+ res.status(403).json({ error: 'forbidden: missing or invalid x-update-token' });
1300
+ return;
1301
+ }
1302
+ let links = req.body && (req.body.links || req.body.packages || (req.body.url ? [req.body.url] : (req.body.link ? [req.body.link] : null)));
1303
+ if (typeof links === 'string') links = [links];
1304
+ if (!Array.isArray(links) || links.length === 0) {
1305
+ res.status(400).json({ error: 'Provide a JSON body like {"links": ["<package.tgz url>", ...]}' });
1306
+ return;
1307
+ }
1308
+ const results = [];
1309
+ for (const link of links) {
1310
+ try {
1311
+ results.push(Object.assign({ link }, await this.forceUpdatePackage(link)));
1312
+ } catch (e) {
1313
+ pckLog.error('Force update failed for ' + link + ': ' + e.message);
1314
+ results.push({ link, status: 'error', error: e.message });
1315
+ }
1316
+ }
1317
+ const failed = results.filter(r => r.status === 'error').length;
1318
+ res.status(failed === results.length ? 500 : 200).json({
1319
+ message: 'Processed ' + results.length + ' package(s), ' + failed + ' failed',
1320
+ results
1321
+ });
1322
+ } catch (error) {
1323
+ pckLog.error('update-package endpoint failed:', error);
1324
+ res.status(500).json({ error: 'update-package failed', message: error.message });
1325
+ } finally {
1326
+ this.stats.countRequest('update-package', Date.now() - start);
1327
+ }
1328
+ });
1329
+
1226
1330
  // Crawler statistics endpoint (existing)
1227
1331
  this.router.get('/stats', async (req, res) => {
1228
1332
  const start = Date.now();
@@ -200,6 +200,17 @@ class PublisherModule {
200
200
  });
201
201
  });
202
202
  }
203
+ if (!columnNames.includes('publisher_version')) {
204
+ await new Promise((resolve, reject) => {
205
+ this.db.run('ALTER TABLE tasks ADD COLUMN publisher_version TEXT', (err) => {
206
+ if (err) reject(err);
207
+ else {
208
+ this.logger.info('Migration: added publisher_version column to tasks table');
209
+ resolve();
210
+ }
211
+ });
212
+ });
213
+ }
203
214
  const websiteColumns = await new Promise((resolve, reject) => {
204
215
  this.db.all("PRAGMA table_info(websites)", (err, rows) => {
205
216
  if (err) reject(err);
@@ -542,6 +553,11 @@ class PublisherModule {
542
553
  // Step 3: Clone GitHub repository
543
554
  await this.cloneRepository(task, draftDir);
544
555
 
556
+ // Step 3b: Update SUSHI to the latest release. The IG Publisher runs SUSHI with
557
+ // --require-latest, so a SUSHI that has fallen behind npm's latest makes the build
558
+ // fail. Refreshing it here keeps draft builds working.
559
+ await this.ensureLatestSushi(task.id);
560
+
545
561
  // Step 4: Run IG publisher
546
562
  await this.runIGPublisher(publisherJar, draftDir, logFile, task.id);
547
563
 
@@ -578,6 +594,16 @@ class PublisherModule {
578
594
  throw new Error('Could not find publisher.jar in latest release');
579
595
  }
580
596
 
597
+ // Record which IG Publisher version this task is using. The same jar is reused
598
+ // for the publication run, so this is the version that produced the output.
599
+ const publisherVersion = releaseResponse.data.tag_name || releaseResponse.data.name || 'unknown';
600
+ try {
601
+ await this.updateTaskFields(taskId, { publisher_version: publisherVersion });
602
+ } catch (e) {
603
+ this.logger.warn('Failed to record publisher_version for task ' + taskId + ': ' + e.message);
604
+ }
605
+ await this.logTaskMessage(taskId, 'info', 'Using IG Publisher version ' + publisherVersion);
606
+
581
607
  await this.logTaskMessage(taskId, 'info', 'Downloading from: ' + downloadUrl);
582
608
 
583
609
  // Download the file
@@ -603,6 +629,62 @@ class PublisherModule {
603
629
  }
604
630
  }
605
631
 
632
+ // Directory holding FHIRsmith's own copy of SUSHI. Installing here (rather than -g)
633
+ // needs no root: on the server the global npm prefix (/usr) is root-owned.
634
+ sushiDir() {
635
+ return folders.filePath('publisher', 'sushi');
636
+ }
637
+
638
+ // Environment for spawning the IG Publisher so it finds our managed SUSHI on PATH.
639
+ // The publisher resolves the `sushi` command from PATH, so prepending our bin dir
640
+ // makes it use the version we just installed.
641
+ publisherEnv() {
642
+ const binDir = path.join(this.sushiDir(), 'bin');
643
+ return Object.assign({}, process.env, {
644
+ PATH: binDir + path.delimiter + (process.env.PATH || '')
645
+ });
646
+ }
647
+
648
+ // Update FHIRsmith's managed SUSHI to the latest npm release before a run. The IG
649
+ // Publisher invokes SUSHI with --require-latest, so a SUSHI that has fallen behind
650
+ // npm's latest makes the build abort. We install into a FHIRsmith-owned prefix
651
+ // (sushiDir) to avoid needing root for a global install, and the publisher picks it
652
+ // up via publisherEnv(). Best-effort: a failure is logged but not fatal - the
653
+ // corrected runBuild check in the IG Publisher now turns a stale SUSHI into a loud
654
+ // publication failure rather than a silently-published draft.
655
+ async ensureLatestSushi(taskId) {
656
+ const { spawn } = require('child_process');
657
+ const dir = this.sushiDir();
658
+ fs.mkdirSync(dir, { recursive: true });
659
+ await this.logTaskMessage(taskId, 'info', 'Ensuring SUSHI is up to date in ' + dir + ' ...');
660
+ await new Promise((resolve) => {
661
+ // -g with --prefix makes npm treat `dir` as the global prefix, so the binary
662
+ // lands at dir/bin/sushi (a FHIRsmith-owned, writable location).
663
+ const npm = spawn('npm', ['install', '-g', 'fsh-sushi@latest', '--prefix', dir], { stdio: ['ignore', 'pipe', 'pipe'] });
664
+ let err = '';
665
+ npm.stdout.on('data', () => { /* ignore */ });
666
+ npm.stderr.on('data', (d) => { err += d.toString(); });
667
+ npm.on('error', async (e) => {
668
+ await this.logTaskMessage(taskId, 'warn', 'Could not run npm to update SUSHI: ' + e.message);
669
+ resolve();
670
+ });
671
+ npm.on('close', async (code) => {
672
+ if (code === 0) {
673
+ let version = '';
674
+ try {
675
+ version = require('child_process').execSync('sushi --version', { env: this.publisherEnv(), encoding: 'utf8' }).trim();
676
+ } catch (e) {
677
+ version = '(version check failed)';
678
+ }
679
+ await this.logTaskMessage(taskId, 'info', 'SUSHI is up to date: ' + version);
680
+ } else {
681
+ await this.logTaskMessage(taskId, 'warn', 'SUSHI update exited with code ' + code + (err ? ': ' + err.trim().slice(-400) : ''));
682
+ }
683
+ resolve();
684
+ });
685
+ });
686
+ }
687
+
606
688
  async cloneRepository(task, draftDir) {
607
689
  const { spawn } = require('child_process');
608
690
  const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
@@ -658,7 +740,8 @@ class PublisherModule {
658
740
  '.'
659
741
  ], {
660
742
  cwd: draftDir,
661
- stdio: ['pipe', 'pipe', 'pipe']
743
+ stdio: ['pipe', 'pipe', 'pipe'],
744
+ env: this.publisherEnv()
662
745
  });
663
746
 
664
747
  // Create log file stream
@@ -684,7 +767,7 @@ class PublisherModule {
684
767
  const elapsedMs = Date.now() - buildStart;
685
768
  const sinceDataMs = Date.now() - lastDataAt;
686
769
  let logKb = 0;
687
- try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) {}
770
+ try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) { /* log file not created yet */ }
688
771
  const elapsedMin = Math.floor(elapsedMs / 60000);
689
772
  const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
690
773
  const idleSec = Math.floor(sinceDataMs / 1000);
@@ -760,6 +843,91 @@ class PublisherModule {
760
843
  await this.logTaskMessage(task.id, 'info', 'Build output verified: package-id=' + qaData['package-id'] + ', version=' + qaData['ig-ver']);
761
844
  }
762
845
 
846
+ // Read package/package.json out of a .tgz without unpacking the whole archive.
847
+ inspectPackageTgz(tgzPath) {
848
+ const { spawn } = require('child_process');
849
+ return new Promise((resolve, reject) => {
850
+ const tar = spawn('tar', ['-xzOf', tgzPath, 'package/package.json']);
851
+ let out = '';
852
+ let err = '';
853
+ tar.stdout.on('data', (d) => { out += d.toString(); });
854
+ tar.stderr.on('data', (d) => { err += d.toString(); });
855
+ tar.on('error', reject);
856
+ tar.on('close', (code) => {
857
+ if (code !== 0) {
858
+ reject(new Error('Could not read ' + tgzPath + ': ' + (err.trim() || ('tar exit ' + code))));
859
+ } else {
860
+ try {
861
+ resolve(JSON.parse(out));
862
+ } catch (e) {
863
+ reject(new Error('Invalid package.json in ' + tgzPath + ': ' + e.message));
864
+ }
865
+ }
866
+ });
867
+ });
868
+ }
869
+
870
+ // Confirm the package.tgz files that landed in the web tree are publication builds,
871
+ // not the draft. Throws (failing the task) if a draft package was published.
872
+ async verifyPublishedPackage(task, website, draftDir) {
873
+ await this.logTaskMessage(task.id, 'info', 'Verifying published package(s) are publication builds...');
874
+
875
+ // The intended publication path comes from the IG's publication-request.json.
876
+ const prPath = path.join(draftDir, 'publication-request.json');
877
+ if (!fs.existsSync(prPath)) {
878
+ await this.logTaskMessage(task.id, 'warn', 'No publication-request.json found at ' + prPath + ' - skipping published-package check');
879
+ return;
880
+ }
881
+ const pr = JSON.parse(fs.readFileSync(prPath, 'utf8'));
882
+ const pubPath = pr.path;
883
+
884
+ // The website base url lets us map a canonical url to a folder in the web tree.
885
+ const setupPath = path.join(website.local_folder, 'publish-setup.json');
886
+ if (!pubPath || !fs.existsSync(setupPath)) {
887
+ await this.logTaskMessage(task.id, 'warn', 'Cannot resolve web path (path or publish-setup.json missing) - skipping published-package check');
888
+ return;
889
+ }
890
+ const setup = JSON.parse(fs.readFileSync(setupPath, 'utf8'));
891
+ const baseUrl = setup.website && setup.website.url;
892
+ if (!baseUrl || !pubPath.startsWith(baseUrl)) {
893
+ await this.logTaskMessage(task.id, 'warn', 'Publication path ' + pubPath + ' is not under website url ' + baseUrl + ' - skipping published-package check');
894
+ return;
895
+ }
896
+
897
+ const relVer = pubPath.substring(baseUrl.length).replace(/^\/+/, '');
898
+ const relCur = relVer.replace(/\/[^/]+\/?$/, ''); // strip the version segment for the "current" copy
899
+ const candidates = [
900
+ path.join(website.local_folder, relVer, 'package.tgz'),
901
+ path.join(website.local_folder, relCur, 'package.tgz')
902
+ ];
903
+
904
+ let checked = 0;
905
+ for (const pkgPath of candidates) {
906
+ if (!fs.existsSync(pkgPath)) {
907
+ continue;
908
+ }
909
+ checked++;
910
+ const json = await this.inspectPackageTgz(pkgPath);
911
+ const problems = [];
912
+ if (json.notForPublication) {
913
+ problems.push('notForPublication is set');
914
+ }
915
+ if (typeof json.url === 'string' && json.url.startsWith('file:')) {
916
+ problems.push('url is a local file path (' + json.url + ')');
917
+ }
918
+ if (problems.length > 0) {
919
+ throw new Error('Published package ' + pkgPath + ' is a draft build, not a publication build (' +
920
+ problems.join('; ') + '). The IG Publisher publication run likely skipped package ' +
921
+ 'regeneration (e.g. a Jekyll/template failure). Not committing.');
922
+ }
923
+ await this.logTaskMessage(task.id, 'info', 'Verified publication package: ' + pkgPath);
924
+ }
925
+
926
+ if (checked === 0) {
927
+ await this.logTaskMessage(task.id, 'warn', 'No published package.tgz found to verify under ' + relVer + ' or ' + relCur);
928
+ }
929
+ }
930
+
763
931
  async runPublication(task) {
764
932
  const website = await this.getWebsite(task.website_id);
765
933
  if (!website) {
@@ -824,6 +992,12 @@ class PublisherModule {
824
992
  // Step 3: Pull latest web folder before publishing into it
825
993
  await this.runCommand('git', ['pull'], { cwd: website.git_root }, task.id, 'Pulling latest web folder');
826
994
 
995
+ // Step 3b: Update SUSHI to the latest release before the publication run. This is the
996
+ // step that previously failed silently: the publication build runs SUSHI with
997
+ // --require-latest, and a draft approved days earlier may now face a newer SUSHI on
998
+ // npm. Refresh it so the publication build doesn't abort on a version mismatch.
999
+ await this.ensureLatestSushi(task.id);
1000
+
827
1001
  // Step 4: Run the IG publisher in go-publish mode
828
1002
  await this.runPublisherGoPublish(task.id, publisherJar, draftDir, website.local_folder,
829
1003
  registryDir, historyDir, templatesDir, zipsDir, publishLogFile);
@@ -836,6 +1010,12 @@ class PublisherModule {
836
1010
  }
837
1011
  await this.logTaskMessage(task.id, 'info', 'Publication run verified: ' + pubLogName + ' found');
838
1012
 
1013
+ // Step 5b: Verify the published package is actually a publication build.
1014
+ // If the IG Publisher's publication run skipped package regeneration (e.g. a Jekyll
1015
+ // or template failure), the draft package - flagged notForPublication with a file://
1016
+ // url - can survive into the web tree. Catch that here, before anything is committed.
1017
+ await this.verifyPublishedPackage(task, website, draftDir);
1018
+
839
1019
  // Step 6: Commit and push the web folder
840
1020
  await this.logTaskMessage(task.id, 'info', 'Committing changes to web folder...');
841
1021
  const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
@@ -892,7 +1072,8 @@ class PublisherModule {
892
1072
 
893
1073
  return new Promise((resolve, reject) => {
894
1074
  const java = spawn('java', args, {
895
- stdio: ['pipe', 'pipe', 'pipe']
1075
+ stdio: ['pipe', 'pipe', 'pipe'],
1076
+ env: this.publisherEnv()
896
1077
  });
897
1078
 
898
1079
  const logStream = fs.createWriteStream(logFile);
@@ -916,7 +1097,7 @@ class PublisherModule {
916
1097
  const elapsedMs = Date.now() - buildStart;
917
1098
  const sinceDataMs = Date.now() - lastDataAt;
918
1099
  let logKb = 0;
919
- try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) {}
1100
+ try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) { /* log file not created yet */ }
920
1101
  const elapsedMin = Math.floor(elapsedMs / 60000);
921
1102
  const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
922
1103
  const idleSec = Math.floor(sinceDataMs / 1000);
@@ -1217,7 +1398,7 @@ class PublisherModule {
1217
1398
  } else {
1218
1399
  content += '<div class="table-responsive">';
1219
1400
  content += '<table class="table table-striped">';
1220
- content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
1401
+ content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>IG Publisher</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
1221
1402
  content += '<tbody>';
1222
1403
 
1223
1404
  for (const task of tasks) {
@@ -1238,6 +1419,7 @@ class PublisherModule {
1238
1419
  content += '<td>' + task.version + '</td>';
1239
1420
  content += '<td>' + task.website_name + '</td>';
1240
1421
  content += '<td><span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></td>';
1422
+ content += '<td>' + (task.publisher_version ? '<code>' + escape(task.publisher_version) + '</code>' : '<span class="text-muted">—</span>') + '</td>';
1241
1423
  content += '<td>' + new Date(task.queued_at).toLocaleString() + '</td>';
1242
1424
  content += '<td>' + task.user_name + '</td>';
1243
1425
  content += '<td class="task-actions">';
@@ -1542,6 +1724,10 @@ class PublisherModule {
1542
1724
  content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></p>';
1543
1725
  content += '<p><strong>GitHub:</strong> ' + task.github_org + '/' + task.github_repo + ' (' + task.git_branch + ')</p>';
1544
1726
 
1727
+ if (task.publisher_version) {
1728
+ content += '<p><strong>IG Publisher:</strong> <code>' + escape(task.publisher_version) + '</code></p>';
1729
+ }
1730
+
1545
1731
  if (task.local_folder) {
1546
1732
  content += '<p><strong>Local Folder:</strong> <code>' + task.local_folder + '</code></p>';
1547
1733
  }