Files
clawsec/.github/workflows/deploy-pages.yml
T
davida-ps fefecaa60a feat(wiki): add full in-app wiki browser and llms index (#80)
* feat(wiki): add full in-app wiki browser and llms index

* feat(wiki): auto-generate per-page llms exports

* vuln package

* fix(wiki): guard malformed route decoding

* fix(wiki): preserve markdown anchor fragments across page links

* refactor(markdown): share default render components

* fix(wiki): block unsafe markdown link schemes

* fix(wiki): block unsafe markdown image schemes

* docs(wiki): migrate root docs into wiki pages

* chore(wiki): de-track generated llms exports

* chore(wiki): ignore generated public wiki artifacts

* fix(wiki): align llms urls with per-page endpoint pattern

* fix(wiki): derive llms index from wiki index page

* refactor(markdown): share frontmatter and title helpers

* refactor(wiki): share route and llms path mapping

* ci(pages): add pr verify workflow and tighten deploy triggers
2026-02-26 10:43:36 +02:00

447 lines
18 KiB
YAML

name: Deploy to GitHub Pages
on:
workflow_run:
workflows: ["CI", "Skill Release"]
types: [completed]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
# Production build only: manual dispatch or trusted workflow_run sources.
# PR validation runs in .github/workflows/pages-verify.yml.
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
(
(
github.event.workflow_run.name == 'CI' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main'
) ||
(
github.event.workflow_run.name == 'Skill Release' &&
github.event.workflow_run.event != 'pull_request'
)
)
)
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify signing key consistency (repo + docs)
run: ./scripts/ci/verify_signing_key_consistency.sh
- name: Auto-discover skills from releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
mkdir -p public/skills
mkdir -p public/releases/download
echo "Fetching releases from GitHub API..."
# Helper function to download release asset by ID (works for private repos)
download_asset() {
local asset_id="$1"
local output_file="$2"
curl -fsSL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/octet-stream" \
"https://api.github.com/repos/${REPO}/releases/assets/${asset_id}" \
-o "$output_file"
}
export -f download_asset # Export for use in subshells (while loop)
# Fetch all releases (paginated)
RELEASES=$(gh api --paginate \
-H "Accept: application/vnd.github+json" \
"/repos/${REPO}/releases?per_page=100" \
| jq -s 'add // []')
# Start building skills index
echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json
FIRST_SKILL=true
declare -A PROCESSED_SKILLS=()
# Process each release (using process substitution to avoid subshell)
while read -r release; do
TAG=$(echo "$release" | jq -r '.tag_name')
# Parse skill-name-v* pattern
if [[ "$TAG" =~ ^(.+)-v([0-9]+\.[0-9]+\.[0-9]+.*)$ ]]; then
SKILL_NAME="${BASH_REMATCH[1]}"
VERSION="${BASH_REMATCH[2]}"
# Skip if we already processed a newer version of this skill
if [[ -n "${PROCESSED_SKILLS[$SKILL_NAME]+x}" ]]; then
echo "Skipping older version: $TAG (already have newer)"
continue
fi
echo "Processing: $SKILL_NAME v$VERSION"
# Get skill.json asset ID from release
SKILL_JSON_ID=$(echo "$release" | jq -r '.assets[] | select(.name=="skill.json") | .id')
if [ -n "$SKILL_JSON_ID" ] && [ "$SKILL_JSON_ID" != "null" ]; then
# Basic safety checks before using tag/asset names as paths
if [[ "$TAG" == *"/"* ]] || [[ "$TAG" == *".."* ]]; then
echo " Warning: Skipping suspicious tag name: $TAG"
continue
fi
# Download skill.json first to decide whether the skill is internal
SKILL_JSON_TMP=$(mktemp)
download_asset "$SKILL_JSON_ID" "$SKILL_JSON_TMP"
# Skip internal skills (not shown in public catalog or mirrored)
IS_INTERNAL=$(jq -r '.openclaw.internal // false' "$SKILL_JSON_TMP")
if [ "$IS_INTERNAL" = "true" ]; then
echo " Skipping internal skill: $SKILL_NAME"
rm -f "$SKILL_JSON_TMP"
continue
fi
# Security: Download to temp directory first, verify signatures, then mirror to final location.
# This ensures unverified releases never appear in public/releases or the skills catalog.
# Use temp directory for downloads before verification
TEMP_DOWNLOAD_DIR=$(mktemp -d)
# Move skill.json to temp dir first
mv "$SKILL_JSON_TMP" "$TEMP_DOWNLOAD_DIR/skill.json"
# Download all remaining assets to temp dir
while read -r asset; do
ASSET_ID=$(echo "$asset" | jq -r '.id')
ASSET_NAME=$(echo "$asset" | jq -r '.name')
# Prevent path traversal / nested directories
if [[ "$ASSET_NAME" == *"/"* ]] || [[ "$ASSET_NAME" == *".."* ]]; then
echo " Warning: Skipping suspicious asset name: $ASSET_NAME"
continue
fi
# Already downloaded above
if [ "$ASSET_NAME" = "skill.json" ]; then
continue
fi
download_asset "$ASSET_ID" "$TEMP_DOWNLOAD_DIR/$ASSET_NAME"
echo " Downloaded to temp: $ASSET_NAME"
done < <(echo "$release" | jq -c '.assets[]')
# Verify signed checksums when signature artifacts are present.
# Legacy releases without signatures are still mirrored for backward compatibility.
if [ -f "$TEMP_DOWNLOAD_DIR/checksums.sig" ] && [ -f "$TEMP_DOWNLOAD_DIR/signing-public.pem" ] && [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
openssl base64 -d -A -in "$TEMP_DOWNLOAD_DIR/checksums.sig" -out "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
# Verify Ed25519 signature (requires -rawin)
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$TEMP_DOWNLOAD_DIR/signing-public.pem" -sigfile "$TEMP_DOWNLOAD_DIR/checksums.sig.bin" -in "$TEMP_DOWNLOAD_DIR/checksums.json"; then
echo " Warning: Invalid checksums signature for $TAG; skipping skill"
rm -rf "$TEMP_DOWNLOAD_DIR"
continue
fi
rm -f "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
echo " Verified checksums signature"
elif [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
echo " Warning: Unsigned legacy checksums for $TAG (missing checksums.sig/signing-public.pem)"
fi
# Verification passed or skipped (legacy) - mirror to final location
MIRROR_DIR="public/releases/download/${TAG}"
mkdir -p "$MIRROR_DIR"
cp -r "$TEMP_DOWNLOAD_DIR"/* "$MIRROR_DIR"/
echo " Mirrored to: $MIRROR_DIR"
# Clean up temp directory
rm -rf "$TEMP_DOWNLOAD_DIR"
# Copy the subset needed for the site catalog (skill pages)
mkdir -p "public/skills/${SKILL_NAME}"
cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json"
echo " Added to catalog: skill.json"
for file in checksums.json checksums.sig signing-public.pem README.md SKILL.md; do
if [ -f "$MIRROR_DIR/$file" ]; then
cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file"
echo " Added to catalog: $file"
fi
done
# Build skill entry for index
SKILL_DATA=$(jq -c --arg tag "$TAG" '{
id: .name,
name: .name,
version: .version,
description: .description,
emoji: .openclaw.emoji,
category: .openclaw.category,
trust: .trust.level,
tag: $tag
}' "$MIRROR_DIR/skill.json")
# Append to index (handle first entry without comma)
if [ -f "public/skills/.first_done" ]; then
echo "," >> public/skills/index.json
else
touch "public/skills/.first_done"
fi
echo "$SKILL_DATA" >> public/skills/index.json
# Mark this skill as processed (track newest only)
PROCESSED_SKILLS["$SKILL_NAME"]=1
else
echo " Warning: skill.json not found in release assets"
fi
fi
done < <(echo "$RELEASES" | jq -c '.[]')
# Close the JSON array
echo ']}' >> public/skills/index.json
# Clean up temp file
rm -f "public/skills/.first_done"
echo ""
echo "=== Skills Index ==="
cat public/skills/index.json | jq . || cat public/skills/index.json
echo ""
echo "=== Skills Directory ==="
ls -la public/skills/
- name: Copy advisory feed to public
run: |
set -euo pipefail
mkdir -p public/advisories
cp advisories/feed.json public/advisories/feed.json
echo "Copied advisory feed to public/advisories/"
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories"
- name: Generate advisory checksums manifest
run: |
set -euo pipefail
FEED_FILE="public/advisories/feed.json"
FEED_SHA=$(sha256sum "$FEED_FILE" | awk '{print $1}')
FEED_SIZE=$(stat -c%s "$FEED_FILE" 2>/dev/null || stat -f%z "$FEED_FILE")
# Generate checksums manifest conforming to parseChecksumsManifest expectations:
# - schema_version: "1" (manifest format version)
# - algorithm: "sha256" (hash algorithm)
# - version: "1.1.0" (feed content version, for informational purposes)
# - generated_at, repository: metadata
# - files: map of path -> {sha256, size, path, url}
jq -n \
--arg schema_version "1" \
--arg algorithm "sha256" \
--arg version "1.1.0" \
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg repo "${{ github.repository }}" \
--arg sha "$FEED_SHA" \
--argjson size "$FEED_SIZE" \
'{
schema_version: $schema_version,
algorithm: $algorithm,
version: $version,
generated_at: $generated,
repository: $repo,
files: {
"advisories/feed.json": {
sha256: $sha,
size: $size,
path: "advisories/feed.json",
url: "https://clawsec.prompt.security/advisories/feed.json"
}
}
}' > public/checksums.json
echo "Generated public/checksums.json"
jq . public/checksums.json
- name: Sign advisory feed and verify
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: public/advisories/feed.json
signature_file: public/advisories/feed.json.sig
public_key_output: public/signing-public.pem
- name: Sign checksums and verify
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: public/checksums.json
signature_file: public/checksums.sig
- name: Verify generated public signing key matches canonical key
run: |
set -euo pipefail
CANONICAL_FPR=$(openssl pkey -pubin -in clawsec-signing-public.pem -outform DER | sha256sum | awk '{print $1}')
GENERATED_FPR=$(openssl pkey -pubin -in public/signing-public.pem -outform DER | sha256sum | awk '{print $1}')
echo "Canonical key fingerprint: $CANONICAL_FPR"
echo "Generated key fingerprint: $GENERATED_FPR"
if [ "$CANONICAL_FPR" != "$GENERATED_FPR" ]; then
echo "::error::public/signing-public.pem fingerprint mismatch vs clawsec-signing-public.pem"
exit 1
fi
- name: Copy public key to advisory directory
run: |
# Clients expect the public key at advisories/feed-signing-public.pem
mkdir -p public/advisories
cp public/signing-public.pem public/advisories/feed-signing-public.pem
echo "Public key available at:"
echo " - public/signing-public.pem (root)"
echo " - public/advisories/feed-signing-public.pem (advisory-specific)"
- name: Show signed advisory artifacts
run: |
echo "Signed advisory artifacts:"
ls -la public/advisories/feed.json*
ls -la public/checksums.json public/checksums.sig public/signing-public.pem
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '20'
cache: 'npm'
- name: Get latest clawsec-suite release URL
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
LATEST_TAG=$(
gh api --paginate \
-H "Accept: application/vnd.github+json" \
"/repos/${REPO}/releases?per_page=100" \
| jq -r -s 'add // [] | [.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty'
)
if [ -n "$LATEST_TAG" ]; then
echo "Found latest clawsec-suite tag: $LATEST_TAG"
echo "VITE_CLAWSEC_SUITE_URL=https://clawsec.prompt.security/releases/download/${LATEST_TAG}/SKILL.md" >> $GITHUB_ENV
# Create a local "latest" mirror path for clients that use GitHub-style URLs.
# This enables swapping the host:
# https://github.com/<repo>/releases/latest/download/<file>
# → https://clawsec.prompt.security/releases/latest/download/<file>
MIRROR_TAG_DIR="public/releases/download/${LATEST_TAG}"
MIRROR_LATEST_DIR="public/releases/latest/download"
rm -rf "$MIRROR_LATEST_DIR"
mkdir -p "$MIRROR_LATEST_DIR"
if [ -d "$MIRROR_TAG_DIR" ]; then
cp -f "$MIRROR_TAG_DIR"/* "$MIRROR_LATEST_DIR"/ 2>/dev/null || true
echo "Mirrored suite release assets to: $MIRROR_LATEST_DIR"
else
echo "Warning: Suite release assets not mirrored (missing: $MIRROR_TAG_DIR)"
fi
# Mirror advisories feed + signatures at the path referenced by suite docs/heartbeat
if [ -f "public/advisories/feed.json" ]; then
mkdir -p "$MIRROR_LATEST_DIR/advisories"
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/advisories/feed.json"
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json"
fi
if [ -f "public/advisories/feed.json.sig" ]; then
mkdir -p "$MIRROR_LATEST_DIR/advisories"
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/advisories/feed.json.sig"
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/feed.json.sig"
fi
if [ -f "public/checksums.json" ]; then
cp "public/checksums.json" "$MIRROR_LATEST_DIR/checksums.json"
fi
if [ -f "public/checksums.sig" ]; then
cp "public/checksums.sig" "$MIRROR_LATEST_DIR/checksums.sig"
fi
if [ -f "public/signing-public.pem" ]; then
cp "public/signing-public.pem" "$MIRROR_LATEST_DIR/signing-public.pem"
fi
else
echo "No clawsec-suite release found, using fallback"
fi
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NODE_ENV: production
VITE_CLAWSEC_SUITE_URL: ${{ env.VITE_CLAWSEC_SUITE_URL }}
- name: Copy skills data to dist
run: |
cp -r public/skills dist/skills 2>/dev/null || echo "No skills directory"
cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No checksums manifest"
cp public/checksums.sig dist/checksums.sig 2>/dev/null || echo "No checksums signature"
cp public/signing-public.pem dist/signing-public.pem 2>/dev/null || echo "No signing public key"
cp -r public/advisories dist/advisories 2>/dev/null || echo "No advisories directory"
echo "=== Dist contents ==="
ls -la dist/
ls -la dist/skills/ 2>/dev/null || echo "No skills in dist"
ls -la dist/advisories/ 2>/dev/null || echo "No advisories in dist"
- name: Add .nojekyll file
run: touch dist/.nojekyll
- name: Setup Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with:
path: ./dist
deploy:
# Deploy after a production build succeeds.
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
(
(
github.event.workflow_run.name == 'CI' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main'
) ||
(
github.event.workflow_run.name == 'Skill Release' &&
github.event.workflow_run.event != 'pull_request'
)
)
)
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5