66 types : [opened, synchronize, reopened]
77 paths :
88 - " extensions/**"
9+ - " .schemas/canvas.schema.json"
910
1011permissions :
1112 contents : read
12- pull-requests : write
1313
1414jobs :
1515 validate :
1616 runs-on : ubuntu-latest
1717 steps :
18- - name : Checkout code
18+ - name : Checkout
1919 uses : actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
2020 with :
2121 fetch-depth : 0
2222
23- - name : Validate changed canvas extensions
24- uses : actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1 .0
23+ - name : Setup Node.js
24+ uses : actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4 .0
2525 with :
26- script : |
27- const fs = require('fs');
28- const path = require('path');
29-
30- // Collect changed extension directories from the PR diff
31- const { execSync } = require('child_process');
32- const changedFiles = execSync(
33- `git diff --name-only origin/${{ github.base_ref }}...HEAD`
34- ).toString().trim().split('\n').filter(Boolean);
35-
36- const EXTENSIONS_DIR = 'extensions';
37- const EXTERNAL_ASSETS_DIR = 'external-assets';
38-
39- const changedExtDirs = new Set();
40- for (const file of changedFiles) {
41- const parts = file.split('/');
42- if (parts[0] === EXTENSIONS_DIR && parts.length >= 2) {
43- const extName = parts[1];
44- // Skip the external-assets directory — it's not a canvas extension
45- // Also skip external.json and other files at extensions root level
46- if (extName !== EXTERNAL_ASSETS_DIR && !extName.includes('.')) {
47- changedExtDirs.add(path.join(EXTENSIONS_DIR, extName));
48- }
49- }
50- }
51-
52- if (changedExtDirs.size === 0) {
53- console.log('No canvas extension directories changed — skipping validation.');
54- return;
55- }
56-
57- console.log(`Validating ${changedExtDirs.size} extension(s): ${[...changedExtDirs].join(', ')}`);
58-
59- const errors = [];
60-
61- for (const extDir of changedExtDirs) {
62- if (!fs.existsSync(extDir)) {
63- // Directory was deleted — skip
64- console.log(`${extDir} no longer exists (deleted?), skipping.`);
65- continue;
66- }
67-
68- const extName = path.basename(extDir);
69-
70- // Rule 1: must contain extension.mjs
71- const mainFile = path.join(extDir, 'extension.mjs');
72- if (!fs.existsSync(mainFile)) {
73- errors.push(
74- `**\`${extDir}\`**: missing required \`extension.mjs\`. ` +
75- `Canvas extensions must have their entry point named \`extension.mjs\`.`
76- );
77- }
78-
79- // Rule 2: must contain assets/preview.png
80- const previewFile = path.join(extDir, 'assets', 'preview.png');
81- if (!fs.existsSync(previewFile)) {
82- errors.push(
83- `**\`${extDir}\`**: missing required \`assets/preview.png\`. ` +
84- `Canvas extensions must include a screenshot at \`assets/preview.png\` ` +
85- `so reviewers and users can preview the extension before installing it.`
86- );
87- }
88- }
89-
90- if (errors.length === 0) {
91- console.log('✅ All changed canvas extensions pass validation.');
92- return;
93- }
94-
95- const isFork = context.payload.pull_request.head.repo.fork;
96- const body = [
97- '❌ **Canvas extension validation failed**',
98- '',
99- 'The following issue(s) were found in changed canvas extension(s):',
100- '',
101- ...errors.map(e => `- ${e}`),
102- '',
103- '---',
104- '',
105- '### Required structure for canvas extensions',
106- '',
107- 'Each extension folder under `extensions/` must contain:',
108- '',
109- '| Path | Required | Description |',
110- '|------|----------|-------------|',
111- '| `extension.mjs` | ✅ | Entry point for the canvas extension |',
112- '| `assets/preview.png` | ✅ | Screenshot shown on the website and in the marketplace |',
113- '',
114- 'Please add the missing file(s) and push an update to this PR.',
115- ].join('\n');
116-
117- if (!isFork) {
118- try {
119- await github.rest.pulls.createReview({
120- owner: context.repo.owner,
121- repo: context.repo.repo,
122- pull_number: context.issue.number,
123- event: 'REQUEST_CHANGES',
124- body
125- });
126- } catch (error) {
127- core.warning(`Could not post PR review: ${error.message}`);
128- core.warning(body);
129- }
130- } else {
131- core.warning('PR is from a fork — skipping createReview to avoid permission errors.');
132- core.warning(body);
133- }
134-
135- core.setFailed(`Canvas extension validation failed with ${errors.length} error(s).`);
26+ node-version : " 22"
27+ cache : " npm"
28+
29+ - name : Install dependencies
30+ run : npm ci --ignore-scripts
31+
32+ - name : Validate changed canvas extensions
33+ run : |
34+ set -euo pipefail
35+
36+ # Validate schema structure once, even for schema-only PRs.
37+ if ! node ./eng/validate-json-schema.mjs --schema .schemas/canvas.schema.json; then
38+ echo "❌ .schemas/canvas.schema.json failed schema compilation"
39+ exit 1
40+ fi
41+
42+ # Collect changed extension directories.
43+ # Use null-terminated (-z) output from git diff so filenames containing newlines
44+ # or other special characters are read atomically (matches the pattern in skill-check.yml).
45+ # Each extracted name is then validated against a strict allowlist regex before use,
46+ # rejecting anything containing shell metacharacters ($, (, ), spaces, etc.).
47+ declare -A seen_dirs=()
48+ changed_extensions=()
49+ errors=()
50+
51+ while IFS= read -r -d '' file; do
52+ case "$file" in
53+ extensions/*)
54+ ext_name="${file#extensions/}"
55+ ext_name="${ext_name%%/*}"
56+
57+ if [ "$ext_name" = "external-assets" ]; then
58+ continue
59+ fi
60+
61+ # Allowlist: extension directory names must be lowercase alphanumeric + hyphens only.
62+ # Fail for disallowed names so a PR cannot bypass validation by using a nonconforming folder.
63+ if [[ ! "$ext_name" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
64+ errors+=("\`extensions/$ext_name\`: invalid extension directory name (must match ^[a-z0-9][a-z0-9-]*$).")
65+ continue
66+ fi
67+
68+ if [ -z "${seen_dirs[$ext_name]+x}" ]; then
69+ seen_dirs["$ext_name"]=1
70+ changed_extensions+=("extensions/$ext_name")
71+ fi
72+ ;;
73+ esac
74+ done < <(git diff --name-only -z "origin/${{ github.base_ref }}...HEAD")
75+
76+ if [ "${#changed_extensions[@]}" -eq 0 ]; then
77+ if [ "${#errors[@]}" -ne 0 ]; then
78+ echo "❌ Canvas extension validation failed:"
79+ for error in "${errors[@]}"; do
80+ echo "- $error"
81+ done
82+ exit 1
83+ fi
84+ echo "No canvas extension directories changed — skipping validation."
85+ exit 0
86+ fi
87+
88+ echo "Validating ${#changed_extensions[@]} extension(s): ${changed_extensions[*]}"
89+
90+ for ext_dir in "${changed_extensions[@]}"; do
91+ if [ ! -d "$ext_dir" ]; then
92+ echo "$ext_dir no longer exists (deleted?), skipping."
93+ continue
94+ fi
95+
96+ if [ ! -f "$ext_dir/extension.mjs" ]; then
97+ errors+=("\`$ext_dir\`: missing required \`extension.mjs\`.")
98+ fi
99+
100+ if [ ! -f "$ext_dir/canvas.json" ]; then
101+ errors+=("\`$ext_dir\`: missing required \`canvas.json\`.")
102+ continue
103+ fi
104+
105+ if [ ! -f "$ext_dir/assets/preview.png" ]; then
106+ errors+=("\`$ext_dir\`: missing required \`assets/preview.png\`.")
107+ fi
108+
109+ if ! schema_output="$(node ./eng/validate-json-schema.mjs --schema .schemas/canvas.schema.json --data "$ext_dir/canvas.json" 2>&1)"; then
110+ condensed_output="$(echo "$schema_output" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g')"
111+ errors+=("\`$ext_dir/canvas.json\`: schema validation failed against \`.schemas/canvas.schema.json\` ($condensed_output).")
112+ continue
113+ fi
114+
115+ mapfile -t screenshot_paths < <(
116+ node -e 'const fs = require("fs"); const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const paths = [manifest?.screenshots?.icon?.path, manifest?.screenshots?.gallery?.path].filter((value) => typeof value === "string" && value.trim().length > 0); for (const value of [...new Set(paths)]) { console.log(value); }' "$ext_dir/canvas.json"
117+ )
118+
119+ for screenshot_path in "${screenshot_paths[@]}"; do
120+ if [[ ! "$screenshot_path" =~ ^assets/([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*\.(png|jpe?g|gif|webp|svg)$ ]]; then
121+ errors+=("\`$ext_dir/canvas.json\`: screenshot path \`$screenshot_path\` is not a valid assets path.")
122+ continue
123+ fi
124+ if [ ! -f "$ext_dir/$screenshot_path" ]; then
125+ errors+=("\`$ext_dir/canvas.json\`: screenshot path \`$screenshot_path\` does not exist in the extension directory.")
126+ fi
127+ done
128+ done
129+
130+ if [ "${#errors[@]}" -ne 0 ]; then
131+ echo "❌ Canvas extension validation failed:"
132+ for error in "${errors[@]}"; do
133+ echo "- $error"
134+ done
135+ exit 1
136+ fi
137+
138+ echo "✅ All changed canvas extensions passed validation."
0 commit comments