name: Deploy to GitHub Pages on: push: branches: [main] workflow_run: workflows: ["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, push to main, or trusted release workflows. # PR validation runs in .github/workflows/pages-verify.yml. if: | github.event_name == 'workflow_dispatch' || ( github.event_name == 'push' && github.ref_name == 'main' ) || ( github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && 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" ' . as $skill | def object_or_empty($value): if ($value | type) == "object" then $value else {} end; def object_field($name): object_or_empty($skill[$name]?); def platform_meta: ($skill.platform as $platform | if ($platform | type) == "string" then object_or_empty($skill[$platform]?) else {} end); def platform_list: ([] + (if ($skill.platforms | type) == "array" then $skill.platforms else [] end) + (if ($skill.platform | type) == "string" then [$skill.platform] else [] end) + (["openclaw", "hermes", "nanoclaw", "picoclaw"] | map(select((object_field(.) | length) > 0)))) | map(select(type == "string") | ascii_downcase) | unique; { id: .name, name: .name, version: .version, description: .description, emoji: (platform_meta.emoji // object_field("openclaw").emoji // object_field("hermes").emoji // object_field("nanoclaw").emoji // object_field("picoclaw").emoji // "📦"), category: (platform_meta.category // object_field("openclaw").category // object_field("hermes").category // object_field("nanoclaw").category // object_field("picoclaw").category // "utility"), platforms: platform_list, 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 if [ -f advisories/ghsa-without-cve.json ]; then cp advisories/ghsa-without-cve.json public/advisories/ghsa-without-cve.json fi echo "Copied advisory feed to public/advisories/" cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories" if [ -f public/advisories/ghsa-without-cve.json ]; then cat public/advisories/ghsa-without-cve.json | jq '.advisories | length' | xargs -I {} echo "GHSA provisional feed contains {} advisories" fi - 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 provisional GHSA feed and verify if: hashFiles('public/advisories/ghsa-without-cve.json') != '' 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/ghsa-without-cve.json signature_file: public/advisories/ghsa-without-cve.json.sig - name: Generate advisory checksums manifest run: | set -euo pipefail FILES_JSON="{}" ADVISORY_ARTIFACTS=(public/advisories/*.json public/advisories/*.json.sig) for file in "${ADVISORY_ARTIFACTS[@]}"; do [ -e "$file" ] || continue REL_PATH="${file#public/}" FILE_SHA=$(sha256sum "$file" | awk '{print $1}') FILE_SIZE=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file") FILES_JSON=$(jq \ --arg path "$REL_PATH" \ --arg sha "$FILE_SHA" \ --argjson size "$FILE_SIZE" \ '. + {($path): {sha256: $sha, size: $size, path: $path, url: ("https://clawsec.prompt.security/" + $path)}}' \ <<< "$FILES_JSON") done # 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 }}" \ --argjson files "$FILES_JSON" \ '{ schema_version: $schema_version, algorithm: $algorithm, version: $version, generated_at: $generated, repository: $repo, files: $files }' > public/checksums.json echo "Generated public/checksums.json" jq . public/checksums.json - 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/*.json* ls -la public/checksums.json public/checksums.sig public/signing-public.pem - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.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//releases/latest/download/ # → https://clawsec.prompt.security/releases/latest/download/ 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/advisories/ghsa-without-cve.json" ]; then mkdir -p "$MIRROR_LATEST_DIR/advisories" cp "public/advisories/ghsa-without-cve.json" "$MIRROR_LATEST_DIR/advisories/ghsa-without-cve.json" cp "public/advisories/ghsa-without-cve.json" "$MIRROR_LATEST_DIR/ghsa-without-cve.json" fi if [ -f "public/advisories/ghsa-without-cve.json.sig" ]; then mkdir -p "$MIRROR_LATEST_DIR/advisories" cp "public/advisories/ghsa-without-cve.json.sig" "$MIRROR_LATEST_DIR/advisories/ghsa-without-cve.json.sig" cp "public/advisories/ghsa-without-cve.json.sig" "$MIRROR_LATEST_DIR/ghsa-without-cve.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@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: ./dist deploy: # Deploy after a production build succeeds. if: | github.event_name == 'workflow_dispatch' || ( github.event_name == 'push' && github.ref_name == 'main' ) || ( github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && 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@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0