Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
197 changes: 83 additions & 114 deletions .github/workflows/validate-canvas-extensions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,130 +6,99 @@ 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
Comment thread
Copilot marked this conversation as resolved.
Outdated

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

# Validate the schema file itself is parseable JSON whenever it is present
if [ -f ".schemas/canvas.schema.json" ]; then
if ! node -e "JSON.parse(require('fs').readFileSync('.schemas/canvas.schema.json','utf8'))" 2>/dev/null; then
echo "❌ .schemas/canvas.schema.json is not valid JSON"
exit 1
fi
echo "✅ .schemas/canvas.schema.json is valid JSON"
fi

mapfile -t changed_extensions < <(
git diff --name-only "origin/${{ github.base_ref }}...HEAD" |
awk -F/ '$1 == "extensions" && $2 != "" && $2 != "external-assets" && $2 !~ /\./ { print "extensions/" $2 }' |
sort -u
)

if [ "${#changed_extensions[@]}" -eq 0 ]; then
echo "No canvas extension directories changed — skipping validation."
exit 0
fi
Comment thread
aaronpowell marked this conversation as resolved.

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

errors=()

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="$(npx ajv-cli validate --spec=draft7 --strict=false -s .schemas/canvas.schema.json -d "$ext_dir/canvas.json" 2>&1)"; then
Comment thread
aaronpowell marked this conversation as resolved.
Outdated
Comment thread
aaronpowell marked this conversation as resolved.
Outdated
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 [ ! -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"
]
}
}
}
}
}
2 changes: 1 addition & 1 deletion eng/yaml-parser.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// YAML parser for frontmatter parsing using vfile-matter
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import path from "path";
import { VFile } from "vfile";
import { matter } from "vfile-matter";
Expand Down
Loading
Loading