Skip to content

Commit faba359

Browse files
authored
ci: add reusable workflow to sync new capability IDs into SDK compliance files (#65)
* ci: add reusable workflow to sync new capability IDs into SDK compliance files * ci: accept client-id and make app-id optional for the sync workflow * ci: port compliance sync script to TypeScript to match matrix tooling * ci: harden sync workflow — late token, no persisted creds, base-branch checkout, fetch before force-push * refactor: derive sync insertion points from the YAML syntax tree; drop async main * refactor(sync): insert IDs via the yaml writer instead of raw text splicing
1 parent 5daeaf5 commit faba359

3 files changed

Lines changed: 247 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
name: Sync SDK Compliance Matrix (reusable)
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
compliance-file:
7+
description: Path to sdk-compliance.yaml in the calling repo
8+
type: string
9+
default: sdk-compliance.yaml
10+
base-branch:
11+
description: Branch to open the pull request against
12+
type: string
13+
default: main
14+
sdk-ref:
15+
description: Ref to check out from supabase/sdk for the spec and script
16+
type: string
17+
default: main
18+
secrets:
19+
app-id:
20+
description: GitHub App ID used to open the pull request (provide this or client-id)
21+
required: false
22+
client-id:
23+
description: GitHub App client ID used to open the pull request (provide this or app-id)
24+
required: false
25+
private-key:
26+
description: GitHub App private key used to open the pull request
27+
required: true
28+
29+
jobs:
30+
sync:
31+
name: Sync new capability IDs
32+
runs-on: ubuntu-latest
33+
timeout-minutes: 10
34+
permissions:
35+
contents: write
36+
pull-requests: write
37+
steps:
38+
- name: Checkout SDK repo
39+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
40+
with:
41+
ref: ${{ inputs.base-branch }}
42+
persist-credentials: false
43+
44+
- name: Checkout capability spec
45+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
46+
with:
47+
repository: supabase/sdk
48+
ref: ${{ inputs.sdk-ref }}
49+
path: _sdk-spec
50+
persist-credentials: false
51+
52+
- name: Setup Node
53+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
54+
with:
55+
node-version: "22"
56+
57+
- name: Install capability matrix tooling
58+
run: npm ci
59+
working-directory: _sdk-spec/scripts/capability-matrix
60+
61+
- name: Insert new capability IDs
62+
working-directory: _sdk-spec/scripts/capability-matrix
63+
env:
64+
COMPLIANCE_FILE: ${{ inputs.compliance-file }}
65+
run: |
66+
npm run sync-compliance -- \
67+
"$GITHUB_WORKSPACE/$COMPLIANCE_FILE" \
68+
--new-ids-output "$GITHUB_WORKSPACE/new_ids.txt"
69+
70+
- name: Generate token
71+
id: app-token
72+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
73+
with:
74+
app-id: ${{ secrets.app-id }}
75+
client-id: ${{ secrets.client-id }}
76+
private-key: ${{ secrets.private-key }}
77+
78+
- name: Create pull request
79+
env:
80+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
81+
COMPLIANCE_FILE: ${{ inputs.compliance-file }}
82+
BASE_BRANCH: ${{ inputs.base-branch }}
83+
run: |
84+
if git diff --quiet -- "$COMPLIANCE_FILE"; then
85+
echo "No new capability IDs; nothing to do."
86+
exit 0
87+
fi
88+
89+
branch="chore/sync-compliance-matrix"
90+
git config user.name "github-actions[bot]"
91+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
92+
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
93+
git fetch origin "$branch:refs/remotes/origin/$branch" || true
94+
git checkout -b "$branch"
95+
git add "$COMPLIANCE_FILE"
96+
git commit -m "chore: sync new capability IDs from canonical spec"
97+
git push --force-with-lease origin "$branch"
98+
99+
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')"
100+
while IFS= read -r feature_id; do
101+
[ -n "$feature_id" ] && body="${body}- \`${feature_id}\`"$'\n'
102+
done < new_ids.txt
103+
104+
if [ -n "$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')" ]; then
105+
echo "Pull request already open for $branch; pushed the update."
106+
else
107+
gh pr create \
108+
--base "$BASE_BRANCH" \
109+
--head "$branch" \
110+
--title "chore: sync new capability IDs from canonical spec" \
111+
--body "$body"
112+
fi

scripts/capability-matrix/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"validate": "tsx src/cli.ts validate",
99
"validate:online": "tsx src/cli.ts validate --online",
1010
"validate-compliance": "tsx src/compliance-cli.ts",
11+
"sync-compliance": "tsx src/sync-compliance-cli.ts",
1112
"normalize-typedoc": "tsx src/normalize-typedoc-cli.ts",
1213
"normalize-symbolgraph": "tsx src/normalize-symbolgraph-cli.ts",
1314
"normalize-griffe": "tsx src/normalize-griffe-cli.ts",
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { readFileSync, writeFileSync } from "node:fs";
2+
import { fileURLToPath } from "node:url";
3+
import { dirname, join, resolve } from "node:path";
4+
import { parseDocument, isMap, isScalar } from "yaml";
5+
import { loadAreas } from "./load.js";
6+
import { collectFeatureIds, findMissingFeatureIds } from "./compliance.js";
7+
import type { RawCompliance } from "./compliance.js";
8+
9+
// Inserts canonical capability IDs that are missing from an SDK's
10+
// sdk-compliance.yaml as `not_implemented`. Canonical IDs and compliance keys
11+
// share the `area.group.feature` shape, so each new ID is placed next to its
12+
// existing siblings (same `area.group.` prefix); an ID whose group has no local
13+
// sibling yet is appended under a comment for manual placement.
14+
//
15+
// Entries are added to the parsed YAML document and re-serialized with the yaml
16+
// writer, so the output is always valid YAML regardless of the source layout.
17+
// Re-serializing may collapse hand-wrapped `note:` scalars onto a single line.
18+
// That is fine; the only concern here is keeping the file valid.
19+
20+
function repoRoot(): string {
21+
const here = dirname(fileURLToPath(import.meta.url));
22+
return resolve(here, "..", "..", "..");
23+
}
24+
25+
function parseArguments(argv: string[]): {
26+
compliancePath: string;
27+
newIdsOutput: string;
28+
} {
29+
let compliancePath: string | undefined;
30+
let newIdsOutput = "new_ids.txt";
31+
for (let index = 0; index < argv.length; index++) {
32+
const argument = argv[index];
33+
if (argument === "--new-ids-output") {
34+
newIdsOutput = argv[++index];
35+
} else if (!argument.startsWith("--") && compliancePath === undefined) {
36+
compliancePath = argument;
37+
}
38+
}
39+
if (!compliancePath) {
40+
console.error(
41+
"Usage: sync-compliance-cli.ts <path-to-sdk-compliance.yaml> [--new-ids-output <path>]",
42+
);
43+
process.exit(1);
44+
}
45+
return { compliancePath, newIdsOutput };
46+
}
47+
48+
function main(): void {
49+
const { compliancePath, newIdsOutput } = parseArguments(
50+
process.argv.slice(2),
51+
);
52+
53+
const { areas, findings } = loadAreas(join(repoRoot(), "capabilities"));
54+
if (findings.some((finding) => finding.level === "error")) {
55+
console.error(
56+
"Failed to load canonical capability spec — check this repo's capabilities/*.yaml",
57+
);
58+
process.exit(1);
59+
}
60+
const knownIds = collectFeatureIds(areas);
61+
62+
const text = readFileSync(resolve(compliancePath), "utf8");
63+
const doc = parseDocument(text);
64+
if (doc.errors.length > 0) {
65+
console.error(`YAML parse error: ${doc.errors[0].message}`);
66+
process.exit(1);
67+
}
68+
const raw = doc.toJS() as RawCompliance;
69+
70+
const missing = findMissingFeatureIds(raw, knownIds);
71+
writeFileSync(
72+
resolve(newIdsOutput),
73+
missing.length ? missing.join("\n") + "\n" : "",
74+
);
75+
if (missing.length === 0) {
76+
console.log("No new capability IDs.");
77+
return;
78+
}
79+
80+
const features = doc.get("features", true);
81+
if (!isMap(features)) {
82+
console.error("Compliance file has no `features` map to sync into.");
83+
process.exit(1);
84+
}
85+
86+
const idIndex = new Map<string, number>();
87+
features.items.forEach((pair, index) => {
88+
if (isScalar(pair.key) && typeof pair.key.value === "string") {
89+
idIndex.set(pair.key.value, index);
90+
}
91+
});
92+
93+
const byPrefix = new Map<string, string[]>();
94+
for (const id of missing) {
95+
const prefix = id.slice(0, id.lastIndexOf(".") + 1);
96+
const bucket = byPrefix.get(prefix);
97+
if (bucket) bucket.push(id);
98+
else byPrefix.set(prefix, [id]);
99+
}
100+
101+
const insertions: { index: number; ids: string[] }[] = [];
102+
const orphans: string[] = [];
103+
for (const [prefix, ids] of byPrefix) {
104+
const siblingIndices = [...idIndex.entries()]
105+
.filter(([existingId]) => existingId.startsWith(prefix))
106+
.map(([, index]) => index);
107+
if (siblingIndices.length === 0) {
108+
orphans.push(...ids);
109+
continue;
110+
}
111+
// Insert right after the last existing sibling so the new IDs land in-group.
112+
insertions.push({ index: Math.max(...siblingIndices) + 1, ids });
113+
}
114+
115+
// Splice back-to-front so earlier indices stay valid as items shift.
116+
for (const { index, ids } of insertions.sort((a, b) => b.index - a.index)) {
117+
const pairs = ids.map((id) => doc.createPair(id, "not_implemented"));
118+
features.items.splice(index, 0, ...pairs);
119+
}
120+
121+
if (orphans.length > 0) {
122+
const pairs = orphans.map((id) => doc.createPair(id, "not_implemented"));
123+
(pairs[0].key as { commentBefore?: string }).commentBefore =
124+
" Newly synced from the canonical spec; no local group yet, place manually.";
125+
features.items.push(...pairs);
126+
}
127+
128+
writeFileSync(resolve(compliancePath), doc.toString({ lineWidth: 0 }));
129+
console.log(
130+
`Added ${missing.length} new capability IDs (${orphans.length} without an existing group).`,
131+
);
132+
}
133+
134+
main();

0 commit comments

Comments
 (0)