Deploy to GitHub Pages #767
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: Setup Node.js | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: '20' | |
| cache: 'npm' | |
| - 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: node scripts/ci/advisory_pages_artifacts.mjs generate --public-dir public --repository "${{ github.repository }}" | |
| - 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: Publish advisory compatibility aliases | |
| run: node scripts/ci/advisory_pages_artifacts.mjs publish-aliases --public-dir public | |
| - 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: 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: 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 the advisory contract at the paths referenced by suite docs/heartbeat. | |
| node scripts/ci/advisory_pages_artifacts.mjs publish-release-mirror \ | |
| --public-dir public \ | |
| --mirror-dir "$MIRROR_LATEST_DIR" | |
| 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: Verify built public artifacts | |
| run: | | |
| set -euo pipefail | |
| test -f dist/skills/index.json | |
| node scripts/ci/advisory_pages_artifacts.mjs verify-built --public-dir public --dist-dir dist | |
| 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: Smoke-test built advisory endpoints | |
| run: node scripts/ci/advisory_pages_artifacts.mjs smoke-http --dist-dir 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 | |
| verify-production: | |
| name: Verify Production Advisory Endpoints | |
| runs-on: ubuntu-latest | |
| needs: deploy | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: '20' | |
| - name: Verify deployed advisory endpoints | |
| run: | | |
| node scripts/ci/advisory_pages_artifacts.mjs verify-url \ | |
| --base-url https://clawsec.prompt.security \ | |
| --attempts 12 \ | |
| --retry-delay-ms 10000 |