fhirsmith 0.13.4 → 0.14.0

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +16 -3
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +22 -3
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/operation-outcome.js +3 -1
  31. package/tx/library/renderer.js +42 -5
  32. package/tx/operation-context.js +80 -17
  33. package/tx/provider.js +85 -0
  34. package/tx/sct/expressions.js +128 -44
  35. package/tx/tx-html.js +426 -44
  36. package/tx/tx.js +117 -9
  37. package/tx/workers/cache-control.js +2 -2
  38. package/tx/workers/closure.js +483 -13
  39. package/tx/workers/metadata.js +8 -3
  40. package/tx/workers/read.js +18 -3
  41. package/tx/workers/subsumes.js +18 -2
  42. package/tx/workers/translate.js +167 -20
  43. package/tx/workers/validate.js +61 -182
  44. package/tx/workers/worker.js +9 -0
  45. package/tx/xversion/xv-conceptmap.js +17 -8
  46. package/utilities/submit-test-report.js +150 -0
@@ -0,0 +1,195 @@
1
+ // Tagging an IG source repository and publishing a GitHub release for it, at the end of a
2
+ // successful publication run.
3
+ //
4
+ // Authentication is a GitHub App rather than a personal access token. The reason is scope: the
5
+ // publisher only ever publishes from the HL7 and FHIR organisations, and an App installed on
6
+ // exactly those two - with Contents: Read and write, which covers both tags and releases - can be
7
+ // revoked, audited and attributed on its own. A fine-grained PAT cannot span two organisations
8
+ // (each is limited to a single resource owner), and a classic PAT would act as whoever minted it.
9
+ //
10
+ // The GitHub CLI is deliberately not used. gh cannot authenticate as an App - it only accepts
11
+ // access tokens - so an installation token has to be minted here regardless, and once that code
12
+ // exists the three remaining REST calls are less machinery than requiring gh to be installed and
13
+ // kept current on the server. Nothing in this file needs the database, which also keeps it
14
+ // testable without sqlite3.
15
+
16
+ const crypto = require('crypto');
17
+ const fs = require('fs');
18
+ const axios = require('axios');
19
+
20
+ const API_ROOT = 'https://api.github.com';
21
+ const API_VERSION = '2022-11-28';
22
+
23
+ // GitHub rejects an App JWT whose exp is more than 10 minutes out, measured against ITS clock, so
24
+ // a token minted right on the 10 minute mark fails whenever this server's clock runs slow. Backdate
25
+ // iat by a minute against skew the other way, and take 8 minutes rather than the full 10 - the
26
+ // token is spent within seconds of being minted, so the headroom costs nothing.
27
+ const JWT_BACKDATE_SECONDS = 60;
28
+ const JWT_LIFETIME_SECONDS = 480;
29
+
30
+ function isConfigured(cfg) {
31
+ return !!(cfg && cfg['app-id'] && cfg['private-key'] && cfg.installations);
32
+ }
33
+
34
+ // task.github_org carries whatever case the requester typed, and GitHub org names are not
35
+ // case-sensitive, so the config's spelling should not have to match it.
36
+ function installationIdFor(cfg, org) {
37
+ const map = (cfg && cfg.installations) || {};
38
+ const wanted = String(org || '').toLowerCase();
39
+ const key = Object.keys(map).find((k) => k.toLowerCase() === wanted);
40
+ return key ? map[key] : null;
41
+ }
42
+
43
+ function base64url(input) {
44
+ return Buffer.from(input).toString('base64')
45
+ .replace(/\+/g, '-')
46
+ .replace(/\//g, '_')
47
+ .replace(/=+$/, '');
48
+ }
49
+
50
+ function appJwt(appId, privateKeyPem, nowSeconds) {
51
+ const now = nowSeconds || Math.floor(Date.now() / 1000);
52
+ const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
53
+ const payload = base64url(JSON.stringify({
54
+ iat: now - JWT_BACKDATE_SECONDS,
55
+ exp: now + JWT_LIFETIME_SECONDS,
56
+ iss: String(appId)
57
+ }));
58
+ const signer = crypto.createSign('RSA-SHA256');
59
+ signer.update(header + '.' + payload);
60
+ signer.end();
61
+ return header + '.' + payload + '.' + base64url(signer.sign(privateKeyPem));
62
+ }
63
+
64
+ function apiHeaders(authorization) {
65
+ return {
66
+ 'Authorization': authorization,
67
+ 'Accept': 'application/vnd.github+json',
68
+ 'X-GitHub-Api-Version': API_VERSION,
69
+ 'User-Agent': 'FHIRsmith-publisher'
70
+ };
71
+ }
72
+
73
+ async function installationToken(cfg, org, http) {
74
+ const installationId = installationIdFor(cfg, org);
75
+ if (!installationId) {
76
+ throw new Error('No GitHub App installation is configured for the organisation ' + org);
77
+ }
78
+ const pem = fs.readFileSync(cfg['private-key'], 'utf8');
79
+ const jwt = appJwt(cfg['app-id'], pem);
80
+ const res = await http.post(
81
+ API_ROOT + '/app/installations/' + installationId + '/access_tokens',
82
+ {},
83
+ { headers: apiHeaders('Bearer ' + jwt) });
84
+ if (!res.data || !res.data.token) {
85
+ throw new Error('GitHub returned no installation token for organisation ' + org);
86
+ }
87
+ return res.data.token;
88
+ }
89
+
90
+ // Create the tag if it is not already there. A re-publication of the same version finds it
91
+ // present: the tag is left alone (it is a public ref others may have resolved), but if it points
92
+ // somewhere other than what was just published, say so - a release attached to a commit that is
93
+ // not the published one is exactly the sort of thing nobody notices for a year.
94
+ async function ensureTag(repoUrl, tag, sha, headers, http, log) {
95
+ const existing = await http.get(repoUrl + '/git/ref/' + encodeURIComponent('tags/' + tag),
96
+ { headers, validateStatus: (s) => s === 200 || s === 404 });
97
+ if (existing.status === 200) {
98
+ const existingSha = existing.data && existing.data.object && existing.data.object.sha;
99
+ if (existingSha && existingSha !== sha) {
100
+ log('warn', 'Tag ' + tag + ' already exists and points at ' + existingSha.substring(0, 8) +
101
+ ', not the commit just published (' + sha.substring(0, 8) + '). Leaving the tag as it is.');
102
+ } else {
103
+ log('info', 'Tag ' + tag + ' already exists and is correct - leaving it alone');
104
+ }
105
+ return false;
106
+ }
107
+ await http.post(repoUrl + '/git/refs', { ref: 'refs/tags/' + tag, sha: sha }, { headers });
108
+ log('info', 'Created tag ' + tag + ' at ' + sha.substring(0, 8));
109
+ return true;
110
+ }
111
+
112
+ // A re-publication should end up with the current build's zip attached, so an existing release for
113
+ // this tag is removed and rebuilt. The tag itself survives that - deleting a release does not
114
+ // delete its ref.
115
+ async function replaceExistingRelease(repoUrl, tag, headers, http, log) {
116
+ const existing = await http.get(repoUrl + '/releases/tags/' + encodeURIComponent(tag),
117
+ { headers, validateStatus: (s) => s === 200 || s === 404 });
118
+ if (existing.status !== 200) {
119
+ return false;
120
+ }
121
+ await http.delete(repoUrl + '/releases/' + existing.data.id, { headers });
122
+ log('info', 'Removed the previous release for ' + tag + ' so it can be replaced');
123
+ return true;
124
+ }
125
+
126
+ async function uploadAsset(uploadUrlTemplate, assetName, zipPath, headers, http) {
127
+ // upload_url comes back as an RFC 6570 template, e.g. https://…/assets{?name,label}
128
+ const base = uploadUrlTemplate.replace(/\{[^}]*\}$/, '');
129
+ const url = base + '?name=' + encodeURIComponent(assetName);
130
+ const body = fs.readFileSync(zipPath);
131
+ const res = await http.post(url, body, {
132
+ headers: Object.assign({}, headers, {
133
+ 'Content-Type': 'application/zip',
134
+ 'Content-Length': body.length
135
+ }),
136
+ maxBodyLength: Infinity,
137
+ maxContentLength: Infinity
138
+ });
139
+ return res.data;
140
+ }
141
+
142
+ /**
143
+ * Tag the source repository and publish a release carrying the source zip.
144
+ *
145
+ * Resolves with a summary; throws on failure. Callers are expected to treat a failure as a
146
+ * warning: by the time this runs the IG itself is already published.
147
+ */
148
+ async function publishRelease(opts) {
149
+ const {
150
+ config, org, repo, tag, sha, releaseName, body, zipPath, assetName
151
+ } = opts;
152
+ const http = opts.http || axios;
153
+ const log = opts.log || function () {};
154
+
155
+ if (!isConfigured(config)) {
156
+ throw new Error('GitHub App is not configured');
157
+ }
158
+ if (!fs.existsSync(zipPath)) {
159
+ throw new Error('Source zip not found at ' + zipPath);
160
+ }
161
+
162
+ const token = await installationToken(config, org, http);
163
+ const headers = apiHeaders('Bearer ' + token);
164
+ const repoUrl = API_ROOT + '/repos/' + org + '/' + repo;
165
+
166
+ const tagCreated = await ensureTag(repoUrl, tag, sha, headers, http, log);
167
+ const releaseReplaced = await replaceExistingRelease(repoUrl, tag, headers, http, log);
168
+
169
+ const created = await http.post(repoUrl + '/releases', {
170
+ tag_name: tag,
171
+ name: releaseName,
172
+ body: body || '',
173
+ draft: false,
174
+ prerelease: false
175
+ }, { headers });
176
+
177
+ await uploadAsset(created.data.upload_url, assetName, zipPath, headers, http);
178
+
179
+ log('info', (releaseReplaced ? 'Replaced' : 'Created') + ' release ' + tag + ' on ' + org + '/' +
180
+ repo + ' with ' + assetName + ' attached: ' + created.data.html_url);
181
+
182
+ return {
183
+ tagCreated: tagCreated,
184
+ releaseReplaced: releaseReplaced,
185
+ url: created.data.html_url
186
+ };
187
+ }
188
+
189
+ module.exports = {
190
+ isConfigured,
191
+ installationIdFor,
192
+ appJwt,
193
+ installationToken,
194
+ publishRelease
195
+ };
@@ -2,6 +2,8 @@ const express = require('express');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
4
  const validation = require('./validation');
5
+ const gitRemote = require('./git-remote');
6
+ const githubRelease = require('./github-release');
5
7
  const Database = require('sqlite3').Database;
6
8
  const bcrypt = require('bcrypt');
7
9
  const session = require('express-session');
@@ -150,6 +152,7 @@ class PublisherModule {
150
152
  build_output_path TEXT,
151
153
  failure_reason TEXT,
152
154
  announcement TEXT,
155
+ release_url TEXT,
153
156
  approved_by INTEGER,
154
157
  queued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
155
158
  building_at DATETIME,
@@ -235,6 +238,17 @@ class PublisherModule {
235
238
  });
236
239
  });
237
240
  }
241
+ if (!columnNames.includes('release_url')) {
242
+ await new Promise((resolve, reject) => {
243
+ this.db.run('ALTER TABLE tasks ADD COLUMN release_url TEXT', (err) => {
244
+ if (err) reject(err);
245
+ else {
246
+ this.logger.info('Migration: added release_url column to tasks table');
247
+ resolve();
248
+ }
249
+ });
250
+ });
251
+ }
238
252
  const websiteColumns = await new Promise((resolve, reject) => {
239
253
  this.db.all("PRAGMA table_info(websites)", (err, rows) => {
240
254
  if (err) reject(err);
@@ -722,7 +736,7 @@ class PublisherModule {
722
736
 
723
737
  async cloneRepository(task, draftDir) {
724
738
  const { spawn } = require('child_process');
725
- const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
739
+ const gitUrl = gitRemote.githubUrl(task.github_org, task.github_repo);
726
740
 
727
741
  await this.logTaskMessage(task.id, 'info', 'Cloning repository: ' + gitUrl + ' (branch: ' + task.git_branch + ')');
728
742
 
@@ -734,7 +748,10 @@ class PublisherModule {
734
748
  gitUrl,
735
749
  draftDir
736
750
  ], {
737
- stdio: ['pipe', 'pipe', 'pipe']
751
+ stdio: ['pipe', 'pipe', 'pipe'],
752
+ // no terminal prompt: a missing repo looks like a private one to GitHub, and
753
+ // git would otherwise ask for a username (see git-remote.js)
754
+ env: gitRemote.gitEnv()
738
755
  });
739
756
 
740
757
  let stderr = '';
@@ -748,7 +765,10 @@ class PublisherModule {
748
765
  await this.logTaskMessage(task.id, 'info', 'Repository cloned successfully');
749
766
  resolve();
750
767
  } else {
751
- const error = 'Git clone failed with code ' + code + ': ' + stderr;
768
+ const explained = gitRemote.explainCloneFailure(stderr, task.github_org, task.github_repo, task.git_branch);
769
+ const error = explained
770
+ ? 'Git clone failed: ' + explained
771
+ : 'Git clone failed with code ' + code + ': ' + stderr;
752
772
  await this.logTaskMessage(task.id, 'error', error);
753
773
  reject(new Error(error));
754
774
  }
@@ -868,6 +888,28 @@ class PublisherModule {
868
888
  errors.push('Version mismatch: task specifies "' + task.version + '" but build produced "' + qaData['ig-ver'] + '"');
869
889
  }
870
890
 
891
+ // publication-request.json is what the publication run actually follows - it decides the
892
+ // version folder the IG is published into. If it wasn't updated along with the IG, the run
893
+ // publishes the new content over the previous release's folder, so catch it here, before the
894
+ // task can be approved.
895
+ const prPath = path.join(draftDir, 'publication-request.json');
896
+ if (fs.existsSync(prPath)) {
897
+ let pr = null;
898
+ try {
899
+ pr = JSON.parse(fs.readFileSync(prPath, 'utf8'));
900
+ } catch (e) {
901
+ errors.push('publication-request.json could not be parsed: ' + e.message);
902
+ }
903
+ if (pr) {
904
+ if (pr['package-id'] !== task.npm_package_id) {
905
+ errors.push('publication-request.json package-id is "' + pr['package-id'] + '" but the task specifies "' + task.npm_package_id + '"');
906
+ }
907
+ if (pr.version !== task.version) {
908
+ errors.push('publication-request.json version is "' + pr.version + '" but the task specifies "' + task.version + '" - update publication-request.json in the IG');
909
+ }
910
+ }
911
+ }
912
+
871
913
  if (errors.length > 0) {
872
914
  for (const err of errors) {
873
915
  await this.logTaskMessage(task.id, 'error', err);
@@ -950,10 +992,14 @@ class PublisherModule {
950
992
  if (typeof json.url === 'string' && json.url.startsWith('file:')) {
951
993
  problems.push('url is a local file path (' + json.url + ')');
952
994
  }
995
+ if (json.version !== task.version) {
996
+ problems.push('its version is ' + json.version + ', not ' + task.version);
997
+ }
953
998
  if (problems.length > 0) {
954
- throw new Error('Published package ' + pkgPath + ' is a draft build, not a publication build (' +
955
- problems.join('; ') + '). The IG Publisher publication run likely skipped package ' +
956
- 'regeneration (e.g. a Jekyll/template failure). Not committing.');
999
+ throw new Error('Published package ' + pkgPath + ' is not the publication build of ' +
1000
+ task.npm_package_id + '#' + task.version + ' (' + problems.join('; ') + '). ' +
1001
+ 'Either the IG Publisher publication run skipped package regeneration (e.g. a Jekyll/template ' +
1002
+ 'failure), or publication-request.json points at another version. Not committing.');
957
1003
  }
958
1004
  await this.logTaskMessage(task.id, 'info', 'Verified publication package: ' + pkgPath);
959
1005
  }
@@ -1203,15 +1249,24 @@ class PublisherModule {
1203
1249
  // Step 7: Commit and push the ig-registry
1204
1250
  await this.logTaskMessage(task.id, 'info', 'Committing changes to ig-registry...');
1205
1251
  const registryCommitMsg = 'publish ' + task.npm_package_id + '#' + task.version;
1206
- await this.runCommand('git', ['commit', '-a', '-m', registryCommitMsg], { cwd: registryDir }, task.id, 'Committing ig-registry changes');
1207
- await this.runCommand('git', ['pull'], { cwd: registryDir }, task.id, 'Pulling latest ig-registry');
1208
- await this.runCommand('git', ['push'], { cwd: registryDir }, task.id, 'Pushing ig-registry changes');
1252
+ // A republication of a version the registry already lists (a technical correction) leaves the
1253
+ // registry as it was, and 'git commit' fails on a clean tree - which is not an error here.
1254
+ const registryStatus = await this.runCommand('git', ['status', '--porcelain', '--untracked-files=no'],
1255
+ { cwd: registryDir }, task.id, 'Checking for ig-registry changes');
1256
+ if (registryStatus.trim() === '') {
1257
+ await this.logTaskMessage(task.id, 'warn', 'The publication run made no changes to the ig-registry - nothing to commit');
1258
+ } else {
1259
+ await this.runCommand('git', ['commit', '-a', '-m', registryCommitMsg], { cwd: registryDir }, task.id, 'Committing ig-registry changes');
1260
+ await this.runCommand('git', ['pull'], { cwd: registryDir }, task.id, 'Pulling latest ig-registry');
1261
+ await this.runCommand('git', ['push'], { cwd: registryDir }, task.id, 'Pushing ig-registry changes');
1262
+ }
1209
1263
 
1210
1264
  // Step 8: Read the announcement text and store it in the database
1265
+ let announcement = null;
1211
1266
  const announcementPath = path.join(zipsDir, task.npm_package_id + '#' + task.version + '-announcement.txt');
1212
1267
  if (fs.existsSync(announcementPath)) {
1213
1268
  try {
1214
- const announcement = fs.readFileSync(announcementPath, 'utf8');
1269
+ announcement = fs.readFileSync(announcementPath, 'utf8');
1215
1270
  await this.updateTaskFields(task.id, { announcement: announcement });
1216
1271
  await this.logTaskMessage(task.id, 'info', 'Announcement text saved (' + announcement.length + ' chars)');
1217
1272
  } catch (err) {
@@ -1221,6 +1276,9 @@ class PublisherModule {
1221
1276
  await this.logTaskMessage(task.id, 'warn', 'No announcement file found at ' + announcementPath);
1222
1277
  }
1223
1278
 
1279
+ // Step 8b: Tag the source repository and publish a GitHub release carrying its source zip
1280
+ await this.createSourceRelease(task, draftDir, zipsDir, announcement);
1281
+
1224
1282
  // Step 9: Run the website update script
1225
1283
  if (website.server_update_script) {
1226
1284
  await this.logTaskMessage(task.id, 'info', 'Running website update script: ' + website.server_update_script);
@@ -1228,6 +1286,60 @@ class PublisherModule {
1228
1286
  }
1229
1287
  }
1230
1288
 
1289
+ // Tag the IG source repository and publish a GitHub release with a zip of the source at the
1290
+ // commit that was published. Runs only when a GitHub App is configured; without one there is no
1291
+ // credential that can write to the source repository, and the step is skipped.
1292
+ //
1293
+ // Nothing here is allowed to fail the task. By the time this runs the IG is published, the web
1294
+ // folder is pushed and the registry is updated - a release that did not get made is worth a
1295
+ // warning in the log and nothing more.
1296
+ async createSourceRelease(task, draftDir, zipsDir, announcement) {
1297
+ const appConfig = this.config && this.config['github-app'];
1298
+ if (!githubRelease.isConfigured(appConfig)) {
1299
+ await this.logTaskMessage(task.id, 'info',
1300
+ 'No GitHub App configured - skipping the source tag and release');
1301
+ return;
1302
+ }
1303
+
1304
+ try {
1305
+ // The draft clone is the exact tree that was published, so its HEAD is the commit the
1306
+ // release should point at.
1307
+ const sha = (await this.runCommand('git', ['rev-parse', 'HEAD'], { cwd: draftDir },
1308
+ task.id, 'Reading the published commit')).trim();
1309
+
1310
+ // git archive takes the committed state, not the working directory: by now the publisher has
1311
+ // built in this folder twice and left output/, temp/, template/ and fsh-generated/ behind,
1312
+ // none of which are in the archive. It is also exactly reproducible from the tag.
1313
+ const stem = task.npm_package_id + '-' + task.version;
1314
+ const assetName = stem + '-source.zip';
1315
+ const zipPath = path.join(zipsDir, assetName);
1316
+ await this.runCommand('git',
1317
+ ['archive', '--format=zip', '--prefix=' + stem + '/', '-o', zipPath, 'HEAD'],
1318
+ { cwd: draftDir }, task.id, 'Building the source zip');
1319
+
1320
+ const result = await githubRelease.publishRelease({
1321
+ config: appConfig,
1322
+ org: task.github_org,
1323
+ repo: task.github_repo,
1324
+ tag: 'v' + task.version,
1325
+ sha: sha,
1326
+ releaseName: task.npm_package_id + '#' + task.version,
1327
+ body: announcement || '',
1328
+ zipPath: zipPath,
1329
+ assetName: assetName,
1330
+ // logTaskMessage never rejects, and github-release logs without waiting
1331
+ log: (level, message) => { void this.logTaskMessage(task.id, level, message); }
1332
+ });
1333
+
1334
+ await this.updateTaskFields(task.id, { release_url: result.url });
1335
+ } catch (err) {
1336
+ const detail = (err.response && err.response.data && err.response.data.message) || err.message;
1337
+ await this.logTaskMessage(task.id, 'warn',
1338
+ 'Could not tag and release the source repository: ' + detail +
1339
+ '. The IG itself is published - this does not affect it.');
1340
+ }
1341
+ }
1342
+
1231
1343
  async runPublisherGoPublish(taskId, publisherJar, sourceDir, webDir, registryDir, historyDir, templatesDir, zipsDir, logFile) {
1232
1344
  const { spawn } = require('child_process');
1233
1345
 
@@ -1692,6 +1804,18 @@ class PublisherModule {
1692
1804
  return res.status(400).send('An active task for this package and version is already in progress. Wait for it to complete or fail before resubmitting.');
1693
1805
  }
1694
1806
 
1807
+ // Check the repo and branch actually exist before queueing, so a typo is
1808
+ // reported now rather than when the build starts. If GitHub can't be
1809
+ // reached, don't block - the clone will report any real problem.
1810
+ const remote = await gitRemote.checkRemoteBranch(github_org, github_repo, git_branch);
1811
+ if (!remote.ok) {
1812
+ if (remote.unreachable) {
1813
+ this.logger.warn('Could not verify ' + github_org + '/' + github_repo + '#' + git_branch + ' before queueing: ' + remote.error);
1814
+ } else {
1815
+ return res.status(400).send('Invalid task details: ' + remote.error);
1816
+ }
1817
+ }
1818
+
1695
1819
  // Insert task (ID will be auto-generated)
1696
1820
  const result = await new Promise((resolve, reject) => {
1697
1821
  this.db.run(
@@ -1908,6 +2032,9 @@ class PublisherModule {
1908
2032
  let content = '<h3>Task Output: #' + task.id + ' - ' + escape(task.npm_package_id) + '#' + escape(task.version) + '</h3>';
1909
2033
  content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + escape(task.status) + '</span></p>';
1910
2034
  content += '<p><strong>GitHub:</strong> ' + escape(task.github_org) + '/' + escape(task.github_repo) + ' (' + escape(task.git_branch) + ')</p>';
2035
+ if (task.release_url) {
2036
+ content += '<p><strong>Release:</strong> <a href="' + escape(task.release_url) + '" target="_blank" rel="noopener noreferrer">' + escape(task.release_url) + '</a></p>';
2037
+ }
1911
2038
 
1912
2039
  if (task.publisher_version) {
1913
2040
  content += '<p><strong>IG Publisher:</strong> <code>' + escape(task.publisher_version) + '</code></p>';
@@ -2053,6 +2180,9 @@ class PublisherModule {
2053
2180
  content += '</div>';
2054
2181
  content += '<div class="col-md-6">';
2055
2182
  content += '<p><strong>GitHub:</strong> ' + escape(task.github_org) + '/' + escape(task.github_repo) + ' (' + escape(task.git_branch) + ')</p>';
2183
+ if (task.release_url) {
2184
+ content += '<p><strong>Release:</strong> <a href="' + escape(task.release_url) + '" target="_blank" rel="noopener noreferrer">' + escape(task.release_url) + '</a></p>';
2185
+ }
2056
2186
  content += '<p><strong>Created by:</strong> ' + escape(task.user_name) + ' (' + escape(task.user_login) + ')</p>';
2057
2187
  if (task.approved_by_name) {
2058
2188
  content += '<p><strong>Approved by:</strong> ' + escape(task.approved_by_name) + '</p>';
@@ -2747,10 +2877,12 @@ class PublisherModule {
2747
2877
  await this.logTaskMessage(taskId, 'info', description);
2748
2878
 
2749
2879
  return new Promise((resolve, reject) => {
2750
- const proc = spawn(command, args, {
2751
- stdio: ['pipe', 'pipe', 'pipe'],
2752
- ...options
2753
- });
2880
+ const spawnOptions = { stdio: ['pipe', 'pipe', 'pipe'], ...options };
2881
+ if (command === 'git') {
2882
+ // never let git block on (or fail obscurely at) a credentials prompt
2883
+ spawnOptions.env = gitRemote.gitEnv(spawnOptions.env || process.env);
2884
+ }
2885
+ const proc = spawn(command, args, spawnOptions);
2754
2886
 
2755
2887
  let stdout = '';
2756
2888
  let stderr = '';
package/server.js CHANGED
@@ -16,6 +16,7 @@ const folders = require('./library/folder-setup'); // <-- ADD: load early
16
16
  const { statSync, readdirSync } = require('fs');
17
17
  const escape = require('escape-html');
18
18
  const { resolveWithin } = require('./library/path-safety');
19
+ const { readCgroupMemoryLimit } = require('./library/cgroup-memory');
19
20
 
20
21
  // Load configuration BEFORE logger
21
22
  let config;
@@ -39,6 +40,12 @@ serverLog.info(`========================================`);
39
40
  serverLog.info(`FHIRsmith v${packageJson.version} starting (PID ${process.pid})`);
40
41
  serverLog.info(`Node.js ${process.version} on ${os.type()} ${os.release()} (${os.arch()})`);
41
42
  serverLog.info(`Memory: ${freeMemGB} GB free / ${totalMemGB} GB total`);
43
+ {
44
+ const cg = readCgroupMemoryLimit();
45
+ serverLog.info(cg.limit > 0
46
+ ? `Memory limit: ${(cg.limit / 1024 / 1024 / 1024).toFixed(1)} GB (from ${cg.source})`
47
+ : 'Memory limit: none found in cgroup - RSS guard disabled');
48
+ }
42
49
  serverLog.info(`Data directory: ${folders.dataDir()}`);
43
50
  serverLog.info(`========================================`);
44
51
 
@@ -57,6 +64,7 @@ const PublisherModule = require('./publisher/publisher.js');
57
64
  const TokenModule = require('./token/token.js');
58
65
  const NpmProjectorModule = require('./npmprojector/npmprojector.js');
59
66
  const TXModule = require('./tx/tx.js');
67
+ const TestingModule = require('./testing/testing.js');
60
68
 
61
69
  const htmlServer = require('./library/html-server');
62
70
  const ServerStats = require("./stats");
@@ -219,6 +227,18 @@ async function initializeModules() {
219
227
  throw error;
220
228
  }
221
229
  }
230
+ if (config.modules?.testing?.enabled) {
231
+ try {
232
+ serverLog.info('Initializing module: testing...');
233
+ modules.testing = new TestingModule(stats.forModule('testing'));
234
+ await modules.testing.initialize(config.modules.testing);
235
+ app.use('/testing', modules.testing.router);
236
+ } catch (error) {
237
+ serverLog.error('Failed to initialize testing module:', error);
238
+ throw error;
239
+ }
240
+ }
241
+
222
242
  // Initialize TX module
223
243
  // Note: TX module registers its own endpoints directly on the app
224
244
  // because it supports multiple endpoints at different paths
@@ -346,6 +366,13 @@ async function buildRootPageContent() {
346
366
  content += '</li>';
347
367
  }
348
368
 
369
+ if (config.modules?.testing?.enabled) {
370
+ content += '<li class="list-group-item">';
371
+ content += '<a href="/testing" class="text-decoration-none">Test Reports</a>: ';
372
+ content += 'TestReports submitted by TxTester and other test tools';
373
+ content += '</li>';
374
+ }
375
+
349
376
  if (config.modules.folder && config.modules.folder.enabled) {
350
377
  content += '<li class="list-group-item">';
351
378
  content += '<strong>Cache Folder</strong>: ';
@@ -405,13 +432,7 @@ async function buildRootPageContent() {
405
432
 
406
433
  // Process RSS vs cgroup limit (or system total)
407
434
  const rssMB = (memUsage.rss / 1024 / 1024).toFixed(0);
408
- let memLimit;
409
- try {
410
- const raw = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
411
- memLimit = raw === 'max' ? os.totalmem() : parseInt(raw);
412
- } catch {
413
- memLimit = os.totalmem();
414
- }
435
+ const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
415
436
  const memLimitMB = (memLimit / 1024 / 1024).toFixed(0);
416
437
  const processPCT = (memUsage.rss * 100) / memLimit;
417
438
 
@@ -440,6 +461,15 @@ async function buildRootPageContent() {
440
461
  content += `<td><strong>Client Cache:</strong> ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts</td>`;
441
462
  content += `<td><strong>Max Client Cache:</strong> ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts</td>`;
442
463
  content += '</tr>';
464
+ const closure = stats.closureStats();
465
+ if (closure) {
466
+ content += '<tr>';
467
+ content += `<td><strong>Closure Tables:</strong> ${closure.tables} tables` +
468
+ (closure.retentionDays ? ` (unused for ${closure.retentionDays} days = deleted)` : '') + `</td>`;
469
+ content += `<td><strong>Closure Entries:</strong> ${closure.entries} entries / ${closure.concepts} concepts</td>`;
470
+ content += `<td><strong>Closure Database:</strong> ${closure.bytes === null ? 'in memory' : formatBytes(closure.bytes)}</td>`;
471
+ content += '</tr>';
472
+ }
443
473
  content += getLogStats();
444
474
  content += '</table>';
445
475
 
@@ -591,13 +621,7 @@ app.get('/dashboard', async (req, res) => {
591
621
  const v8PCT = (memUsage.heapUsed * 100) / heapStats.heap_size_limit; // % of V8 heap limit used
592
622
 
593
623
  // Process RSS as % of cgroup memory limit (or system total as fallback)
594
- let memLimit;
595
- try {
596
- const raw = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
597
- memLimit = raw === 'max' ? os.totalmem() : parseInt(raw);
598
- } catch {
599
- memLimit = os.totalmem();
600
- }
624
+ const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
601
625
  const processPCT = (memUsage.rss * 100) / memLimit;
602
626
 
603
627
  const totalMemBytes = os.totalmem();
@@ -649,6 +673,19 @@ app.get('/dashboard', async (req, res) => {
649
673
  }
650
674
  });
651
675
 
676
+ function formatBytes(bytes) {
677
+ if (bytes < 1024) {
678
+ return `${bytes} bytes`;
679
+ }
680
+ if (bytes < 1024 * 1024) {
681
+ return `${(bytes / 1024).toFixed(1)} KB`;
682
+ }
683
+ if (bytes < 1024 * 1024 * 1024) {
684
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
685
+ }
686
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
687
+ }
688
+
652
689
  function pctColor(pct) {
653
690
  // Gradient from green (#deffe0) at 0% to red (#ffd3d1) at 100%
654
691
  const t = Math.max(0, Math.min(100, pct)) / 100;
package/stats.js CHANGED
@@ -124,20 +124,13 @@ class ServerStats {
124
124
  // event-loop block into noise; the max is what shows request-freezing
125
125
  // stalls (see the 2026-07-30 tx.fhir.org incident).
126
126
  const loopMax = this.eventLoopMonitor.max / 1e6;
127
- // Two distinct caches: the expansion cache (count entries) and the client
128
- // (resource) cache (count concepts held - a sense of how much it's carrying).
129
- let expansionItems = 0;
130
- let clientConcepts = 0;
131
- for (let m of this.cachingModules) {
132
- if (typeof m.expansionItemCount === 'function') {
133
- expansionItems = expansionItems + m.expansionItemCount();
134
- }
135
- if (typeof m.clientConceptCount === 'function') {
136
- clientConcepts = clientConcepts + m.clientConceptCount();
137
- }
138
- }
127
+ // The client (resource) cache, as concepts held - a sense of how much it's carrying.
128
+ // (The expansion cache isn't tracked over time: it fills to its limit over a few
129
+ // days and then sits there, so a history of it says nothing. The home page still
130
+ // shows its current size.)
131
+ const clientConcepts = this._sumCachingModules('clientConceptCount');
139
132
 
140
- this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, blockMax: loopMax, expansion: expansionItems, clientConcepts: clientConcepts});
133
+ this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, blockMax: loopMax, clientConcepts: clientConcepts});
141
134
 
142
135
  this.eventLoopMonitor.reset();
143
136
  this.requestCountSnapshot = combinedCount;
@@ -328,6 +321,19 @@ class ServerStats {
328
321
  maxClientCaches() { return this._sumCachingModules('maxClientCacheCount'); }
329
322
  maxClientConcepts() { return this._sumCachingModules('maxClientConceptCount'); }
330
323
 
324
+ // $closure usage, from whichever module keeps closure tables; null if none does
325
+ closureStats() {
326
+ for (let m of this.cachingModules) {
327
+ if (typeof m.closureStats === 'function') {
328
+ const s = m.closureStats();
329
+ if (s) {
330
+ return s;
331
+ }
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+
331
337
  getTaskColor(status) {
332
338
  switch (status) {
333
339
  case "started": return "LightGrey";