diff --git a/.github/workflows/sync-sdk-compliance.yml b/.github/workflows/sync-sdk-compliance.yml new file mode 100644 index 0000000..b7c7959 --- /dev/null +++ b/.github/workflows/sync-sdk-compliance.yml @@ -0,0 +1,112 @@ +name: Sync SDK Compliance Matrix (reusable) + +on: + workflow_call: + inputs: + compliance-file: + description: Path to sdk-compliance.yaml in the calling repo + type: string + default: sdk-compliance.yaml + base-branch: + description: Branch to open the pull request against + type: string + default: main + sdk-ref: + description: Ref to check out from supabase/sdk for the spec and script + type: string + default: main + secrets: + app-id: + description: GitHub App ID used to open the pull request (provide this or client-id) + required: false + client-id: + description: GitHub App client ID used to open the pull request (provide this or app-id) + required: false + private-key: + description: GitHub App private key used to open the pull request + required: true + +jobs: + sync: + name: Sync new capability IDs + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout SDK repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.base-branch }} + persist-credentials: false + + - name: Checkout capability spec + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: supabase/sdk + ref: ${{ inputs.sdk-ref }} + path: _sdk-spec + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Install capability matrix tooling + run: npm ci + working-directory: _sdk-spec/scripts/capability-matrix + + - name: Insert new capability IDs + working-directory: _sdk-spec/scripts/capability-matrix + env: + COMPLIANCE_FILE: ${{ inputs.compliance-file }} + run: | + npm run sync-compliance -- \ + "$GITHUB_WORKSPACE/$COMPLIANCE_FILE" \ + --new-ids-output "$GITHUB_WORKSPACE/new_ids.txt" + + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.app-id }} + client-id: ${{ secrets.client-id }} + private-key: ${{ secrets.private-key }} + + - name: Create pull request + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + COMPLIANCE_FILE: ${{ inputs.compliance-file }} + BASE_BRANCH: ${{ inputs.base-branch }} + run: | + if git diff --quiet -- "$COMPLIANCE_FILE"; then + echo "No new capability IDs; nothing to do." + exit 0 + fi + + branch="chore/sync-compliance-matrix" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git fetch origin "$branch:refs/remotes/origin/$branch" || true + git checkout -b "$branch" + git add "$COMPLIANCE_FILE" + git commit -m "chore: sync new capability IDs from canonical spec" + git push --force-with-lease origin "$branch" + + body="$(printf 'New capability IDs from https://github.com/supabase/sdk were added as `not_implemented`.\n\nTriage each one: set the real status and add symbols/notes before merging.\n\n')" + while IFS= read -r feature_id; do + [ -n "$feature_id" ] && body="${body}- \`${feature_id}\`"$'\n' + done < new_ids.txt + + if [ -n "$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')" ]; then + echo "Pull request already open for $branch; pushed the update." + else + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$branch" \ + --title "chore: sync new capability IDs from canonical spec" \ + --body "$body" + fi diff --git a/scripts/capability-matrix/package.json b/scripts/capability-matrix/package.json index 8248e5a..9649664 100644 --- a/scripts/capability-matrix/package.json +++ b/scripts/capability-matrix/package.json @@ -8,6 +8,7 @@ "validate": "tsx src/cli.ts validate", "validate:online": "tsx src/cli.ts validate --online", "validate-compliance": "tsx src/compliance-cli.ts", + "sync-compliance": "tsx src/sync-compliance-cli.ts", "normalize-typedoc": "tsx src/normalize-typedoc-cli.ts", "normalize-symbolgraph": "tsx src/normalize-symbolgraph-cli.ts", "normalize-griffe": "tsx src/normalize-griffe-cli.ts", diff --git a/scripts/capability-matrix/src/sync-compliance-cli.ts b/scripts/capability-matrix/src/sync-compliance-cli.ts new file mode 100644 index 0000000..6d9212d --- /dev/null +++ b/scripts/capability-matrix/src/sync-compliance-cli.ts @@ -0,0 +1,134 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { parseDocument, isMap, isScalar } from "yaml"; +import { loadAreas } from "./load.js"; +import { collectFeatureIds, findMissingFeatureIds } from "./compliance.js"; +import type { RawCompliance } from "./compliance.js"; + +// Inserts canonical capability IDs that are missing from an SDK's +// sdk-compliance.yaml as `not_implemented`. Canonical IDs and compliance keys +// share the `area.group.feature` shape, so each new ID is placed next to its +// existing siblings (same `area.group.` prefix); an ID whose group has no local +// sibling yet is appended under a comment for manual placement. +// +// Entries are added to the parsed YAML document and re-serialized with the yaml +// writer, so the output is always valid YAML regardless of the source layout. +// Re-serializing may collapse hand-wrapped `note:` scalars onto a single line. +// That is fine; the only concern here is keeping the file valid. + +function repoRoot(): string { + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, "..", "..", ".."); +} + +function parseArguments(argv: string[]): { + compliancePath: string; + newIdsOutput: string; +} { + let compliancePath: string | undefined; + let newIdsOutput = "new_ids.txt"; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--new-ids-output") { + newIdsOutput = argv[++index]; + } else if (!argument.startsWith("--") && compliancePath === undefined) { + compliancePath = argument; + } + } + if (!compliancePath) { + console.error( + "Usage: sync-compliance-cli.ts [--new-ids-output ]", + ); + process.exit(1); + } + return { compliancePath, newIdsOutput }; +} + +function main(): void { + const { compliancePath, newIdsOutput } = parseArguments( + process.argv.slice(2), + ); + + const { areas, findings } = loadAreas(join(repoRoot(), "capabilities")); + if (findings.some((finding) => finding.level === "error")) { + console.error( + "Failed to load canonical capability spec — check this repo's capabilities/*.yaml", + ); + process.exit(1); + } + const knownIds = collectFeatureIds(areas); + + const text = readFileSync(resolve(compliancePath), "utf8"); + const doc = parseDocument(text); + if (doc.errors.length > 0) { + console.error(`YAML parse error: ${doc.errors[0].message}`); + process.exit(1); + } + const raw = doc.toJS() as RawCompliance; + + const missing = findMissingFeatureIds(raw, knownIds); + writeFileSync( + resolve(newIdsOutput), + missing.length ? missing.join("\n") + "\n" : "", + ); + if (missing.length === 0) { + console.log("No new capability IDs."); + return; + } + + const features = doc.get("features", true); + if (!isMap(features)) { + console.error("Compliance file has no `features` map to sync into."); + process.exit(1); + } + + const idIndex = new Map(); + features.items.forEach((pair, index) => { + if (isScalar(pair.key) && typeof pair.key.value === "string") { + idIndex.set(pair.key.value, index); + } + }); + + const byPrefix = new Map(); + for (const id of missing) { + const prefix = id.slice(0, id.lastIndexOf(".") + 1); + const bucket = byPrefix.get(prefix); + if (bucket) bucket.push(id); + else byPrefix.set(prefix, [id]); + } + + const insertions: { index: number; ids: string[] }[] = []; + const orphans: string[] = []; + for (const [prefix, ids] of byPrefix) { + const siblingIndices = [...idIndex.entries()] + .filter(([existingId]) => existingId.startsWith(prefix)) + .map(([, index]) => index); + if (siblingIndices.length === 0) { + orphans.push(...ids); + continue; + } + // Insert right after the last existing sibling so the new IDs land in-group. + insertions.push({ index: Math.max(...siblingIndices) + 1, ids }); + } + + // Splice back-to-front so earlier indices stay valid as items shift. + for (const { index, ids } of insertions.sort((a, b) => b.index - a.index)) { + const pairs = ids.map((id) => doc.createPair(id, "not_implemented")); + features.items.splice(index, 0, ...pairs); + } + + if (orphans.length > 0) { + const pairs = orphans.map((id) => doc.createPair(id, "not_implemented")); + (pairs[0].key as { commentBefore?: string }).commentBefore = + " Newly synced from the canonical spec; no local group yet, place manually."; + features.items.push(...pairs); + } + + writeFileSync(resolve(compliancePath), doc.toString({ lineWidth: 0 })); + console.log( + `Added ${missing.length} new capability IDs (${orphans.length} without an existing group).`, + ); +} + +main();