From 296faad0710c27263cd32eee4b0d81a6333fb9bd Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:55:49 -0300 Subject: [PATCH 1/3] fix(docs): cover npm add in v1 pin guard (#1126) Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 02cc77b458655080bfeb4863c0ef55bd8373a200 Ported-Public-Prefix: superdoc/public --- apps/docs/scripts/validate-v1-package-pins.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/scripts/validate-v1-package-pins.test.mjs b/apps/docs/scripts/validate-v1-package-pins.test.mjs index d493bae9b5..62da52c279 100644 --- a/apps/docs/scripts/validate-v1-package-pins.test.mjs +++ b/apps/docs/scripts/validate-v1-package-pins.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; const docsRoot = new URL('../', import.meta.url); const scannedExtensions = new Set(['.jsx', '.mdx', '.txt']); -const installCommand = /(?:npm (?:install|i)|pnpm add|bun add|yarn add)\s+([^\n]+)/gu; +const installCommand = /(?:npm (?:install|i|add)|pnpm add|bun add|yarn add)\s+([^\n]+)/gu; const staleNextTag = /(?:superdoc|@superdoc-dev\/react)@next\b/u; const wrongV1AssetPath = /superdoc@1\/dist-cdn\b/u; const browserPackageUrl = @@ -40,6 +40,7 @@ test('the guard rejects unpinned installs from supported package managers', () = const unsafeExamples = [ 'npm install superdoc', 'npm i superdoc', + 'npm add superdoc', 'pnpm add superdoc', 'bun add superdoc', 'yarn add superdoc', From 2757c3f6d121a8bd1dc862c66b129fb9f3f24061 Mon Sep 17 00:00:00 2001 From: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:33:29 +0300 Subject: [PATCH 2/3] fix: v1 w:ins/w:del lose tracking when a run begins with w:noBreakHyphen (#1139) Co-authored-by: Artem Nistuley Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: d91ef133c6e77551d777df5007250be7d5e846c0 Ported-Public-Prefix: superdoc/public --- .../w/del/del-translator.integration.test.js | 55 ++++++++++++++ .../v3/handlers/w/del/del-translator.js | 13 +--- .../v3/handlers/w/del/del-translator.test.js | 58 ++++++++++++++ .../w/ins/ins-translator.integration.test.js | 71 ++++++++++++++++++ .../v3/handlers/w/ins/ins-translator.js | 13 +--- .../v3/handlers/w/ins/ins-translator.test.js | 58 ++++++++++++++ .../w/r/helpers/track-change-helpers.js | 32 ++++++++ .../w/r/helpers/track-change-helpers.test.js | 60 ++++++++++++++- .../src/editors/v1/tests/data/.gitignore | 2 + .../tracked-delete-nobreakhyphen.docx | Bin 0 -> 16910 bytes .../tracked-insert-nobreakhyphen.docx | Bin 0 -> 6032 bytes 11 files changed, 339 insertions(+), 23 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.integration.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js create mode 100644 packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-delete-nobreakhyphen.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-insert-nobreakhyphen.docx diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.integration.test.js new file mode 100644 index 0000000000..ef8e49e625 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.integration.test.js @@ -0,0 +1,55 @@ +// Full importer-pipeline regression test mirroring ins-translator.integration.test.js +// for the w:del side of the same bug class (trackDelete only applied to +// content[0] when it was text). The fixture is derived from +// tests/data/behavior-fixtures/tracked-insert-nobreakhyphen.docx by converting +// its two noBreakHyphen-leading tracked-insert runs (paragraph 16, w:id 2/3) +// into tracked-delete runs (/ per OOXML CT_RunTrackChange) — +// the original repro docx contains no w:del content of its own. +import { describe, it, expect, afterEach } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +const findParagraphNode = (docJson, needle) => + (docJson.content || []).find((node) => node.type === 'paragraph' && JSON.stringify(node).includes(needle)); + +const flattenInlineContent = (paragraphNode) => + (paragraphNode.content || []).flatMap((runNode) => runNode.content || []); + +const hasTrackDeleteMark = (node) => (node.marks || []).some((mark) => mark.type === 'trackDelete'); + +describe('w:del importer-pipeline integration: run beginning with w:noBreakHyphen', () => { + let editor; + + afterEach(() => { + if (editor) { + editor.destroy(); + editor = null; + } + }); + + it('imports the noBreakHyphen atom and the text following it as one tracked deletion (paragraph 16)', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-delete-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const docJson = editor.getJSON(); + const paragraph16 = findParagraphNode(docJson, 'Notwithstanding any other provision'); + expect(paragraph16).toBeTruthy(); + + const inlineNodes = flattenInlineContent(paragraph16); + // Only w:id 2/3 were converted to in this fixture (see the header + // comment); w:id 1 stays a plain tracked insertion, so scope assertions to + // the two converted runs' known text rather than every node in the paragraph. + const deletedTextNodes = inlineNodes.filter( + (node) => node.type === 'text' && (node.text.includes('tangible form') || node.text.includes('unaided memories')), + ); + expect(deletedTextNodes.length).toBe(2); + + const noBreakHyphenNodes = inlineNodes.filter((node) => node.type === 'noBreakHyphen'); + expect(noBreakHyphenNodes.length).toBe(2); + + [...noBreakHyphenNodes, ...deletedTextNodes].forEach((node) => { + expect(hasTrackDeleteMark(node)).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index 888ffc46ba..88a121fedd 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -7,6 +7,7 @@ import { stampImportTrackingAttrs, withParentFrame, } from '../../../../v2/importer/importTrackingContext.js'; +import { applyTrackedMarkToRunContent } from '../r/helpers/track-change-helpers.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:del'; @@ -64,17 +65,7 @@ const encode = (params, encodedAttrs = {}) => { encodedAttrs.origin = converter.documentOrigin; } - subs.forEach((subElement) => { - subElement.marks = []; - if (subElement?.content?.[0]) { - if (subElement.content[0].marks === undefined) { - subElement.content[0].marks = []; - } - if (subElement.content[0].type === 'text') { - subElement.content[0].marks.push({ type: 'trackDelete', attrs: encodedAttrs }); - } - } - }); + applyTrackedMarkToRunContent(subs, 'trackDelete', encodedAttrs); return subs; }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index 643fc19441..d9fc2d20db 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -80,6 +80,64 @@ describe('w:del translator', () => { expect(getMarkAttrs(result)).toEqual(expect.objectContaining({ id: '123', sourceId: '123' })); }); + it('marks a leading non-text atom (e.g. noBreakHyphen) and the text that follows it', () => { + const mockSubNodes = [ + { + content: [{ type: 'noBreakHyphen' }, { type: 'text', text: 'text' }], + }, + ]; + const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(result[0].content[0].marks).toEqual([ + { type: 'trackDelete', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + expect(result[0].content[1].marks).toEqual([ + { type: 'trackDelete', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + }); + + it('does not mark a non-whitelisted content child but still marks trailing text', () => { + const mockSubNodes = [ + { + content: [{ type: 'tab' }, { type: 'text', text: 'text' }], + }, + ]; + const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(result[0].content[0].marks).toBeUndefined(); + expect(result[0].content[1].marks).toEqual([ + { type: 'trackDelete', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + }); + it('remaps id via trackedChangeIdMap and preserves sourceId', () => { const converter = { trackedChangeIdMap: new Map([['123', 'shared-uuid-abc']]), diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js new file mode 100644 index 0000000000..cbfbf7f8eb --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js @@ -0,0 +1,71 @@ +// Full importer-pipeline regression test for the "V1 w:ins loses tracking when +// a run begins with w:noBreakHyphen" bug. Goes through the real Editor import +// path (Editor.loadXmlData + new Editor(...)), not just a direct translator +// call, using a docx copied from plans/repro_tracked_insert_nbh.docx (that +// path is gitignored, so a tracked copy lives under tests/data/behavior-fixtures/). +import { describe, it, expect, afterEach } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +const findParagraphNode = (docJson, needle) => + (docJson.content || []).find((node) => node.type === 'paragraph' && JSON.stringify(node).includes(needle)); + +const flattenInlineContent = (paragraphNode) => + (paragraphNode.content || []).flatMap((runNode) => runNode.content || []); + +const hasTrackInsertMark = (node) => (node.marks || []).some((mark) => mark.type === 'trackInsert'); + +describe('w:ins importer-pipeline integration: run beginning with w:noBreakHyphen', () => { + let editor; + + afterEach(() => { + if (editor) { + editor.destroy(); + editor = null; + } + }); + + it('imports the noBreakHyphen atom and the text following it as one tracked insertion (paragraph 16)', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-insert-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const docJson = editor.getJSON(); + const paragraph16 = findParagraphNode(docJson, 'Notwithstanding any other provision'); + expect(paragraph16).toBeTruthy(); + + const inlineNodes = flattenInlineContent(paragraph16); + const noBreakHyphenNodes = inlineNodes.filter((node) => node.type === 'noBreakHyphen'); + expect(noBreakHyphenNodes.length).toBeGreaterThan(0); + + // Every noBreakHyphen atom in this tracked paragraph must carry trackInsert. + noBreakHyphenNodes.forEach((node) => { + expect(hasTrackInsertMark(node)).toBe(true); + }); + + // And so must every text node in the paragraph, including the text that + // follows a noBreakHyphen atom within the same run. + const textNodes = inlineNodes.filter((node) => node.type === 'text'); + expect(textNodes.length).toBeGreaterThan(0); + textNodes.forEach((node) => { + expect(hasTrackInsertMark(node)).toBe(true); + }); + }); + + it('does not regress the control paragraph using plain hyphen-minus characters (paragraph 16B)', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-insert-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const docJson = editor.getJSON(); + const paragraph16B = findParagraphNode(docJson, 'control paragraph'); + expect(paragraph16B).toBeTruthy(); + + const textNodes = flattenInlineContent(paragraph16B).filter((node) => node.type === 'text'); + expect(textNodes.length).toBeGreaterThan(0); + textNodes.forEach((node) => { + expect(hasTrackInsertMark(node)).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index 8d10c3805d..b4a020eba1 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -7,6 +7,7 @@ import { stampImportTrackingAttrs, withParentFrame, } from '../../../../v2/importer/importTrackingContext.js'; +import { applyTrackedMarkToRunContent } from '../r/helpers/track-change-helpers.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:ins'; @@ -63,17 +64,7 @@ const encode = (params, encodedAttrs = {}) => { encodedAttrs.origin = converter.documentOrigin; } - subs.forEach((subElement) => { - subElement.marks = []; - if (subElement?.content?.[0]) { - if (subElement.content[0].marks === undefined) { - subElement.content[0].marks = []; - } - if (subElement.content[0].type === 'text') { - subElement.content[0].marks.push({ type: 'trackInsert', attrs: encodedAttrs }); - } - } - }); + applyTrackedMarkToRunContent(subs, 'trackInsert', encodedAttrs); return subs; }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js index 766a2c1de2..e1ac8110ad 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js @@ -82,6 +82,64 @@ describe('w:ins translator', () => { ]); }); + it('marks a leading non-text atom (e.g. noBreakHyphen) and the text that follows it', () => { + const mockSubNodes = [ + { + content: [{ type: 'noBreakHyphen' }, { type: 'text', text: 'text' }], + }, + ]; + const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(result[0].content[0].marks).toEqual([ + { type: 'trackInsert', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + expect(result[0].content[1].marks).toEqual([ + { type: 'trackInsert', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + }); + + it('does not mark a non-whitelisted content child but still marks trailing text', () => { + const mockSubNodes = [ + { + content: [{ type: 'tab' }, { type: 'text', text: 'text' }], + }, + ]; + const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(result[0].content[0].marks).toBeUndefined(); + expect(result[0].content[1].marks).toEqual([ + { type: 'trackInsert', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + }); + it('preserves the original Word ID as sourceId when no map exists', () => { const { result } = encodeWith(); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js index 036b869bad..2c8a340062 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js @@ -63,6 +63,38 @@ export const prepareRunTrackingContext = (node = {}) => { }; }; +/** + * Content child types whose decode() path is confirmed to consult + * trackInsert/trackDelete marks on export (see no-break-hyphen-translator.js + * and t-translator.js). Widening this set requires adding the matching + * decode-side branch to that content type's own translator first — otherwise + * export would silently drop the tracked-change metadata. + */ +export const TRACKABLE_RUN_CONTENT_TYPES = new Set(['text', 'noBreakHyphen']); + +/** + * Stamp a tracked-change mark (trackInsert/trackDelete) onto every trackable + * content child of each encoded run, not just the first. A run imported from + * ``/`` may begin with an inline atom (e.g. ``) + * before its text — marking only content[0] when it happens to be text drops + * tracking for that atom and for any content after it. + * + * @param {Array<{ content?: Array> }>} subElements + * @param {string} markType + * @param {Record} attrs + */ +export const applyTrackedMarkToRunContent = (subElements = [], markType, attrs) => { + subElements.forEach((subElement) => { + subElement.marks = []; + const content = Array.isArray(subElement?.content) ? subElement.content : []; + content.forEach((child) => { + if (!child || !TRACKABLE_RUN_CONTENT_TYPES.has(child.type)) return; + if (child.marks === undefined) child.marks = []; + child.marks.push({ type: markType, attrs }); + }); + }); +}; + const mapTrackingAttrs = (mark, attrMap) => { const source = mark?.attrs || {}; const mapped = {}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js index ea96fb5c6b..5b0595aeb6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest'; import { TrackInsertMarkName, TrackDeleteMarkName } from '@extensions/track-changes/constants.js'; -import { ensureTrackedWrapper, prepareRunTrackingContext } from './track-change-helpers.js'; +import { + ensureTrackedWrapper, + prepareRunTrackingContext, + applyTrackedMarkToRunContent, + TRACKABLE_RUN_CONTENT_TYPES, +} from './track-change-helpers.js'; describe('track-change-helpers', () => { describe('prepareRunTrackingContext', () => { @@ -142,4 +147,57 @@ describe('track-change-helpers', () => { expect(result).toEqual([]); }); }); + + describe('applyTrackedMarkToRunContent', () => { + const attrs = { id: '123', author: 'Test' }; + + it('marks every trackable content child, not just the first', () => { + const subs = [ + { + content: [{ type: 'noBreakHyphen' }, { type: 'text', text: 'added text' }], + }, + ]; + + applyTrackedMarkToRunContent(subs, 'trackInsert', attrs); + + expect(subs[0].marks).toEqual([]); + expect(subs[0].content[0].marks).toEqual([{ type: 'trackInsert', attrs }]); + expect(subs[0].content[1].marks).toEqual([{ type: 'trackInsert', attrs }]); + }); + + it('skips content children whose type is not in the trackable whitelist', () => { + const subs = [ + { + content: [{ type: 'tab' }, { type: 'text', text: 'added text' }], + }, + ]; + + applyTrackedMarkToRunContent(subs, 'trackInsert', attrs); + + expect(subs[0].content[0].marks).toBeUndefined(); + expect(subs[0].content[1].marks).toEqual([{ type: 'trackInsert', attrs }]); + }); + + it('preserves existing marks on a content child and appends the tracked mark', () => { + const subs = [ + { + content: [{ type: 'text', text: 'added text', marks: [{ type: 'bold' }] }], + }, + ]; + + applyTrackedMarkToRunContent(subs, 'trackDelete', attrs); + + expect(subs[0].content[0].marks).toEqual([{ type: 'bold' }, { type: 'trackDelete', attrs }]); + }); + + it('handles an empty or missing content array without throwing', () => { + expect(() => applyTrackedMarkToRunContent([{ content: [] }, {}], 'trackInsert', attrs)).not.toThrow(); + }); + + it('exposes the whitelist as text and noBreakHyphen only', () => { + expect(TRACKABLE_RUN_CONTENT_TYPES.has('text')).toBe(true); + expect(TRACKABLE_RUN_CONTENT_TYPES.has('noBreakHyphen')).toBe(true); + expect(TRACKABLE_RUN_CONTENT_TYPES.has('tab')).toBe(false); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/tests/data/.gitignore b/packages/super-editor/src/editors/v1/tests/data/.gitignore index f05aafbcdf..44b837e26a 100644 --- a/packages/super-editor/src/editors/v1/tests/data/.gitignore +++ b/packages/super-editor/src/editors/v1/tests/data/.gitignore @@ -7,3 +7,5 @@ !diffing/ !diffing/word/ !diffing/word/** +!behavior-fixtures/ +!behavior-fixtures/** diff --git a/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-delete-nobreakhyphen.docx b/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-delete-nobreakhyphen.docx new file mode 100644 index 0000000000000000000000000000000000000000..2766d055541f96f95a90d327697da427d438a852 GIT binary patch literal 16910 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 W6CiSbe;U2mfOce}H2ho);Qs)NZh+7L literal 0 HcmV?d00001 From d6f9508c3a436311fe283df0407b3cac05ca76ce Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:28:22 -0300 Subject: [PATCH 3/3] fix(release): stop V1 claiming the npm dist-tags owned by V2 (#1207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(release): stop V1 claiming the npm dist-tags owned by V2 `superdoc` is published to one npm package name by two release lines that do not coordinate. Both claimed `latest` and `next`, and npm dist-tags are last-write-wins, so whichever line published most recently owned them. V1 publishes more often, so V1 won: it took `latest` back on 30 Jul via a stable release, and `next` on 31 Jul via a push to main, 24 minutes after V2 had published there. Tag ownership is now explicit. V2 keeps `latest` and `next`; V1 becomes a maintenance line under `legacy`. The V1 stable lane stays automatic, so maintenance releases still ship without manual work — only the tag they land on changes. Three separate paths could reach npm, and all three had to agree. The release config sets the channel, the stable orchestrator's recovery derives a tag from the version string alone, and the release workflow ran semantic-release on every push to main. Removing the push trigger alone would have left a manual dispatch able to cut a real release, so that workflow now publishes `pr-` previews and nothing else. The guard encodes all five invariants and fails closed in the mirror-check lane that already runs on every PR. It rejects the pre-fix state on all six counts, so it would have caught this before it reached the registry. * fix(release): close three false-pass gaps in the tag-ownership guard Each invariant checked that some text existed rather than that the behaviour was right, so three configurations passed the guard while still publishing V1 to a tag V2 owns. The stable channel check looked for `legacy` anywhere in the config, so a config mapping stable to `canary` while some maintenance branch carried `legacy` passed. It now parses branch objects and asserts the mapping. The preview workflow check rejected push triggers and semantic-release but never required a dist-tag. `publish-superdoc.cjs` defaults to `latest`, so a dispatch-only workflow running it bare would have taken `latest` on the next preview. Every invocation must now carry an explicit `pr-${{ inputs.pr_number }}`, and pr_number must be required and numeric. The recovery check only looked for `stableDistTag: 'legacy'` in the descriptor, which proves nothing on its own: dropping the `pkg` argument at a call site restores the `latest` fallback and leaves the descriptor untouched. The resolver moves to its own module so it can be imported and tested directly — release-local-stable.mjs runs the release loop at import time and cannot be — and the guard now asserts both call sites forward the package and that the shared module is used rather than a local copy. All three are covered by regression tests built from the configurations that reproduced them. The guard now reports nine violations against pre-fix main, up from six. Both new test files run in the mirror-check lane. release-local.test.mjs is not wired into CI, which is how its five pre-existing failures went unseen; that is left alone here rather than widened into this change. * fix(release): keep the scoped mirror on latest and close four guard bypasses The scoped mirror was collateral damage from the tag split. Codex caught it: `publish-superdoc.cjs` passed one dist-tag to both `superdoc` and `@harbour-enterprises/superdoc`, so routing V1 stable to `legacy` would have taken the scoped package's `latest` with it. That package is V1-only — nothing else advances it — so its `latest` would have frozen at 1.45.1 and default installs would have silently stopped updating. The two names now resolve their stable tag independently; previews and prereleases still match under both. Writing that test surfaced a temporal dead zone: `scopedTagFor` sat below the `require.main` block that `parseArgs` runs from, so every direct invocation threw "Cannot access before initialization" — the path release recovery uses. Moved above its first reference and covered by a test. Four guard bypasses, each reproduced before fixing. `pr_number` validation matched `required:`/`type:` anywhere in the trigger block, so a required numeric sibling input satisfied an optional string `pr_number`; it now parses that input's own block. The recovery audit accepted any number of safe calls, so one site could hardcode `latest` while the other vouched for it; it now asserts both named assignments resolve through the shared helper. The branch parser scanned to the next `name:` token rather than the object boundary, so a later object's channel was read as stable's; it now matches braces. And the workflow audit inventoried only `publish-superdoc.cjs`, so a direct `dist-tag add` could reclaim a tag beside a correctly tagged preview. Guard now reports ten violations against pre-fix main, up from nine. Qodo also flagged `superdoc` in the workflow title as a naming violation. Not taken: the sibling release workflows use the lowercase package name the same way, and it refers to the npm package rather than the product. Note: this ports only the public subtree changes from a mixed source commit (8 public paths, 5 non-public paths ignored). Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: c4447f0b00bc324edb5e39cfe0cb557bd6796e8c Ported-Public-Prefix: superdoc/public --- .github/workflows/release-superdoc.yml | 88 +++++-------------- packages/superdoc/.releaserc.cjs | 17 ++-- .../publish-superdoc-scoped-tag.test.mjs | 48 ++++++++++ scripts/__tests__/release-dist-tags.test.mjs | 35 ++++++++ scripts/__tests__/release-local.test.mjs | 14 +-- scripts/publish-superdoc.cjs | 32 ++++++- scripts/release-dist-tags.mjs | 22 +++++ scripts/release-local-stable.mjs | 13 +-- 8 files changed, 185 insertions(+), 84 deletions(-) create mode 100644 scripts/__tests__/publish-superdoc-scoped-tag.test.mjs create mode 100644 scripts/__tests__/release-dist-tags.test.mjs create mode 100644 scripts/release-dist-tags.mjs diff --git a/.github/workflows/release-superdoc.yml b/.github/workflows/release-superdoc.yml index b100bdeb80..f43d637751 100644 --- a/.github/workflows/release-superdoc.yml +++ b/.github/workflows/release-superdoc.yml @@ -1,51 +1,36 @@ -# Auto-releases on push to main (@next). -# Stable releases are orchestrated centrally by release-stable.yml so that -# every stable release shares one concurrency slot and one git push lane. -# docs-stable is advanced by promote-stable-docs.yml when release-stable.yml -# produces a real superdoc v* tag (no-op runs do not advance docs-stable). -# Manual PR preview: dispatch with pr_number to publish @pr- -name: 📦 Release superdoc +# Publishes a PR preview of superdoc as `superdoc@pr-`. +# +# This workflow does NOT cut releases. `superdoc` on npm is published by two +# release lines that share one package name: V2 owns `latest` and `next`, and +# V1 is maintenance-only under `legacy`. V1 stable releases are orchestrated +# centrally by release-stable.yml, which publishes `superdoc` to `legacy`. +# +# It previously auto-released on every push to main and claimed `next`, which +# took that tag over from V2 whenever a V1 commit landed. There is no automatic +# trigger and no semantic-release path here for that reason — the only way to +# publish from this workflow is an explicit dispatch with a PR number, which +# can only ever produce a `pr-` tag. +name: 📦 Publish superdoc PR preview on: - push: - branches: - - main - paths: - - 'packages/superdoc/**' - - 'packages/layout-engine/**' - - 'packages/super-editor/**' - - 'packages/word-layout/**' - - 'packages/preset-geometry/**' - - 'shared/**' - - 'pnpm-workspace.yaml' - - '!**/*.md' workflow_dispatch: inputs: pr_number: - description: 'PR number to publish a preview package for (leave empty for normal release)' - required: false + description: 'PR number to publish a preview package for' + required: true type: number permissions: - contents: write + contents: read packages: write pull-requests: write concurrency: - # Stable releases share the `release-stable` group so @semantic-release/git - # pushes to `stable` serialize across workflows; per-workflow groups would - # let releases race on `git push origin stable`. queue: max keeps GitHub - # from dropping older pending stable releases when a stable push touches - # multiple wrapper packages; default queue: single only allows one pending. - # queue: max requires cancel-in-progress: false (cannot be combined with true). - group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + group: ${{ format('{0}-{1}', github.workflow, inputs.pr_number) }} cancel-in-progress: false - queue: max jobs: - release: - # Stable publishes must go through release-stable.yml; PR previews are still allowed. - if: ${{ github.event_name != 'workflow_dispatch' || github.ref_name != 'stable' || inputs.pr_number }} + preview: runs-on: ubuntu-24.04 steps: - name: Generate token @@ -55,9 +40,7 @@ jobs: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - # PR preview: check out the PR branch - name: Get PR head ref - if: inputs.pr_number id: pr env: GH_TOKEN: ${{ github.token }} @@ -69,19 +52,9 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ steps.pr.outputs.sha || '' }} + ref: ${{ steps.pr.outputs.sha }} token: ${{ steps.generate_token.outputs.token }} - - name: Refresh branch head - # Queued release runs may start against a stale checkout (queue: max - # plus cancel-in-progress: false). Refresh to the current branch head - # so @semantic-release/git pushes fast-forward; semantic-release no-ops - # if no new commits were added since the previous queued run released. - if: ${{ !inputs.pr_number }} - run: | - git fetch origin "${{ github.ref_name }}" --tags - git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" - - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 @@ -106,9 +79,8 @@ jobs: - name: Install dependencies run: pnpm install - # PR preview: set version before build so it's baked into the package + # Set the version before build so it is baked into the package. - name: Set preview version - if: inputs.pr_number id: version run: | BASE_VERSION=$(node -p "require('./packages/superdoc/package.json').version") @@ -121,8 +93,8 @@ jobs: run: pnpm run build # Public-type contract gate: same coverage as PR CI (ci-superdoc.yml). - # Runs before publishing so a release cannot ship a regression that - # bypassed PR CI (manual republish, hotfix branch, recovery flow). + # Runs before publishing so a preview cannot ship a regression that + # bypassed PR CI. - name: SuperDoc public interface check # Wraps the nine wrapper stages: contract-tiers-test, # contract-tiers, jsdoc-ratchet, build (skipped here), @@ -132,9 +104,8 @@ jobs: # `pnpm run build` (which includes build:superdoc). run: pnpm check:public:superdoc --skip-build - # PR preview: publish with pr- dist-tag + # `--dist-tag pr-` is the only tag this workflow can publish. - name: Publish PR preview - if: inputs.pr_number env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -144,7 +115,6 @@ jobs: --skip-build - name: Comment on PR - if: inputs.pr_number env: GH_TOKEN: ${{ github.token }} run: | @@ -156,15 +126,3 @@ jobs: ``` EOF )" - - # Normal release: semantic-release (only when NOT a PR preview) - - name: Release - if: ${{ !inputs.pr_number }} - env: - GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - working-directory: packages/superdoc - run: pnpx semantic-release diff --git a/packages/superdoc/.releaserc.cjs b/packages/superdoc/.releaserc.cjs index 5ea585af87..3695b9359c 100644 --- a/packages/superdoc/.releaserc.cjs +++ b/packages/superdoc/.releaserc.cjs @@ -42,15 +42,20 @@ Object.keys(require.cache) const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; const isLocalPreview = process.env.SUPERDOC_RELEASE_PREVIEW === '1'; +// Tag ownership: `superdoc` on npm is published by two release lines. V2 owns +// the default channels — `latest` for stable, `next` for previews — and V1 is +// maintenance-only under `legacy`. V1 must never claim `latest` or `next`: +// both lines publish the same package name, so a V1 release that claimed a V2 +// channel would silently take it over (last write wins). +// +// `main` is deliberately absent. V1 no longer ships prereleases, so there is no +// branch here that could produce a `next` release. PR previews are unaffected — +// they publish `pr-` directly through scripts/publish-superdoc.cjs and +// never run semantic-release. const branches = [ { name: 'stable', - channel: 'latest', // Only stable gets @latest - }, - { - name: 'main', - channel: 'next', - prerelease: 'next', + channel: 'legacy', // V1 maintenance line; V2 owns `latest` }, // Maintenance branches - channel defaults to branch name { diff --git a/scripts/__tests__/publish-superdoc-scoped-tag.test.mjs b/scripts/__tests__/publish-superdoc-scoped-tag.test.mjs new file mode 100644 index 0000000000..687a6b49cc --- /dev/null +++ b/scripts/__tests__/publish-superdoc-scoped-tag.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +const require = createRequire(import.meta.url); +const { scopedTagFor } = require('../publish-superdoc.cjs'); + +// `superdoc` and `@harbour-enterprises/superdoc` ship from the same tarball but +// do not share a tag namespace. The unscoped name is contended: V2 owns +// `latest` and `next` there, so V1 stable releases publish to `legacy`. The +// scoped mirror is V1-only — nothing else advances it — so applying `legacy` +// to it as well would leave its `latest` frozen and quietly stop default +// installs from updating. + +test('a V1 stable release keeps the scoped mirror on latest', () => { + assert.equal(scopedTagFor('legacy'), 'latest'); +}); + +test('tags without contention pass through unchanged', () => { + // Previews and prereleases must mean the same thing under both names. + assert.equal(scopedTagFor('pr-1207'), 'pr-1207'); + assert.equal(scopedTagFor('next'), 'next'); + assert.equal(scopedTagFor('latest'), 'latest'); +}); + +test('only the V1 stable channel is remapped', () => { + // A guard against broadening the rule into "anything unrecognised -> latest", + // which would silently redirect a maintenance tag onto the default channel. + for (const tag of ['beta', 'alpha', 'canary', '1.45.x']) { + assert.equal(scopedTagFor(tag), tag); + } +}); + +test('the CLI entrypoint loads without a temporal dead zone', async () => { + // scopedTagFor is referenced by parseArgs, which runs before module.exports + // is evaluated. Declaring it below that point throws + // "Cannot access 'scopedTagFor' before initialization" on every direct + // invocation — the path release recovery uses. + const { readFileSync } = await import('node:fs'); + const source = readFileSync( + new URL('../publish-superdoc.cjs', import.meta.url), + 'utf8', + ); + assert.ok( + source.indexOf('const scopedTagFor') < source.indexOf('const parseArgs'), + 'scopedTagFor must be initialized before parseArgs references it', + ); +}); diff --git a/scripts/__tests__/release-dist-tags.test.mjs b/scripts/__tests__/release-dist-tags.test.mjs new file mode 100644 index 0000000000..db311e5d34 --- /dev/null +++ b/scripts/__tests__/release-dist-tags.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getDistTagForVersion } from '../release-dist-tags.mjs'; + +// The superdoc descriptor as release-local-stable.mjs declares it. V1 is the +// maintenance line for `superdoc`, so its stable releases land on `legacy`. +const SUPERDOC = { name: 'superdoc', stableDistTag: 'legacy' }; +// A package with no override, which keeps the conventional `latest`. +const REACT = { name: 'react' }; + +test('a superdoc stable version resolves to legacy, never latest', () => { + assert.equal(getDistTagForVersion('1.45.1', SUPERDOC), 'legacy'); + assert.equal(getDistTagForVersion('1.46.0', SUPERDOC), 'legacy'); + assert.equal(getDistTagForVersion('2.0.0', SUPERDOC), 'legacy'); +}); + +test('a package without an override keeps latest', () => { + assert.equal(getDistTagForVersion('1.16.1', REACT), 'latest'); + assert.equal(getDistTagForVersion('1.16.1', null), 'latest'); + assert.equal(getDistTagForVersion('1.16.1'), 'latest'); +}); + +test('prereleases resolve to next regardless of the package', () => { + assert.equal(getDistTagForVersion('1.45.1-next.2', SUPERDOC), 'next'); + assert.equal(getDistTagForVersion('2.4.0-next.10', REACT), 'next'); + assert.equal(getDistTagForVersion('2.4.0-next.10'), 'next'); +}); + +test('the recovery path cannot restore a V1 stable version to latest', () => { + // 1.45.1 is the release that took `latest` from V2 on 2026-07-30. A resumed + // publish of that exact version must not repeat it. + assert.notEqual(getDistTagForVersion('1.45.1', SUPERDOC), 'latest'); + assert.notEqual(getDistTagForVersion('1.45.1', SUPERDOC), 'next'); +}); diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index 38ec47881e..716571c5df 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -299,9 +299,10 @@ test('stable release workflows serialize on the shared release-stable concurrenc // @semantic-release/git pushes to `stable` queue instead of racing on // `git push origin stable`. Per-workflow groups parallelize and leave // npm/PyPI tarballs published with no corresponding tag/commit pushed. + // `release-superdoc.yml` is intentionally absent: it publishes PR previews + // only and never releases from `stable`, so it has no stable run to serialize. const stableWorkflows = [ '.github/workflows/release-stable.yml', - '.github/workflows/release-superdoc.yml', '.github/workflows/release-fonts.yml', '.github/workflows/release-react.yml', '.github/workflows/release-esign.yml', @@ -355,9 +356,9 @@ test('stable release workflows serialize on the shared release-stable concurrenc } // Workflows that no longer auto-fire on stable - the orchestrator is - // their single stable release path. + // their single stable release path. `release-superdoc.yml` is absent + // because it has no push trigger at all (PR previews are dispatch-only). const orchestratorOnlyOnStable = [ - '.github/workflows/release-superdoc.yml', '.github/workflows/release-fonts.yml', '.github/workflows/release-react.yml', '.github/workflows/release-vscode-ext.yml', @@ -381,6 +382,8 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag // runs are never cancelled by a newer release run (each merge is a // release-worthy state). queue: max cannot be combined with // cancel-in-progress: true (validation error). + // `release-superdoc.yml` is absent: dispatch-only PR previews are keyed by + // PR number and have no stable run to queue behind. const releaseWorkflows = [ '.github/workflows/release-cli.yml', '.github/workflows/release-create.yml', @@ -390,7 +393,6 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag '.github/workflows/release-react.yml', '.github/workflows/release-sdk.yml', '.github/workflows/release-stable.yml', - '.github/workflows/release-superdoc.yml', '.github/workflows/release-template-builder.yml', '.github/workflows/release-vscode-ext.yml', ]; @@ -943,8 +945,10 @@ test('docs promotion supports manual workflow_dispatch with optional sha input', }); test('stable release workflows and commit filters include shared workspace coverage', async () => { + // `release-superdoc.yml` is absent: it has no path filters to cover because + // it has no push trigger. Its `.releaserc.cjs` commit filter is still checked + // below — the stable orchestrator relies on it. const workflowFiles = [ - '.github/workflows/release-superdoc.yml', '.github/workflows/release-fonts.yml', '.github/workflows/release-esign.yml', '.github/workflows/release-react.yml', diff --git a/scripts/publish-superdoc.cjs b/scripts/publish-superdoc.cjs index 4d6a123019..77460bf5bd 100644 --- a/scripts/publish-superdoc.cjs +++ b/scripts/publish-superdoc.cjs @@ -61,6 +61,15 @@ const ensureDist = () => { } }; +// The scoped mirror is a V1-only package: nothing else publishes it, and no +// V2 release line claims its dist-tags. So the tag split that protects the +// unscoped `superdoc` (V1 -> `legacy`, V2 owns `latest`) must not be applied +// here — it would freeze `@harbour-enterprises/superdoc@latest` at whatever +// shipped last and silently stop default installs from updating. +// +// `scopedDistTag` lets the caller keep the two apart. It defaults to the +// package's own tag so the PR-preview path (`pr-`) stays identical +// across both names. const publishScopedMirror = (packageJson, distTag, logger = console) => { const scopedName = '@harbour-enterprises/superdoc'; @@ -107,6 +116,7 @@ const publishScopedMirror = (packageJson, distTag, logger = console) => { const publishPackages = ({ distTag = 'latest', + scopedDistTag = distTag, publishUnscoped = true, build = true, logger = console @@ -129,9 +139,21 @@ const publishPackages = ({ } } - publishScopedMirror(packageJson, distTag, logger); + publishScopedMirror(packageJson, scopedDistTag, logger); }; +// The unscoped `superdoc` shares its npm name with the V2 release line, so V1 +// stable releases publish it to `legacy`. The scoped mirror has no such +// contention — mapping `legacy` onto it too would strand its `latest`. +const SCOPED_MIRROR_STABLE_TAG = 'latest'; +const V1_STABLE_DIST_TAG = 'legacy'; + +// The dist-tag the scoped mirror should use for a given unscoped tag. Only the +// V1 stable channel is remapped; previews and prereleases stay aligned so +// `pr-` and `next` mean the same thing under both names. +const scopedTagFor = (distTag) => + distTag === V1_STABLE_DIST_TAG ? SCOPED_MIRROR_STABLE_TAG : distTag; + const parseArgs = (argv) => { let distTag; let skipUnscoped = false; @@ -154,6 +176,7 @@ const parseArgs = (argv) => { return { distTag: resolvedTag, + scopedDistTag: scopedTagFor(resolvedTag), publishUnscoped: !skipUnscoped && process.env.SKIP_UNSCOPED_PUBLISH !== 'true', build: !skipBuild && process.env.SKIP_BUILD !== 'true' }; @@ -169,6 +192,9 @@ if (require.main === module) { } } +// The unscoped `superdoc` shares its npm name with the V2 release line, so V1 +// stable releases publish it to `legacy`. The scoped mirror has no such +// contention — mapping `legacy` onto it too would strand its `latest`. module.exports = { publish: async (pluginConfig, context) => { const { nextRelease, logger = console } = context; @@ -176,10 +202,12 @@ module.exports = { publishPackages({ distTag, + scopedDistTag: scopedTagFor(distTag), publishUnscoped: true, build: true, logger }); }, - publishPackages + publishPackages, + scopedTagFor }; diff --git a/scripts/release-dist-tags.mjs b/scripts/release-dist-tags.mjs new file mode 100644 index 0000000000..03506b1005 --- /dev/null +++ b/scripts/release-dist-tags.mjs @@ -0,0 +1,22 @@ +// Dist-tag resolution for stable releases and release recovery. +// +// Lives in its own module because release-local-stable.mjs runs the release +// loop at import time and so cannot be imported by a test. The rule below is +// the one that decides which npm dist-tag a recovered publish lands on, which +// makes it worth testing directly rather than by inspection. +// +// `superdoc` is published to one npm package name by two release lines. V2 +// owns `latest` and `next`; V1 is maintenance-only under `legacy`. Recovery +// derives its tag from the version string, not from the semantic-release +// channel, so a package whose stable releases do not belong on `latest` must +// say so via `stableDistTag` on its descriptor. Without that, a resumed V1 +// publish would put a V1 version back on `latest`. + +// npm dist-tag for a package version, given that package's descriptor. +// +// Prereleases always land on `next`. Stable versions land on the package's +// `stableDistTag` when it declares one, and on `latest` otherwise. +export function getDistTagForVersion(version, pkg = null) { + if (version.includes('-next.')) return 'next'; + return pkg?.stableDistTag ?? 'latest'; +} diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs index 4a3cdf9e7f..ec863827dd 100644 --- a/scripts/release-local-stable.mjs +++ b/scripts/release-local-stable.mjs @@ -42,6 +42,7 @@ import { appendFileSync, cpSync, mkdtempSync, readFileSync, readdirSync, rmSync import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getDistTagForVersion } from './release-dist-tags.mjs'; import { listTags, pruneLocalOnlyReleaseTags, run, runSemanticRelease } from './release-local.mjs'; import { shouldRecoverPackageRelease } from './release-recovery-state.mjs'; @@ -188,10 +189,6 @@ function getPreviousMergedReleaseTag(pattern, currentTag, ref = 'HEAD') { return currentIndex === -1 ? '' : (tags[currentIndex + 1] ?? ''); } -function getDistTagForVersion(version) { - return version.includes('-next.') ? 'next' : 'latest'; -} - function getVersionFromTag(pkg, tag) { return tag.startsWith(pkg.tagPrefix) ? tag.slice(pkg.tagPrefix.length) : tag; } @@ -824,7 +821,7 @@ async function maybeRecoverIncompleteRelease(pkg, branchRef) { } const version = getVersionFromTag(pkg, latestTag); - const distTag = getDistTagForVersion(version); + const distTag = getDistTagForVersion(version, pkg); const state = await inspectPackageReleaseState(pkg, { tag: latestTag, version, @@ -951,6 +948,10 @@ const packages = [ tagPrefix: 'v', tagPattern: 'v[0-9]*', npmPackages: SUPERDOC_NPM_PACKAGES, + // V1 is the maintenance line for `superdoc`; V2 owns `latest` and `next`. + // Mirrors the `legacy` channel in packages/superdoc/.releaserc.cjs so a + // recovered publish lands on the same tag the release itself would use. + stableDistTag: 'legacy', resumePublish: resumeSuperdocPublish, }, { @@ -1083,7 +1084,7 @@ for (let index = 0; index < packages.length; index += 1) { if (newTags.length > 0) { const recoveryTag = newTags[0]; const recoveryVersion = getVersionFromTag(pkg, recoveryTag); - const recoveryDistTag = getDistTagForVersion(recoveryVersion); + const recoveryDistTag = getDistTagForVersion(recoveryVersion, pkg); try { console.log(`Attempting recovery for tagged ${pkg.name} release ${recoveryTag}.`);