diff --git a/.github/workflows/training-agent-storyboards.yml b/.github/workflows/training-agent-storyboards.yml index 28e7816d02c..f4d454937c6 100644 --- a/.github/workflows/training-agent-storyboards.yml +++ b/.github/workflows/training-agent-storyboards.yml @@ -2,7 +2,7 @@ name: Training Agent Storyboards on: pull_request: - branches: [main, '3.1.x', '3.0.x'] + branches: [main, '3.2.x', '3.1.x', '3.0.x'] paths: - 'server/src/training-agent/**' - 'server/src/services/hosted-compliance-version.ts' @@ -22,7 +22,7 @@ on: - 'dist/schemas/**' - '.github/workflows/training-agent-storyboards.yml' push: - branches: [main, '3.1.x', '3.0.x'] + branches: [main, '3.2.x', '3.1.x', '3.0.x'] paths: - 'server/src/training-agent/**' - 'server/src/services/hosted-compliance-version.ts' diff --git a/scripts/check-changeset-protocol-scope.cjs b/scripts/check-changeset-protocol-scope.cjs index b7eae2f8ca9..d0456965341 100644 --- a/scripts/check-changeset-protocol-scope.cjs +++ b/scripts/check-changeset-protocol-scope.cjs @@ -96,8 +96,27 @@ function isProtocolScopedPath(filePath) { return PROTOCOL_SCOPED_PATHS.some(pattern => pattern.test(normalized)); } -function hasProtocolScopedChanges(changes) { - return changes.some(change => (change.paths || []).some(isProtocolScopedPath)); +function isStoryboardBranchRegistrationOnly(headContent, baseContent) { + const oldBranches = "branches: [main, '3.1.x', '3.0.x']"; + const newBranches = "branches: [main, '3.2.x', '3.1.x', '3.0.x']"; + return baseContent.split(oldBranches).length === 3 + && headContent === baseContent.replaceAll(oldBranches, newBranches); +} + +function isProtocolScopedChange(change, readFileAtHead, readFileAtBase) { + return (change.paths || []).some(filePath => { + if (!isProtocolScopedPath(filePath)) return false; + if (normalizePath(filePath) === '.github/workflows/training-agent-storyboards.yml' + && change.status === 'M' + && isStoryboardBranchRegistrationOnly( + readFileAtHead(filePath), readFileAtBase(filePath) + )) return false; + return true; + }); +} + +function hasProtocolScopedChanges(changes, readFileAtHead = () => '', readFileAtBase = () => '') { + return changes.some(change => isProtocolScopedChange(change, readFileAtHead, readFileAtBase)); } function isChangesetMaintenancePath(filePath) { @@ -221,10 +240,8 @@ function findChangesetProtocolScopeViolations(changes, readFileAtHead, readFileA const protocolScopedFiles = []; for (const change of changes) { - for (const filePath of change.paths || []) { - if (isProtocolScopedPath(filePath)) { - protocolScopedFiles.push(filePath); - } + if (isProtocolScopedChange(change, readFileAtHead, readFileAtBase)) { + protocolScopedFiles.push(...change.paths.filter(isProtocolScopedPath)); } const headPath = changedPathForHead(change); @@ -313,7 +330,11 @@ function run(argv = process.argv.slice(2)) { } if (argv.includes('--has-protocol-scoped-changes')) { - const hasProtocol = hasProtocolScopedChanges(changes); + const hasProtocol = hasProtocolScopedChanges( + changes, + readFileAtHead, + filePath => readFileAtRef(baseRef, filePath) + ); if (hasProtocol) { console.log('Protocol-scoped changes detected.'); return 0; @@ -347,6 +368,7 @@ module.exports = { findChangesetProtocolScopeViolations, formatViolationMessage, hasProtocolScopedChanges, + isStoryboardBranchRegistrationOnly, isChangesetBumpDowngradeOrRemoval, isChangesetBumpEscalation, isChangesetClassificationMaintenance, diff --git a/tests/changeset-protocol-scope.test.cjs b/tests/changeset-protocol-scope.test.cjs index a0f707c120b..191be5c1a4d 100644 --- a/tests/changeset-protocol-scope.test.cjs +++ b/tests/changeset-protocol-scope.test.cjs @@ -13,6 +13,7 @@ const { isChangesetEditOnlyMaintenance, isChangesetStatusExemptMaintenance, isProtocolScopedPath, + isStoryboardBranchRegistrationOnly, parseNameStatus, } = require('../scripts/check-changeset-protocol-scope.cjs'); @@ -78,6 +79,56 @@ assert.strictEqual(isProtocolScopedPath('.changeset/billing-fix.md'), false); assert.strictEqual(isProtocolScopedPath('.github/workflows/release.yml'), false); assert.strictEqual(isProtocolScopedPath('.github/workflows/training-agent-storyboards.yml'), true); assert.strictEqual(isProtocolScopedPath('scripts/build-protocol-tarball.cjs'), true); +const storyboardWorkflowPath = '.github/workflows/training-agent-storyboards.yml'; +const storyboardWorkflowBase = `pull_request:\n branches: [main, '3.1.x', '3.0.x']\npush:\n branches: [main, '3.1.x', '3.0.x']\njob: run\n`; +const storyboardWorkflowRegistered = storyboardWorkflowBase.replaceAll( + "branches: [main, '3.1.x', '3.0.x']", + "branches: [main, '3.2.x', '3.1.x', '3.0.x']" +); +const storyboardChange = [{ status: 'M', paths: [storyboardWorkflowPath] }]; +assert.strictEqual( + isStoryboardBranchRegistrationOnly(storyboardWorkflowRegistered, storyboardWorkflowBase), + true +); +assert.strictEqual( + hasProtocolScopedChanges( + storyboardChange, + readFiles({ [storyboardWorkflowPath]: storyboardWorkflowRegistered }), + readFiles({ [storyboardWorkflowPath]: storyboardWorkflowBase }) + ), + false, + 'Registering the exact two 3.2.x CI triggers does not release the protocol' +); +assert.strictEqual( + hasProtocolScopedChanges( + storyboardChange, + readFiles({ [storyboardWorkflowPath]: `${storyboardWorkflowRegistered}another job\n` }), + readFiles({ [storyboardWorkflowPath]: storyboardWorkflowBase }) + ), + true, + 'Changes to storyboard jobs remain protocol scoped' +); +assert.strictEqual( + hasProtocolScopedChanges( + storyboardChange, + readFiles({ [storyboardWorkflowPath]: storyboardWorkflowRegistered.replace('push:\n branches: [main, \'3.2.x\',', 'push:\n branches: [main,') }), + readFiles({ [storyboardWorkflowPath]: storyboardWorkflowBase }) + ), + true, + 'Both pull request and push trigger lists must be registered together' +); +assert.strictEqual( + findChangesetProtocolScopeViolations( + [...storyboardChange, { status: 'A', paths: ['.changeset/empty-workflow.md'] }], + readFiles({ + [storyboardWorkflowPath]: storyboardWorkflowRegistered, + '.changeset/empty-workflow.md': emptyChangeset, + }), + readFiles({ [storyboardWorkflowPath]: storyboardWorkflowBase }) + ).length, + 1, + 'Branch registration cannot justify an artificial protocol changeset' +); assert.strictEqual( hasProtocolScopedChanges([ { status: 'M', paths: ['.github/workflows/release.yml'] }, diff --git a/tests/docs-nav-validation.test.cjs b/tests/docs-nav-validation.test.cjs index ce42c138aff..5998ca81c67 100644 --- a/tests/docs-nav-validation.test.cjs +++ b/tests/docs-nav-validation.test.cjs @@ -333,7 +333,14 @@ test('OpenAPI navigation uses release-pinned public sources', () => { function stableDocsRef() { if (process.env.STABLE_DOCS_REF) return process.env.STABLE_DOCS_REF; const line = /^(\d+\.\d+)$/.exec(defaultVersion)?.[1]; - return line ? `origin/${line}.x` : null; + if (!line) return null; + const branch = `${line}.x`; + // On the stable maintenance branch itself, or a pull request into it, the + // branch is its own release surface. Comparing against its pre-change tip + // would reject every navigation change there, including the GA flip that + // makes it the stable surface. + if ((process.env.GITHUB_BASE_REF || process.env.GITHUB_REF_NAME) === branch) return 'HEAD'; + return `origin/${branch}`; } test('default navigation matches the stable release branch surface', () => {