Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build-website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ on:
- instructions
- hooks
- workflows
- extensions
- .schemas/canvas.schema.json
Comment on lines 11 to +15

permissions:
contents: read
Expand Down
231 changes: 117 additions & 114 deletions .github/workflows/validate-canvas-extensions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,130 +6,133 @@ on:
types: [opened, synchronize, reopened]
paths:
- "extensions/**"
- ".schemas/canvas.schema.json"

permissions:
contents: read
pull-requests: write

jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0

- name: Validate changed canvas extensions
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
script: |
const fs = require('fs');
const path = require('path');

// Collect changed extension directories from the PR diff
const { execSync } = require('child_process');
const changedFiles = execSync(
`git diff --name-only origin/${{ github.base_ref }}...HEAD`
).toString().trim().split('\n').filter(Boolean);

const EXTENSIONS_DIR = 'extensions';
const EXTERNAL_ASSETS_DIR = 'external-assets';

const changedExtDirs = new Set();
for (const file of changedFiles) {
const parts = file.split('/');
if (parts[0] === EXTENSIONS_DIR && parts.length >= 2) {
const extName = parts[1];
// Skip the external-assets directory — it's not a canvas extension
// Also skip external.json and other files at extensions root level
if (extName !== EXTERNAL_ASSETS_DIR && !extName.includes('.')) {
changedExtDirs.add(path.join(EXTENSIONS_DIR, extName));
}
}
}

if (changedExtDirs.size === 0) {
console.log('No canvas extension directories changed — skipping validation.');
return;
}

console.log(`Validating ${changedExtDirs.size} extension(s): ${[...changedExtDirs].join(', ')}`);

const errors = [];

for (const extDir of changedExtDirs) {
if (!fs.existsSync(extDir)) {
// Directory was deleted — skip
console.log(`${extDir} no longer exists (deleted?), skipping.`);
continue;
}

const extName = path.basename(extDir);

// Rule 1: must contain extension.mjs
const mainFile = path.join(extDir, 'extension.mjs');
if (!fs.existsSync(mainFile)) {
errors.push(
`**\`${extDir}\`**: missing required \`extension.mjs\`. ` +
`Canvas extensions must have their entry point named \`extension.mjs\`.`
);
}

// Rule 2: must contain assets/preview.png
const previewFile = path.join(extDir, 'assets', 'preview.png');
if (!fs.existsSync(previewFile)) {
errors.push(
`**\`${extDir}\`**: missing required \`assets/preview.png\`. ` +
`Canvas extensions must include a screenshot at \`assets/preview.png\` ` +
`so reviewers and users can preview the extension before installing it.`
);
}
}

if (errors.length === 0) {
console.log('✅ All changed canvas extensions pass validation.');
return;
}

const isFork = context.payload.pull_request.head.repo.fork;
const body = [
'❌ **Canvas extension validation failed**',
'',
'The following issue(s) were found in changed canvas extension(s):',
'',
...errors.map(e => `- ${e}`),
'',
'---',
'',
'### Required structure for canvas extensions',
'',
'Each extension folder under `extensions/` must contain:',
'',
'| Path | Required | Description |',
'|------|----------|-------------|',
'| `extension.mjs` | ✅ | Entry point for the canvas extension |',
'| `assets/preview.png` | ✅ | Screenshot shown on the website and in the marketplace |',
'',
'Please add the missing file(s) and push an update to this PR.',
].join('\n');

if (!isFork) {
try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
event: 'REQUEST_CHANGES',
body
});
} catch (error) {
core.warning(`Could not post PR review: ${error.message}`);
core.warning(body);
}
} else {
core.warning('PR is from a fork — skipping createReview to avoid permission errors.');
core.warning(body);
}

core.setFailed(`Canvas extension validation failed with ${errors.length} error(s).`);
node-version: "22"
cache: "npm"

- name: Install dependencies
run: npm ci --ignore-scripts

- name: Validate changed canvas extensions
run: |
set -euo pipefail

# Validate schema structure once, even for schema-only PRs.
if ! node ./eng/validate-json-schema.mjs --schema .schemas/canvas.schema.json; then
echo "❌ .schemas/canvas.schema.json failed schema compilation"
exit 1
fi

# Collect changed extension directories.
# Use null-terminated (-z) output from git diff so filenames containing newlines
# or other special characters are read atomically (matches the pattern in skill-check.yml).
# Each extracted name is then validated against a strict allowlist regex before use,
# rejecting anything containing shell metacharacters ($, (, ), spaces, etc.).
declare -A seen_dirs=()
changed_extensions=()
errors=()

while IFS= read -r -d '' file; do
case "$file" in
extensions/*)
ext_name="${file#extensions/}"
ext_name="${ext_name%%/*}"

if [ "$ext_name" = "external-assets" ]; then
continue
fi

# Allowlist: extension directory names must be lowercase alphanumeric + hyphens only.
# Fail for disallowed names so a PR cannot bypass validation by using a nonconforming folder.
if [[ ! "$ext_name" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
errors+=("\`extensions/$ext_name\`: invalid extension directory name (must match ^[a-z0-9][a-z0-9-]*$).")
continue
fi

if [ -z "${seen_dirs[$ext_name]+x}" ]; then
seen_dirs["$ext_name"]=1
changed_extensions+=("extensions/$ext_name")
fi
;;
esac
done < <(git diff --name-only -z "origin/${{ github.base_ref }}...HEAD")

if [ "${#changed_extensions[@]}" -eq 0 ]; then
if [ "${#errors[@]}" -ne 0 ]; then
echo "❌ Canvas extension validation failed:"
for error in "${errors[@]}"; do
echo "- $error"
done
exit 1
fi
echo "No canvas extension directories changed — skipping validation."
exit 0
fi

echo "Validating ${#changed_extensions[@]} extension(s): ${changed_extensions[*]}"

for ext_dir in "${changed_extensions[@]}"; do
if [ ! -d "$ext_dir" ]; then
echo "$ext_dir no longer exists (deleted?), skipping."
continue
fi

if [ ! -f "$ext_dir/extension.mjs" ]; then
errors+=("\`$ext_dir\`: missing required \`extension.mjs\`.")
fi

if [ ! -f "$ext_dir/canvas.json" ]; then
errors+=("\`$ext_dir\`: missing required \`canvas.json\`.")
continue
fi

if [ ! -f "$ext_dir/assets/preview.png" ]; then
errors+=("\`$ext_dir\`: missing required \`assets/preview.png\`.")
fi

if ! schema_output="$(node ./eng/validate-json-schema.mjs --schema .schemas/canvas.schema.json --data "$ext_dir/canvas.json" 2>&1)"; then
condensed_output="$(echo "$schema_output" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g')"
errors+=("\`$ext_dir/canvas.json\`: schema validation failed against \`.schemas/canvas.schema.json\` ($condensed_output).")
continue
fi

mapfile -t screenshot_paths < <(
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"
)

for screenshot_path in "${screenshot_paths[@]}"; do
if [[ ! "$screenshot_path" =~ ^assets/([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*\.(png|jpe?g|gif|webp|svg)$ ]]; then
errors+=("\`$ext_dir/canvas.json\`: screenshot path \`$screenshot_path\` is not a valid assets path.")
continue
fi
if [ ! -f "$ext_dir/$screenshot_path" ]; then
errors+=("\`$ext_dir/canvas.json\`: screenshot path \`$screenshot_path\` does not exist in the extension directory.")
fi
done
done

if [ "${#errors[@]}" -ne 0 ]; then
echo "❌ Canvas extension validation failed:"
for error in "${errors[@]}"; do
echo "- $error"
done
exit 1
fi

echo "✅ All changed canvas extensions passed validation."
121 changes: 121 additions & 0 deletions .schemas/canvas.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Canvas Extension Manifest",
"description": "Schema for extensions/<name>/canvas.json files",
"type": "object",
"required": [
"id",
"name",
"description",
"version",
"keywords",
"screenshots"
],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the canvas extension",
"pattern": "^[a-z0-9-]+$",
"minLength": 1,
"maxLength": 100
},
Comment on lines +16 to +22
"name": {
"type": "string",
"description": "Display name for the extension",
"minLength": 1,
"maxLength": 100
},
"description": {
"type": "string",
"description": "Human-friendly description of what the extension does",
"minLength": 1,
"maxLength": 500
},
"version": {
"type": "string",
"description": "Semantic version of the extension metadata",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"
},
"author": {
"type": "object",
"description": "Optional author metadata",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"url": {
"type": "string",
"format": "uri",
"maxLength": 2048
}
}
},
"keywords": {
"type": "array",
"description": "Keywords used for search and filtering",
"items": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"minLength": 1,
"maxLength": 50
},
Comment on lines +63 to +68
"minItems": 1,
"maxItems": 20,
"uniqueItems": true
},
"screenshots": {
"type": "object",
"description": "Screenshot metadata for icon and gallery cards",
"required": [
"icon",
"gallery"
],
"additionalProperties": false,
"properties": {
"icon": {
"$ref": "#/definitions/screenshot"
},
"gallery": {
"$ref": "#/definitions/screenshot"
}
}
}
},
"definitions": {
"screenshot": {
"type": "object",
"required": [
"path",
"type"
],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"description": "Path relative to the extension root",
"pattern": "^assets/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*\\.(png|jpg|jpeg|gif|webp|svg)$",
"minLength": 1,
"maxLength": 200
},
"type": {
"type": "string",
"description": "MIME type for the referenced image",
"enum": [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml"
]
}
}
}
}
}
Loading
Loading