diff --git a/.last-synced-sha b/.last-synced-sha index 96c5587..5b99e48 100644 --- a/.last-synced-sha +++ b/.last-synced-sha @@ -1 +1 @@ -7cf31eec6b8b4bc94ad975aabddc5b61da65c73c +53bf6960607cb91b93bc11a86901bdf68e90bb30 diff --git a/scripts/__tests__/sdk-release-metadata.spec.mjs b/scripts/__tests__/sdk-release-metadata.spec.mjs index b3d4ddf..0407bd1 100644 --- a/scripts/__tests__/sdk-release-metadata.spec.mjs +++ b/scripts/__tests__/sdk-release-metadata.spec.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { factsFromDiff, renderChangelogMarkdown, scopesForServices } from '../sdk-release-metadata.mjs'; +import { factsFromCompat, factsFromDiff, renderChangelogMarkdown, scopesForServices } from '../sdk-release-metadata.mjs'; // factsFromDiff only reaches indexes.symbolScopes (scope resolution) for the // kinds under test; an empty index leaves scope unresolved, which is fine — we @@ -161,3 +161,36 @@ test('renderChangelogMarkdown keeps distinct scopes as separate headings', () => assert.ok(markdown.includes('**[sso](https://s)**:')); assert.ok(markdown.indexOf('[pipes]') < markdown.indexOf('[sso]')); }); + +// Regression: a compat surface change must resolve to a real scope with a +// docs_url, never the `sdk` fallback (which hard-fails --strict-scopes in CI). +// factsFromCompat distrusts a service scope that is merely `toSnakeCase(root)` +// and defers to scopeFromName; every root below reached CI as `sdk` because +// scopeFromName had no rule for it. These map one-per-scope (the per-scope +// dedup keeps a single breaking fact each) so all four resolve in one pass. +const compatSurfaceScopeCases = [ + { symbol: 'AdminPortal.generate_link', scope: 'admin_portal' }, + { symbol: 'Authorization.assign_role', scope: 'authorization' }, + { symbol: 'Agents.create_validate', scope: 'agents' }, + { symbol: 'ClientApi.create_token', scope: 'client' }, + { symbol: 'CreateApplicationSecret.from_dict', scope: 'connect' }, +]; + +for (const { symbol, scope } of compatSurfaceScopeCases) { + test(`factsFromCompat resolves ${symbol} to ${scope}, not sdk`, () => { + const compatReport = { + changes: [ + { + severity: 'breaking', + category: 'parameter_type_narrowed', + symbol, + message: `Parameter type changed for "x" on "${symbol}"`, + }, + ], + }; + const facts = factsFromCompat(compatReport, [], EMPTY_INDEXES); + assert.equal(facts.length, 1); + assert.equal(facts[0].scope, scope); + assert.notEqual(facts[0].scope, 'sdk'); + }); +} diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index 8052be3..4b297ec 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -1136,6 +1136,59 @@ function renderMarkdown(languageData, options) { return lines.join('\n') + '\n'; } +// GitHub rejects issue comments whose body exceeds this many characters +// (HTTP 422 "Body is too long"). Leave headroom below the hard limit for the +// truncation footer and any
tags we close on the way out. +const GITHUB_COMMENT_LIMIT = 65536; + +/** + * Cap the comment body to GitHub's issue-comment size limit. + * + * The full report can balloon on large spec diffs (every affected symbol in + * every language). When it overflows we trim at a line boundary, close any + *
blocks we cut through so the HTML stays balanced, and append a + * note pointing at the complete report (Pages URL or the run's artifact). + */ +function truncateForComment(markdown, { pagesUrl, runId, repo }) { + if (markdown.length <= GITHUB_COMMENT_LIMIT) return markdown; + + const linkTarget = pagesUrl + ? `[the full SDK diff report](${pagesUrl})` + : runId && repo + ? `the \`sdk-diff-report.html\` artifact on [this run](https://github.com/${repo}/actions/runs/${runId}#artifacts)` + : 'the full SDK diff report'; + + const footer = [ + '', + '> [!NOTE]', + `> This report was truncated because it exceeded GitHub's ${GITHUB_COMMENT_LIMIT.toLocaleString('en-US')}-character comment limit. See ${linkTarget} for the complete details.`, + '', + ].join('\n'); + + // Reserve room for the footer plus a handful of closing
tags. + const budget = GITHUB_COMMENT_LIMIT - footer.length - 256; + + const kept = []; + let length = 0; + let openDetails = 0; + for (const line of markdown.split('\n')) { + const lineCost = line.length + 1; // account for the rejoined newline + if (length + lineCost > budget) break; + kept.push(line); + length += lineCost; + openDetails += (line.match(/]/g) ?? []).length; + openDetails -= (line.match(/<\/details>/g) ?? []).length; + } + + // Balance any
blocks the cut left open. + while (openDetails > 0) { + kept.push('
'); + openDetails -= 1; + } + + return kept.join('\n') + footer; +} + function main() { const args = parseArgs(process.argv); const artifactDirs = listArtifactDirs(args.artifactsRoot); @@ -1147,7 +1200,12 @@ function main() { codeDiffAvailable: args.codeDiffAvailable, pagesUrl: args.pagesUrl, }); - fs.writeFileSync(args.output, markdown, 'utf8'); + const capped = truncateForComment(markdown, { + pagesUrl: args.pagesUrl, + runId: args.runId, + repo: args.repo, + }); + fs.writeFileSync(args.output, capped, 'utf8'); } main(); diff --git a/scripts/sdk-release-metadata.mjs b/scripts/sdk-release-metadata.mjs index b89a92e..4970815 100644 --- a/scripts/sdk-release-metadata.mjs +++ b/scripts/sdk-release-metadata.mjs @@ -8,6 +8,7 @@ import { pathToFileURL } from 'node:url'; const SCOPE_LABELS = { admin_portal: 'admin portal', + agents: 'agents', api_keys: 'API key', audit_logs: 'audit log', authorization: 'authorization', @@ -36,6 +37,7 @@ const SCOPE_LABELS = { const SCOPE_DOC_URLS = { admin_portal: 'https://workos.com/docs/reference/admin-portal', + agents: 'https://workos.com/docs/reference/agents', api_keys: 'https://workos.com/docs/reference/authkit/api-keys', audit_logs: 'https://workos.com/docs/reference/audit-logs', authorization: 'https://workos.com/docs/reference/fga', @@ -412,6 +414,15 @@ function scopeFromName(name) { if (/^Radar/.test(name)) return 'radar'; if (/^(Vault|Object$|ObjectMetadata|ObjectSummary|ObjectVersion|ObjectWithoutValue)/.test(name)) return 'vault'; if (/^AuditLog/.test(name)) return 'audit_logs'; + // `/agents/*` standalone surface (`Agents.create_validate`, `.get_registration`, + // `.update_attempts`) and agent-registration types. + if (/^Agent/.test(name)) return 'agents'; + // `/client/token` issuance (`ClientApi.create_token`). Distinct root from the + // bare `Client` the compat client rule already maps. + if (/^ClientApi/.test(name)) return 'client'; + // Connect application secrets (`CreateApplicationSecret`, `NewConnectApplicationSecret`); + // the `Create`/`New` prefixes dodge the anchored `Application|Connect` rule below. + if (/ApplicationSecret/.test(name)) return 'connect'; if (/^(Application|Connect|UserObject$|ApplicationCredentials|ExternalAuth|RedirectUriInput)/.test(name)) return 'connect'; if (/^(Group|CreateGroup|UpdateGroup)/.test(name)) return 'groups'; if (/OrganizationMembership/.test(name)) return 'organization_membership'; @@ -426,13 +437,17 @@ function scopeFromName(name) { if (/^(Invitation|MagicAuth|PasswordReset|RevokeSession|Session|User|CreateUser|UpdateUser|EmailChange)/.test(name)) { return 'user_management'; } - if (/^(Role|Permission)/.test(name)) return 'authorization'; + if (/^(Role|Permission|Authorization)/.test(name)) return 'authorization'; if (/^Widget/.test(name)) return 'widgets'; if (/^Event/.test(name)) return 'events'; - // Admin Portal generate-link intent options (`GenerateLinkDto`). The SSO and + // Admin Portal service surface (`AdminPortal.generate_link`) and its + // generate-link intent options (`GenerateLinkDto`). The service scope equals + // `toSnakeCase('AdminPortal')` (`admin_portal`), so factsFromCompat falls back + // to this name rule; without it the surface resolves to `sdk`. The SSO and // domain-verification variants resolve to their own scopes via the rules - // above; this catches the bare `IntentOptions` aggregate and any other + // above; `IntentOptions$` catches the bare aggregate and any other // intent-options type not otherwise classified. + if (/^AdminPortal/.test(name)) return 'admin_portal'; if (/IntentOptions$/.test(name)) return 'admin_portal'; return 'sdk'; @@ -938,7 +953,7 @@ function compatChangeIsBreaking(change, indexes) { return true; } -function factsFromCompat(compatReport, existingFacts, indexes) { +export function factsFromCompat(compatReport, existingFacts, indexes) { const facts = []; const existingBreakingScopes = new Set(existingFacts.filter((fact) => fact.severity === 'breaking').map((fact) => fact.scope)); const renames = renamesFromCompat(compatReport); diff --git a/spec/open-api-spec.yaml b/spec/open-api-spec.yaml index 8d61393..e2a89cc 100644 --- a/spec/open-api-spec.yaml +++ b/spec/open-api-spec.yaml @@ -13122,20 +13122,35 @@ paths: content: application/json: schema: - type: object - properties: - code: - type: string - description: The error code identifying the type of error. - example: daily_quota_exceeded - const: daily_quota_exceeded - message: - type: string - description: A human-readable description of the error. - example: Request could not be processed. - required: - - code - - message + oneOf: + - type: object + properties: + code: + type: string + description: The error code identifying the type of error. + example: daily_quota_exceeded + const: daily_quota_exceeded + message: + type: string + description: A human-readable description of the error. + example: Request could not be processed. + required: + - code + - message + - type: object + properties: + error: + type: string + description: The OAuth error code. + example: too_many_requests + const: too_many_requests + error_description: + type: string + description: A human-readable description of the error. + example: 'The request failed due to: too_many_requests.' + required: + - error + - error_description summary: Authenticate tags: - user-management.authentication