backend-manager 5.10.1 → 5.10.3

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.
@@ -31,6 +31,12 @@ const { Octokit } = require('@octokit/rest');
31
31
  const REPO_OWNER = 'itw-creative-works';
32
32
  const REPO_NAME = 'newsletter-assets';
33
33
 
34
+ // When true, asset URLs use the parent CDN (cdn.{parentDomain}/newsletters/...)
35
+ // instead of raw.githubusercontent.com. Requires a Cloudflare redirect rule
36
+ // on the CDN domain that maps /newsletters/* to raw.githubusercontent.com.
37
+ // Set to false to revert to direct GitHub URLs.
38
+ const USE_CDN_URLS = true;
39
+
34
40
  const PAGES_BASE = `https://${REPO_OWNER}.github.io/${REPO_NAME}`;
35
41
 
36
42
  // Fallback RAW_BASE for callers that don't have a brand context
@@ -65,11 +71,14 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
65
71
  * @param {string} [args.subject] - Newsletter subject. Embedded in the commit message so
66
72
  * `git log` reads as a human-browseable history.
67
73
  * @param {string} [args.commitMessage] - Full override of the default commit message
74
+ * @param {string} [args.cdnBase] - CDN base URL (e.g. 'https://cdn.itwcreativeworks.com/newsletters').
75
+ * When provided and USE_CDN_URLS is true, asset URLs use this
76
+ * instead of raw.githubusercontent.com.
68
77
  * @param {string} [args.token] - GitHub token (defaults to process.env.GH_TOKEN)
69
78
  * @param {object} [args.assistant] - logger
70
79
  * @returns {Promise<{ urls: string[], paths: string[], htmlUrl?: string, htmlPath?: string, previewUrl?: string, folderUrl: string, commitSha: string }>}
71
80
  */
72
- async function uploadAssets({ images, html, markdown, summary, brandId, campaignId, subject, commitMessage, token, assistant }) {
81
+ async function uploadAssets({ images, html, markdown, summary, brandId, campaignId, subject, commitMessage, cdnBase, token, assistant }) {
73
82
  const hasImages = Array.isArray(images) && images.length > 0;
74
83
  const hasHtml = typeof html === 'string' && html.length > 0;
75
84
  const hasMarkdown = typeof markdown === 'string' && markdown.length > 0;
@@ -92,7 +101,11 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
92
101
 
93
102
  // Brand branch — each brand gets its own branch, zero cross-brand contention
94
103
  const branch = brandId;
95
- const rawBase = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${branch}`;
104
+ const gitRawBase = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${branch}`;
105
+
106
+ // CDN URLs: cdn.{parentDomain}/newsletters/{brandId}/content/{campaignId}/...
107
+ // The CDN redirect maps /newsletters/{brandId}/* → raw.githubusercontent.com/{brandId}/*
108
+ const assetBase = (USE_CDN_URLS && cdnBase) ? `${cdnBase}/${brandId}` : gitRawBase;
96
109
 
97
110
  // 1. Build the list of files to commit. Validate each before we touch GitHub.
98
111
  const files = [];
@@ -225,42 +238,55 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
225
238
  });
226
239
  }
227
240
 
228
- // 4. Build tree on top of the branch tip
229
- const { data: baseCommit } = await octokit.rest.git.getCommit({
230
- owner: REPO_OWNER,
231
- repo: REPO_NAME,
232
- commit_sha: branchSha,
233
- });
234
-
235
- const { data: newTree } = await octokit.rest.git.createTree({
236
- owner: REPO_OWNER,
237
- repo: REPO_NAME,
238
- base_tree: baseCommit.tree.sha,
239
- tree: treeItems,
240
- });
241
-
242
- // 5. Commit
241
+ // 4. Commit + push with retry. The newsletter pipeline calls uploadAssets
242
+ // twice sequentially (images then HTML). GitHub's API can return a stale
243
+ // ref on the second getRef if the first updateRef hasn't fully propagated.
244
+ // Retry with a fresh ref read + jitter handles this.
243
245
  const defaultSubject = subject ? subject.trim() : `${files.length} newsletter asset${files.length === 1 ? '' : 's'}`;
244
246
  const message = commitMessage || `[${brandId}] ${campaignId} — ${defaultSubject}`;
247
+ const MAX_RETRIES = 3;
248
+ let newCommit;
249
+
250
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
251
+ const { data: refData } = await octokit.rest.git.getRef({
252
+ owner: REPO_OWNER, repo: REPO_NAME, ref: `heads/${branch}`,
253
+ });
254
+ const tipSha = refData.object.sha;
255
+
256
+ const { data: baseCommit } = await octokit.rest.git.getCommit({
257
+ owner: REPO_OWNER, repo: REPO_NAME, commit_sha: tipSha,
258
+ });
259
+
260
+ const { data: newTree } = await octokit.rest.git.createTree({
261
+ owner: REPO_OWNER, repo: REPO_NAME,
262
+ base_tree: baseCommit.tree.sha,
263
+ tree: treeItems,
264
+ });
245
265
 
246
- const { data: newCommit } = await octokit.rest.git.createCommit({
247
- owner: REPO_OWNER,
248
- repo: REPO_NAME,
249
- message,
250
- tree: newTree.sha,
251
- parents: [branchSha],
252
- });
253
-
254
- // 6. Update brand branch ref. Since each brand has its own branch,
255
- // the only possible race is the SAME brand uploading images and HTML
256
- // at the same time — which is sequential in the newsletter pipeline.
257
- // No retry/jitter needed.
258
- await octokit.rest.git.updateRef({
259
- owner: REPO_OWNER,
260
- repo: REPO_NAME,
261
- ref: `heads/${branch}`,
262
- sha: newCommit.sha,
263
- });
266
+ newCommit = (await octokit.rest.git.createCommit({
267
+ owner: REPO_OWNER, repo: REPO_NAME,
268
+ message,
269
+ tree: newTree.sha,
270
+ parents: [tipSha],
271
+ })).data;
272
+
273
+ try {
274
+ await octokit.rest.git.updateRef({
275
+ owner: REPO_OWNER, repo: REPO_NAME,
276
+ ref: `heads/${branch}`,
277
+ sha: newCommit.sha,
278
+ });
279
+ break;
280
+ } catch (e) {
281
+ if (attempt < MAX_RETRIES && /fast.forward/i.test(e.message)) {
282
+ const jitter = 500 + Math.floor(Math.random() * 1500);
283
+ log(`updateRef race (attempt ${attempt}/${MAX_RETRIES}), retrying in ${jitter}ms...`);
284
+ await new Promise(r => setTimeout(r, jitter));
285
+ continue;
286
+ }
287
+ throw e;
288
+ }
289
+ }
264
290
 
265
291
  // 7. Split the URL list by kind so callers can grab each independently.
266
292
  const imageFiles = files.filter((f) => f.kind === 'image');
@@ -269,25 +295,25 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
269
295
  const summaryFile = files.find((f) => f.kind === 'summary');
270
296
 
271
297
  const result = {
272
- urls: imageFiles.map((f) => `${rawBase}/${f.path}`),
298
+ urls: imageFiles.map((f) => `${assetBase}/${f.path}`),
273
299
  paths: imageFiles.map((f) => f.path),
274
300
  folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${branch}/${CONTENT_DIR}/${campaignId}`,
275
301
  commitSha: newCommit.sha,
276
302
  };
277
303
 
278
304
  if (htmlFile) {
279
- result.htmlUrl = `${rawBase}/${htmlFile.path}`;
305
+ result.htmlUrl = `${assetBase}/${htmlFile.path}`;
280
306
  result.htmlPath = htmlFile.path;
281
307
  result.previewUrl = `${PAGES_BASE}/?path=${encodeURIComponent(htmlFile.path)}&branch=${encodeURIComponent(branch)}`;
282
308
  }
283
309
 
284
310
  if (markdownFile) {
285
- result.markdownUrl = `${rawBase}/${markdownFile.path}`;
311
+ result.markdownUrl = `${assetBase}/${markdownFile.path}`;
286
312
  result.markdownPath = markdownFile.path;
287
313
  }
288
314
 
289
315
  if (summaryFile) {
290
- result.summaryUrl = `${rawBase}/${summaryFile.path}`;
316
+ result.summaryUrl = `${assetBase}/${summaryFile.path}`;
291
317
  result.summaryPath = summaryFile.path;
292
318
  }
293
319
 
@@ -310,6 +336,7 @@ function validateCampaignId(campaignId) {
310
336
 
311
337
  module.exports = {
312
338
  uploadAssets,
339
+ USE_CDN_URLS,
313
340
  REPO_OWNER,
314
341
  REPO_NAME,
315
342
  RAW_BASE,