From 6155114f677592a0b389297f54346965f0285791 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 10 Jul 2026 10:57:23 -0300 Subject: [PATCH 1/6] feat(capability-matrix): add drift detection logic for CI Check 2 --- scripts/capability-matrix/src/drift-check.ts | 78 ++++++++++++ .../test/drift-check.test.ts | 120 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 scripts/capability-matrix/src/drift-check.ts create mode 100644 scripts/capability-matrix/test/drift-check.test.ts diff --git a/scripts/capability-matrix/src/drift-check.ts b/scripts/capability-matrix/src/drift-check.ts new file mode 100644 index 0000000..03ba83f --- /dev/null +++ b/scripts/capability-matrix/src/drift-check.ts @@ -0,0 +1,78 @@ +import type { RawCompliance } from "./compliance.js"; +import type { ParsedSymbol } from "./normalize-typedoc.js"; + +export interface DriftFinding { + featureId: string; + symbol?: string; +} + +export function checkDrift( + prSymbols: ParsedSymbol[], + compliance: RawCompliance, +): DriftFinding[] { + const prNames = new Set(prSymbols.map((s) => s.name)); + const findings: DriftFinding[] = []; + + for (const [featureId, value] of Object.entries(compliance.features ?? {})) { + let status: string | undefined; + let symbols: string[] | undefined; + + if (typeof value === "string") { + status = value; + } else if (typeof value === "object" && value !== null) { + status = value.status; + symbols = value.symbols; + } + + if (status !== "implemented") continue; + + if (!symbols || symbols.length === 0) { + findings.push({ featureId }); + continue; + } + + for (const symbol of symbols) { + if (!prNames.has(symbol)) { + findings.push({ featureId, symbol }); + } + } + } + + return findings; +} + +export function formatDriftSummary(findings: DriftFinding[], sdkName: string): string { + const missingSymbol = findings.filter((f) => f.symbol !== undefined); + const unverifiable = findings.filter((f) => f.symbol === undefined); + + const lines: string[] = [ + "", + "⚠️ Capability matrix drift detected", + ]; + + if (missingSymbol.length > 0) { + lines.push( + "", + `The following capabilities are marked \`implemented\` in the matrix but could not be found in ${sdkName}:`, + ...missingSymbol.map((f) => ` - ${f.featureId} → expected symbol: ${f.symbol}`), + ); + } + + if (unverifiable.length > 0) { + lines.push( + "", + `The following capabilities are marked \`implemented\` in ${sdkName} but have no registered symbols to verify:`, + ...unverifiable.map( + (f) => ` - ${f.featureId} (no \`symbols\` list — cannot confirm implementation exists)`, + ), + ); + } + + lines.push( + "", + "These may have been renamed, removed, or never registered. Please update the capability matrix.", + "See: https://github.com/supabase/sdk/blob/main/docs/capability-matrix.md", + ); + + return lines.join("\n"); +} diff --git a/scripts/capability-matrix/test/drift-check.test.ts b/scripts/capability-matrix/test/drift-check.test.ts new file mode 100644 index 0000000..5f48090 --- /dev/null +++ b/scripts/capability-matrix/test/drift-check.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { checkDrift, formatDriftSummary } from "../src/drift-check"; +import type { ParsedSymbol } from "../src/normalize-typedoc"; + +function sym(name: string): ParsedSymbol { + return { name, kind: "method", file: "src/index.ts" }; +} + +describe("checkDrift", () => { + it("returns no finding when a registered symbol is present", () => { + const compliance = { + sdk: "javascript", + features: { + "auth.sign_up": { status: "implemented", symbols: ["AuthClient.signUp"] }, + }, + }; + expect(checkDrift([sym("AuthClient.signUp")], compliance)).toEqual([]); + }); + + it("reports a finding when a registered symbol is missing", () => { + const compliance = { + sdk: "javascript", + features: { + "auth.mfa.enroll": { status: "implemented", symbols: ["MFAApi.enroll"] }, + }, + }; + expect(checkDrift([], compliance)).toEqual([ + { featureId: "auth.mfa.enroll", symbol: "MFAApi.enroll" }, + ]); + }); + + it("reports one finding per missing symbol when multiple are registered", () => { + const compliance = { + sdk: "javascript", + features: { + "auth.sign_up": { + status: "implemented", + symbols: ["AuthClient.signUp", "AuthClient.signUpAnon"], + }, + }, + }; + expect(checkDrift([sym("AuthClient.signUp")], compliance)).toEqual([ + { featureId: "auth.sign_up", symbol: "AuthClient.signUpAnon" }, + ]); + }); + + it("skips entries that are not status implemented", () => { + const compliance = { + sdk: "javascript", + features: { + "auth.sign_up": { status: "not_implemented", symbols: ["AuthClient.signUp"] }, + }, + }; + expect(checkDrift([], compliance)).toEqual([]); + }); + + it("reports an unverifiable finding when an implemented entry has no symbols list", () => { + const compliance = { + sdk: "javascript", + features: { + "auth.mfa.enroll": { status: "implemented" }, + }, + }; + expect(checkDrift([], compliance)).toEqual([{ featureId: "auth.mfa.enroll" }]); + }); + + it("reports an unverifiable finding when an implemented entry has an empty symbols list", () => { + const compliance = { + sdk: "javascript", + features: { + "auth.mfa.enroll": { status: "implemented", symbols: [] }, + }, + }; + expect(checkDrift([], compliance)).toEqual([{ featureId: "auth.mfa.enroll" }]); + }); + + it("reports an unverifiable finding for the string status shorthand", () => { + const compliance = { + sdk: "javascript", + features: { + "auth.mfa.enroll": "implemented", + }, + }; + expect(checkDrift([], compliance)).toEqual([{ featureId: "auth.mfa.enroll" }]); + }); +}); + +describe("formatDriftSummary", () => { + it("includes the marker and the symbol-not-found section", () => { + const msg = formatDriftSummary( + [{ featureId: "auth.mfa.enroll", symbol: "MFAApi.enroll" }], + "supabase-flutter", + ); + expect(msg).toContain(""); + expect(msg).toContain("⚠️ Capability matrix drift detected"); + expect(msg).toContain("could not be found in supabase-flutter"); + expect(msg).toContain("auth.mfa.enroll → expected symbol: MFAApi.enroll"); + }); + + it("includes the unverifiable section for symbol-less findings", () => { + const msg = formatDriftSummary([{ featureId: "auth.mfa.enroll" }], "supabase-flutter"); + expect(msg).toContain("no registered symbols to verify"); + expect(msg).toContain( + "auth.mfa.enroll (no `symbols` list — cannot confirm implementation exists)", + ); + }); + + it("omits the symbol-not-found section when there are no such findings", () => { + const msg = formatDriftSummary([{ featureId: "auth.mfa.enroll" }], "javascript"); + expect(msg).not.toContain("could not be found in javascript"); + }); + + it("omits the unverifiable section when there are no such findings", () => { + const msg = formatDriftSummary( + [{ featureId: "auth.mfa.enroll", symbol: "MFAApi.enroll" }], + "javascript", + ); + expect(msg).not.toContain("no registered symbols to verify"); + }); +}); From 9d37a27132ad9d4354122f2e80519a29713f31ac Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 10 Jul 2026 11:02:04 -0300 Subject: [PATCH 2/6] feat(capability-matrix): add YAML source-line mapping for drift annotations --- .../src/compliance-source-map.ts | 37 ++++++++++++++ .../test/compliance-source-map.test.ts | 48 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 scripts/capability-matrix/src/compliance-source-map.ts create mode 100644 scripts/capability-matrix/test/compliance-source-map.test.ts diff --git a/scripts/capability-matrix/src/compliance-source-map.ts b/scripts/capability-matrix/src/compliance-source-map.ts new file mode 100644 index 0000000..73b92d5 --- /dev/null +++ b/scripts/capability-matrix/src/compliance-source-map.ts @@ -0,0 +1,37 @@ +import { parseDocument, LineCounter, isMap, isSeq, isScalar } from "yaml"; + +export interface SourceMap { + symbolLines: Map; + featureLines: Map; +} + +export function buildSourceMap(complianceYamlText: string): SourceMap { + const symbolLines = new Map(); + const featureLines = new Map(); + + const lineCounter = new LineCounter(); + const doc = parseDocument(complianceYamlText, { lineCounter }); + + const features = doc.get("features", true); + if (!isMap(features)) return { symbolLines, featureLines }; + + for (const pair of features.items) { + const key = pair.key; + if (!isScalar(key) || typeof key.value !== "string") continue; + if (key.range) featureLines.set(key.value, lineCounter.linePos(key.range[0]).line); + + const entry = pair.value; + if (!isMap(entry)) continue; + + const symbols = entry.get("symbols", true); + if (!isSeq(symbols)) continue; + + for (const item of symbols.items) { + if (isScalar(item) && typeof item.value === "string" && item.range) { + symbolLines.set(item.value, lineCounter.linePos(item.range[0]).line); + } + } + } + + return { symbolLines, featureLines }; +} diff --git a/scripts/capability-matrix/test/compliance-source-map.test.ts b/scripts/capability-matrix/test/compliance-source-map.test.ts new file mode 100644 index 0000000..524acd8 --- /dev/null +++ b/scripts/capability-matrix/test/compliance-source-map.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { buildSourceMap } from "../src/compliance-source-map"; + +describe("buildSourceMap", () => { + it("maps each symbol to its 1-based line number", () => { + const yamlText = [ + "sdk: javascript", + "features:", + " auth.sign_up:", + " status: implemented", + " symbols:", + " - AuthClient.signUp", + " - AuthClient.signUpAnon", + " auth.sign_in:", + " status: implemented", + " symbols:", + " - AuthClient.signIn", + ].join("\n"); + + const { symbolLines } = buildSourceMap(yamlText); + + expect(symbolLines.get("AuthClient.signUp")).toBe(6); + expect(symbolLines.get("AuthClient.signUpAnon")).toBe(7); + expect(symbolLines.get("AuthClient.signIn")).toBe(11); + }); + + it("maps each feature id to its own key line", () => { + const yamlText = [ + "sdk: javascript", + "features:", + " auth.sign_up:", + " status: implemented", + " auth.mfa.enroll:", + " status: implemented", + ].join("\n"); + + const { featureLines } = buildSourceMap(yamlText); + + expect(featureLines.get("auth.sign_up")).toBe(3); + expect(featureLines.get("auth.mfa.enroll")).toBe(5); + }); + + it("returns empty maps for a compliance file with no features", () => { + const { symbolLines, featureLines } = buildSourceMap("sdk: javascript\nfeatures: {}\n"); + expect(symbolLines.size).toBe(0); + expect(featureLines.size).toBe(0); + }); +}); From d509b46b9a5a6c18577290732088190ab8bed539 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 10 Jul 2026 11:06:03 -0300 Subject: [PATCH 3/6] feat(capability-matrix): add check-drift CLI for CI Check 2 --- scripts/capability-matrix/package.json | 1 + scripts/capability-matrix/src/check-drift.ts | 65 ++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 scripts/capability-matrix/src/check-drift.ts diff --git a/scripts/capability-matrix/package.json b/scripts/capability-matrix/package.json index 2f75e49..8248e5a 100644 --- a/scripts/capability-matrix/package.json +++ b/scripts/capability-matrix/package.json @@ -12,6 +12,7 @@ "normalize-symbolgraph": "tsx src/normalize-symbolgraph-cli.ts", "normalize-griffe": "tsx src/normalize-griffe-cli.ts", "check-api-symbols": "tsx src/check-api-symbols.ts", + "check-drift": "tsx src/check-drift.ts", "aggregate": "tsx src/aggregate.ts", "report": "tsx src/cli.ts report", "test": "vitest run", diff --git a/scripts/capability-matrix/src/check-drift.ts b/scripts/capability-matrix/src/check-drift.ts new file mode 100644 index 0000000..13d7554 --- /dev/null +++ b/scripts/capability-matrix/src/check-drift.ts @@ -0,0 +1,65 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse } from "yaml"; +import { checkDrift, formatDriftSummary } from "./drift-check.js"; +import { buildSourceMap } from "./compliance-source-map.js"; +import type { RawCompliance } from "./compliance.js"; +import type { ParseResult } from "./normalize-typedoc.js"; + +async function main(): Promise { + const [compliancePath, prFile, annotationPath] = process.argv.slice(2); + + if (!compliancePath || !prFile) { + console.error("Usage: check-drift [annotation-file-path]"); + return; + } + + let complianceText: string; + let compliance: RawCompliance; + + try { + complianceText = readFileSync(resolve(compliancePath), "utf8"); + compliance = parse(complianceText) as RawCompliance; + } catch (e) { + console.error(`Failed to read compliance file: ${(e as Error).message}`); + return; + } + + let prResult: ParseResult; + try { + prResult = JSON.parse(readFileSync(resolve(prFile), "utf8")) as ParseResult; + } catch (e) { + console.error(`Failed to read PR symbols: ${(e as Error).message}`); + return; + } + + const findings = checkDrift(prResult.symbols, compliance); + + if (findings.length === 0) { + console.log("✅ No capability matrix drift detected."); + return; + } + + const sourceMap = buildSourceMap(complianceText); + const annotationFile = annotationPath ?? compliancePath; + + for (const finding of findings) { + const line = finding.symbol + ? sourceMap.symbolLines.get(finding.symbol) + : sourceMap.featureLines.get(finding.featureId); + const message = finding.symbol + ? `${finding.featureId}: expected symbol ${finding.symbol} not found in ${compliance.sdk}` + : `${finding.featureId}: marked implemented but has no registered symbols to verify`; + console.log( + line !== undefined + ? `::warning file=${annotationFile},line=${line}::${message}` + : `::warning::${message}`, + ); + } + + const summary = formatDriftSummary(findings, compliance.sdk); + writeFileSync(resolve("drift-summary.md"), summary, "utf8"); + console.error(summary); +} + +main().catch((e) => { console.error(e); }); From 903e22b0c960b4008b27dde829717bec66ec1fde Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 10 Jul 2026 11:11:02 -0300 Subject: [PATCH 4/6] feat(ci): add sdk-compliance-check-drift composite action --- .../sdk-compliance-check-drift/action.yml | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/actions/sdk-compliance-check-drift/action.yml diff --git a/.github/actions/sdk-compliance-check-drift/action.yml b/.github/actions/sdk-compliance-check-drift/action.yml new file mode 100644 index 0000000..6c8ab3e --- /dev/null +++ b/.github/actions/sdk-compliance-check-drift/action.yml @@ -0,0 +1,61 @@ +name: Check capability matrix drift +description: Non-blocking check — warns when an `implemented` capability's registered symbols can't be found by the parser, or has none registered at all. + +inputs: + compliance-file: + description: Path to sdk-compliance.yaml in the calling repo + default: sdk-compliance.yaml + +runs: + using: composite + steps: + - name: Check capability matrix drift + shell: bash + env: + COMPLIANCE_FILE: ${{ inputs.compliance-file }} + run: | + npm run check-drift -- \ + "$GITHUB_WORKSPACE/_sdk-pr/$COMPLIANCE_FILE" \ + "$GITHUB_WORKSPACE/pr-symbols.json" \ + "$COMPLIANCE_FILE" + working-directory: _sdk-spec/scripts/capability-matrix + + - name: Upsert or clear drift comment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + const marker = ''; + const summaryPath = '_sdk-spec/scripts/capability-matrix/drift-summary.md'; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + + if (fs.existsSync(summaryPath)) { + const body = fs.readFileSync(summaryPath, 'utf8'); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + } else if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } From 76262de536496057f1bc4134157d9e0fbd291935 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 10 Jul 2026 11:15:29 -0300 Subject: [PATCH 5/6] ci: wire capability matrix drift check into all 4 SDK compliance workflows --- .github/workflows/validate-sdk-compliance-dart.yml | 6 ++++++ .github/workflows/validate-sdk-compliance-javascript.yml | 6 ++++++ .github/workflows/validate-sdk-compliance-python.yml | 6 ++++++ .github/workflows/validate-sdk-compliance-swift.yml | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/.github/workflows/validate-sdk-compliance-dart.yml b/.github/workflows/validate-sdk-compliance-dart.yml index dd803e5..d795fb4 100644 --- a/.github/workflows/validate-sdk-compliance-dart.yml +++ b/.github/workflows/validate-sdk-compliance-dart.yml @@ -26,6 +26,8 @@ jobs: name: Check public API against capability matrix if: github.event_name == 'pull_request' runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - uses: supabase/sdk/.github/actions/sdk-compliance-check-setup@main with: @@ -52,3 +54,7 @@ jobs: - uses: supabase/sdk/.github/actions/sdk-compliance-check-symbols@main with: compliance-file: ${{ inputs.compliance-file }} + + - uses: supabase/sdk/.github/actions/sdk-compliance-check-drift@main + with: + compliance-file: ${{ inputs.compliance-file }} diff --git a/.github/workflows/validate-sdk-compliance-javascript.yml b/.github/workflows/validate-sdk-compliance-javascript.yml index 006a60b..08b9d1b 100644 --- a/.github/workflows/validate-sdk-compliance-javascript.yml +++ b/.github/workflows/validate-sdk-compliance-javascript.yml @@ -34,6 +34,8 @@ jobs: name: Check public API against capability matrix if: github.event_name == 'pull_request' runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - uses: supabase/sdk/.github/actions/sdk-compliance-check-setup@main with: @@ -71,3 +73,7 @@ jobs: - uses: supabase/sdk/.github/actions/sdk-compliance-check-symbols@main with: compliance-file: ${{ inputs.compliance-file }} + + - uses: supabase/sdk/.github/actions/sdk-compliance-check-drift@main + with: + compliance-file: ${{ inputs.compliance-file }} diff --git a/.github/workflows/validate-sdk-compliance-python.yml b/.github/workflows/validate-sdk-compliance-python.yml index 871100a..3ffdc78 100644 --- a/.github/workflows/validate-sdk-compliance-python.yml +++ b/.github/workflows/validate-sdk-compliance-python.yml @@ -34,6 +34,8 @@ jobs: name: Check public API against capability matrix if: github.event_name == 'pull_request' runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - uses: supabase/sdk/.github/actions/sdk-compliance-check-setup@main with: @@ -79,3 +81,7 @@ jobs: - uses: supabase/sdk/.github/actions/sdk-compliance-check-symbols@main with: compliance-file: ${{ inputs.compliance-file }} + + - uses: supabase/sdk/.github/actions/sdk-compliance-check-drift@main + with: + compliance-file: ${{ inputs.compliance-file }} diff --git a/.github/workflows/validate-sdk-compliance-swift.yml b/.github/workflows/validate-sdk-compliance-swift.yml index 49ad9fb..dc2298f 100644 --- a/.github/workflows/validate-sdk-compliance-swift.yml +++ b/.github/workflows/validate-sdk-compliance-swift.yml @@ -26,6 +26,8 @@ jobs: name: Check public API against capability matrix if: github.event_name == 'pull_request' runs-on: macos-latest + permissions: + pull-requests: write steps: - uses: supabase/sdk/.github/actions/sdk-compliance-check-setup@main with: @@ -69,3 +71,7 @@ jobs: - uses: supabase/sdk/.github/actions/sdk-compliance-check-symbols@main with: compliance-file: ${{ inputs.compliance-file }} + + - uses: supabase/sdk/.github/actions/sdk-compliance-check-drift@main + with: + compliance-file: ${{ inputs.compliance-file }} From cbc7e5afea5399c8a6e98e4672afddd44c1feb74 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 10 Jul 2026 11:25:00 -0300 Subject: [PATCH 6/6] fix: address final whole-branch review findings for CI Check 2 drift warning - Wrap the drift-comment github-script body in try/catch so a read-only GITHUB_TOKEN on fork PRs (403 on write calls) can't fail the job; the ::warning:: annotations already carry the signal, the sticky comment is best-effort. - Restore contents: read alongside pull-requests: write on the check job's permissions block in all 4 validate-sdk-compliance-*.yml workflows, since any job-level permissions key zeroes out unlisted scopes and checkout needs contents: read. - Add docs/capability-matrix.md so the drift summary's doc link resolves. - Sanitize newlines out of featureId/symbol before building ::warning:: annotation messages to prevent workflow-command injection via YAML. --- .../sdk-compliance-check-drift/action.yml | 55 +++++++++++-------- .../validate-sdk-compliance-dart.yml | 1 + .../validate-sdk-compliance-javascript.yml | 1 + .../validate-sdk-compliance-python.yml | 1 + .../validate-sdk-compliance-swift.yml | 1 + docs/capability-matrix.md | 30 ++++++++++ scripts/capability-matrix/src/check-drift.ts | 11 +++- 7 files changed, 74 insertions(+), 26 deletions(-) create mode 100644 docs/capability-matrix.md diff --git a/.github/actions/sdk-compliance-check-drift/action.yml b/.github/actions/sdk-compliance-check-drift/action.yml index 6c8ab3e..8c54347 100644 --- a/.github/actions/sdk-compliance-check-drift/action.yml +++ b/.github/actions/sdk-compliance-check-drift/action.yml @@ -28,34 +28,41 @@ runs: const marker = ''; const summaryPath = '_sdk-spec/scripts/capability-matrix/drift-summary.md'; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = comments.find((c) => c.body && c.body.includes(marker)); + try { + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); - if (fs.existsSync(summaryPath)) { - const body = fs.readFileSync(summaryPath, 'utf8'); - if (existing) { - await github.rest.issues.updateComment({ + if (fs.existsSync(summaryPath)) { + const body = fs.readFileSync(summaryPath, 'utf8'); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + } else if (existing) { + await github.rest.issues.deleteComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, }); } - } else if (existing) { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - }); + } catch (error) { + // Fork PRs get a read-only GITHUB_TOKEN, so writes here 403. This + // comment is a best-effort enhancement — the ::warning:: annotations + // from the CLI step already carry the signal, so never fail the job. + console.error('Could not upsert/clear drift comment:', error); } diff --git a/.github/workflows/validate-sdk-compliance-dart.yml b/.github/workflows/validate-sdk-compliance-dart.yml index d795fb4..9c494c7 100644 --- a/.github/workflows/validate-sdk-compliance-dart.yml +++ b/.github/workflows/validate-sdk-compliance-dart.yml @@ -27,6 +27,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - uses: supabase/sdk/.github/actions/sdk-compliance-check-setup@main diff --git a/.github/workflows/validate-sdk-compliance-javascript.yml b/.github/workflows/validate-sdk-compliance-javascript.yml index 08b9d1b..efc28ab 100644 --- a/.github/workflows/validate-sdk-compliance-javascript.yml +++ b/.github/workflows/validate-sdk-compliance-javascript.yml @@ -35,6 +35,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - uses: supabase/sdk/.github/actions/sdk-compliance-check-setup@main diff --git a/.github/workflows/validate-sdk-compliance-python.yml b/.github/workflows/validate-sdk-compliance-python.yml index 3ffdc78..596f2c8 100644 --- a/.github/workflows/validate-sdk-compliance-python.yml +++ b/.github/workflows/validate-sdk-compliance-python.yml @@ -35,6 +35,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - uses: supabase/sdk/.github/actions/sdk-compliance-check-setup@main diff --git a/.github/workflows/validate-sdk-compliance-swift.yml b/.github/workflows/validate-sdk-compliance-swift.yml index dc2298f..fbc141c 100644 --- a/.github/workflows/validate-sdk-compliance-swift.yml +++ b/.github/workflows/validate-sdk-compliance-swift.yml @@ -27,6 +27,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: macos-latest permissions: + contents: read pull-requests: write steps: - uses: supabase/sdk/.github/actions/sdk-compliance-check-setup@main diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md new file mode 100644 index 0000000..f43d3d5 --- /dev/null +++ b/docs/capability-matrix.md @@ -0,0 +1,30 @@ +# Capability Matrix + +The capability matrix is the canonical feature registry for all Supabase client SDKs. It defines what features exist (ID, name, description, grouping) across the supported client SDKs, and each SDK repo declares which features it implements via its own `sdk-compliance.yaml` file. + +## Feature IDs + +Feature IDs use three segments: `{area}.{group}.{method}` (e.g., `auth.sign_in.email`, `storage.buckets.create`). IDs are defined in this repo's `capabilities/*.yaml` files and must be globally unique. + +## SDK Compliance Format + +Each SDK repo hosts a `sdk-compliance.yaml` at a known path: + +```yaml +sdk: javascript +features: + auth.sign_in.email: implemented + auth.mfa.enroll: + status: partially_implemented + note: "TOTP only" + symbols: + - GoTrueClient.mfaEnroll + storage.objects.upload: not_implemented +``` + +Valid status values: `implemented`, `partially_implemented`, `not_implemented`, `not_applicable`. + +The optional `symbols` field lists the public API symbol(s) that implement a feature. CI uses it two ways: + +- **New-symbol check** (blocking): a PR that adds a new public symbol not listed under any feature's `symbols` fails, prompting the author to register it. +- **Drift check** (non-blocking warning): every feature marked `implemented` with a `symbols` list is periodically re-verified against the SDK's actual public API. If a registered symbol can no longer be found (renamed, removed) — or if an `implemented` feature has no `symbols` registered at all to verify against — CI posts a warning so the entry can be corrected. diff --git a/scripts/capability-matrix/src/check-drift.ts b/scripts/capability-matrix/src/check-drift.ts index 13d7554..8febf2f 100644 --- a/scripts/capability-matrix/src/check-drift.ts +++ b/scripts/capability-matrix/src/check-drift.ts @@ -6,6 +6,12 @@ import { buildSourceMap } from "./compliance-source-map.js"; import type { RawCompliance } from "./compliance.js"; import type { ParseResult } from "./normalize-typedoc.js"; +// ponytail: workflow-command annotations are single-line; strip newlines so a +// crafted featureId/symbol in committed YAML can't inject extra `::` commands. +function sanitize(s: string): string { + return s.replace(/[\r\n]+/g, " "); +} + async function main(): Promise { const [compliancePath, prFile, annotationPath] = process.argv.slice(2); @@ -47,9 +53,10 @@ async function main(): Promise { const line = finding.symbol ? sourceMap.symbolLines.get(finding.symbol) : sourceMap.featureLines.get(finding.featureId); + const featureId = sanitize(finding.featureId); const message = finding.symbol - ? `${finding.featureId}: expected symbol ${finding.symbol} not found in ${compliance.sdk}` - : `${finding.featureId}: marked implemented but has no registered symbols to verify`; + ? `${featureId}: expected symbol ${sanitize(finding.symbol)} not found in ${compliance.sdk}` + : `${featureId}: marked implemented but has no registered symbols to verify`; console.log( line !== undefined ? `::warning file=${annotationFile},line=${line}::${message}`