name: Skill Release on: push: tags: - '*-v[0-9]*.[0-9]*.[0-9]*' pull_request: paths: - 'skills/*/skill.json' - 'skills/*/SKILL.md' permissions: contents: write pages: write id-token: write concurrency: group: skill-release-${{ github.ref }} cancel-in-progress: false jobs: validate-pr-version-sync: if: github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Validate version parity for bumped skills env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail get_md_version() { local md_file="$1" awk ' NR == 1 && $0 == "---" { in_frontmatter = 1; next } in_frontmatter && $0 == "---" { exit } in_frontmatter && $0 ~ /^version:[[:space:]]*/ { sub(/^version:[[:space:]]*/, "", $0) gsub(/[[:space:]]+$/, "", $0) print $0 exit } ' "$md_file" } get_md_version_from_git() { local sha="$1" local path="$2" local tmp_file tmp_file="$(mktemp)" if git cat-file -e "${sha}:${path}" 2>/dev/null; then git show "${sha}:${path}" > "$tmp_file" get_md_version "$tmp_file" fi rm -f "$tmp_file" } touched_skills_file="$(mktemp)" git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- 'skills/*/skill.json' 'skills/*/SKILL.md' \ | awk -F/ 'NF >= 3 {print $1 "/" $2}' \ | sort -u > "${touched_skills_file}" if [ ! -s "${touched_skills_file}" ]; then echo "No skill metadata files changed in this PR." rm -f "${touched_skills_file}" exit 0 fi checked_skills=0 failures=0 while IFS= read -r skill_dir; do json_path="${skill_dir}/skill.json" md_path="${skill_dir}/SKILL.md" head_json_version="" if [ -f "${json_path}" ]; then head_json_version="$(jq -r '.version // empty' "${json_path}" 2>/dev/null || true)" fi head_md_version="" if [ -f "${md_path}" ]; then head_md_version="$(get_md_version "${md_path}")" fi base_json_version="" if git cat-file -e "${BASE_SHA}:${json_path}" 2>/dev/null; then base_json_version="$(git show "${BASE_SHA}:${json_path}" | jq -r '.version // empty' 2>/dev/null || true)" fi base_md_version="$(get_md_version_from_git "${BASE_SHA}" "${md_path}")" json_version_changed=false md_version_changed=false if [ "${head_json_version}" != "${base_json_version}" ]; then json_version_changed=true fi if [ "${head_md_version}" != "${base_md_version}" ]; then md_version_changed=true fi if [ "${json_version_changed}" != "true" ] && [ "${md_version_changed}" != "true" ]; then echo "No version bump detected for ${skill_dir}; skipping." continue fi checked_skills=$((checked_skills + 1)) echo "Version bump detected for ${skill_dir} (skill.json changed: ${json_version_changed}, SKILL.md changed: ${md_version_changed})" if [ ! -f "${json_path}" ]; then echo "::error file=${json_path}::Missing skill.json after version bump." failures=$((failures + 1)) continue fi if [ ! -f "${md_path}" ]; then echo "::error file=${md_path}::Missing SKILL.md after version bump." failures=$((failures + 1)) continue fi if [ -z "${head_json_version}" ]; then echo "::error file=${json_path}::Missing .version in skill.json." failures=$((failures + 1)) continue fi if [ -z "${head_md_version}" ]; then echo "::error file=${md_path}::Missing version in SKILL.md frontmatter." failures=$((failures + 1)) continue fi if [ "${head_json_version}" != "${head_md_version}" ]; then echo "::error file=${json_path}::Version mismatch. skill.json=${head_json_version}, SKILL.md=${head_md_version}" failures=$((failures + 1)) continue fi echo "Version parity OK for ${skill_dir}: ${head_json_version}" done < "${touched_skills_file}" rm -f "${touched_skills_file}" if [ "${checked_skills}" -eq 0 ]; then echo "No version bumps detected in changed skill metadata files." exit 0 fi if [ "${failures}" -gt 0 ]; then echo "::error::Found ${failures} version parity issue(s) across ${checked_skills} bumped skill(s)." exit 1 fi echo "Validated ${checked_skills} bumped skill(s): skill.json and SKILL.md versions are present and equal." release: if: github.event_name == 'pull_request' needs: validate-pr-version-sync runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Run release dry-run for changed skills env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail get_md_version() { local md_file="$1" awk ' NR == 1 && $0 == "---" { in_frontmatter = 1; next } in_frontmatter && $0 == "---" { exit } in_frontmatter && $0 ~ /^version:[[:space:]]*/ { sub(/^version:[[:space:]]*/, "", $0) gsub(/[[:space:]]+$/, "", $0) print $0 exit } ' "$md_file" } touched_skills_file="$(mktemp)" git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- 'skills/*/skill.json' 'skills/*/SKILL.md' \ | awk -F/ 'NF >= 3 {print $1 "/" $2}' \ | sort -u > "${touched_skills_file}" if [ ! -s "${touched_skills_file}" ]; then echo "No skill metadata files changed in this PR." rm -f "${touched_skills_file}" exit 0 fi dry_run_count=0 failures=0 mkdir -p dist/dry-run while IFS= read -r skill_dir; do json_path="${skill_dir}/skill.json" md_path="${skill_dir}/SKILL.md" head_json_version="" if [ -f "${json_path}" ]; then head_json_version="$(jq -r '.version // empty' "${json_path}" 2>/dev/null || true)" fi head_md_version="" if [ -f "${md_path}" ]; then head_md_version="$(get_md_version "${md_path}")" fi if [ -z "${head_json_version}" ] || [ -z "${head_md_version}" ] || [ "${head_json_version}" != "${head_md_version}" ]; then echo "::error file=${skill_dir}::Version metadata is invalid for dry-run. Ensure validate-pr-version-sync passes." failures=$((failures + 1)) continue fi if [ ! -f "${json_path}" ]; then echo "::error file=${json_path}::Missing skill.json." failures=$((failures + 1)) continue fi if [ ! -f "${md_path}" ]; then echo "::error file=${md_path}::Missing SKILL.md." failures=$((failures + 1)) continue fi if ! jq -e '.name and .version and .sbom and .sbom.files and (.sbom.files | type == "array")' "${json_path}" >/dev/null 2>&1; then echo "::error file=${json_path}::skill.json missing required release fields (name/version/sbom.files)." failures=$((failures + 1)) continue fi skill_name="$(basename "${skill_dir}")" version="${head_json_version}" tag="${skill_name}-v${version}" dry_run_count=$((dry_run_count + 1)) echo "::group::Dry-run release ${tag}" out_root="dist/dry-run/${tag}" out_assets="${out_root}/release-assets" mkdir -p "${out_assets}" # --- Stage SBOM files preserving directory structure --- staging_dir="$(mktemp -d)" inner_dir="${staging_dir}/${skill_name}" mkdir -p "${inner_dir}" temp_sbom_file="$(mktemp)" jq -r '.sbom.files[].path' "${json_path}" > "${temp_sbom_file}" while IFS= read -r file; do [ -z "${file}" ] && continue full_path="${skill_dir}/${file}" if [ -f "${full_path}" ]; then mkdir -p "${inner_dir}/$(dirname "${file}")" cp "${full_path}" "${inner_dir}/${file}" else echo "::error file=${json_path}::SBOM references missing file: ${file}" failures=$((failures + 1)) fi done < "${temp_sbom_file}" cp "${json_path}" "${inner_dir}/skill.json" # --- Create zip preserving directory structure --- zip_name="${skill_name}-v${version}.zip" (cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .) # --- Generate checksums.json via jq --- files_json="{}" while IFS= read -r file; do [ -z "${file}" ] && continue full_path="${skill_dir}/${file}" if [ -f "${full_path}" ]; then sha256="$(sha256sum "${full_path}" | awk '{print $1}')" size="$(stat -c%s "${full_path}" 2>/dev/null || stat -f%z "${full_path}")" files_json="$(echo "${files_json}" | jq \ --arg key "${file}" \ --arg sha "${sha256}" \ --argjson sz "${size}" \ '. + {($key): {sha256: $sha, size: $sz, path: $key}}')" fi done < "${temp_sbom_file}" rm -f "${temp_sbom_file}" skill_json_sha="$(sha256sum "${json_path}" | awk '{print $1}')" skill_json_size="$(stat -c%s "${json_path}" 2>/dev/null || stat -f%z "${json_path}")" files_json="$(echo "${files_json}" | jq \ --arg sha "${skill_json_sha}" \ --argjson sz "${skill_json_size}" \ '. + {"skill.json": {sha256: $sha, size: $sz}}')" zip_sha="$(sha256sum "${out_assets}/${zip_name}" | awk '{print $1}')" zip_size="$(stat -c%s "${out_assets}/${zip_name}" 2>/dev/null || stat -f%z "${out_assets}/${zip_name}")" jq -n \ --arg skill "${skill_name}" \ --arg version "${version}" \ --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg repo "${{ github.repository }}" \ --arg tag "${tag}" \ --arg zip_file "${zip_name}" \ --arg zip_sha "${zip_sha}" \ --argjson zip_size "${zip_size}" \ --arg zip_url "https://github.com/${{ github.repository }}/releases/download/${tag}/${zip_name}" \ --argjson files "${files_json}" \ '{ skill: $skill, version: $version, generated_at: $generated, repository: $repo, tag: $tag, archive: { filename: $zip_file, sha256: $zip_sha, size: $zip_size, url: $zip_url }, files: $files }' > "${out_assets}/checksums.json" if ! jq -e . "${out_assets}/checksums.json" >/dev/null 2>&1; then echo "::error::Generated checksums.json is invalid JSON." failures=$((failures + 1)) rm -rf "${staging_dir}" echo "::endgroup::" continue fi # --- Copy skill.json and root-level docs alongside the zip --- cp "${json_path}" "${out_assets}/skill.json" if [ -f "${skill_dir}/SKILL.md" ]; then cp "${skill_dir}/SKILL.md" "${out_assets}/SKILL.md" fi if [ -f "${skill_dir}/README.md" ]; then cp "${skill_dir}/README.md" "${out_assets}/README.md" fi rm -rf "${staging_dir}" echo "Prepared dry-run assets for ${tag}:" ls -la "${out_assets}" echo "::endgroup::" done < "${touched_skills_file}" rm -f "${touched_skills_file}" if [ "${failures}" -gt 0 ]; then echo "::error::Release dry-run failed with ${failures} issue(s) across ${dry_run_count} skill(s)." exit 1 fi if [ "${dry_run_count}" -eq 0 ]; then echo "No changed skills found for dry-run." exit 0 fi echo "Release dry-run completed successfully for ${dry_run_count} changed skill(s)." release-tag: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest env: CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} steps: - name: Parse tag id: parse run: | TAG="${{ github.ref_name }}" # Extract skill name (everything before -v) SKILL_NAME="${TAG%-v*}" # Extract version (everything after -v) VERSION="${TAG#*-v}" echo "skill_name=${SKILL_NAME}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "skill_path=skills/${SKILL_NAME}" >> $GITHUB_OUTPUT echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}" - name: Checkout uses: actions/checkout@v4 - name: Validate skill exists run: | SKILL_PATH="${{ steps.parse.outputs.skill_path }}" if [ ! -d "$SKILL_PATH" ]; then echo "Error: Skill directory not found: $SKILL_PATH" exit 1 fi if [ ! -f "$SKILL_PATH/skill.json" ]; then echo "Error: skill.json not found in $SKILL_PATH" exit 1 fi echo "Skill validated: $SKILL_PATH" - name: Validate version match run: | SKILL_PATH="${{ steps.parse.outputs.skill_path }}" TAG_VERSION="${{ steps.parse.outputs.version }}" # Extract version from skill.json JSON_VERSION=$(jq -r '.version' "$SKILL_PATH/skill.json") if [ "$TAG_VERSION" != "$JSON_VERSION" ]; then echo "::error::Version mismatch! Tag version ($TAG_VERSION) != skill.json version ($JSON_VERSION)" echo "Please ensure the version in $SKILL_PATH/skill.json matches your tag." exit 1 fi echo "Version validated: $TAG_VERSION" - name: Validate SKILL.md frontmatter version run: | SKILL_PATH="${{ steps.parse.outputs.skill_path }}" TAG_VERSION="${{ steps.parse.outputs.version }}" # Check if SKILL.md exists if [ -f "$SKILL_PATH/SKILL.md" ]; then # Extract version from YAML frontmatter MD_VERSION=$(grep -m 1 "^version:" "$SKILL_PATH/SKILL.md" | sed 's/version: *//' | tr -d '\r') if [ -z "$MD_VERSION" ]; then echo "::warning::No version found in $SKILL_PATH/SKILL.md frontmatter" elif [ "$TAG_VERSION" != "$MD_VERSION" ]; then echo "::error::Version mismatch! Tag version ($TAG_VERSION) != SKILL.md version ($MD_VERSION)" echo "Please ensure the version in $SKILL_PATH/SKILL.md frontmatter matches your tag." exit 1 else echo "SKILL.md version validated: $MD_VERSION" fi else echo "No SKILL.md found, skipping frontmatter validation" fi - name: Detect publishability id: publishable run: | SKILL_PATH="${{ steps.parse.outputs.skill_path }}" INTERNAL=$(jq -r '.openclaw.internal // false' "$SKILL_PATH/skill.json") PUBLISHABLE=true if [ "$INTERNAL" = "true" ]; then PUBLISHABLE=false echo "Skill marked internal=true; will skip ClawHub publish." fi echo "internal=${INTERNAL}" >> $GITHUB_OUTPUT echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 - name: Install clawhub CLI if: steps.publishable.outputs.publishable == 'true' run: npm install -g clawhub - name: Login to ClawHub if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != '' run: | set -euo pipefail SITE=${CLAWHUB_SITE:-https://clawhub.ai} REGISTRY=${CLAWHUB_REGISTRY:-$SITE} export CLAWHUB_CONFIG_PATH="$HOME/.clawhub-ci/config.json" mkdir -p "$(dirname "$CLAWHUB_CONFIG_PATH")" CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \ clawhub login --token "$CLAWHUB_TOKEN" --site "$SITE" --no-input - name: Package release assets run: | set -euo pipefail SKILL_NAME="${{ steps.parse.outputs.skill_name }}" SKILL_PATH="${{ steps.parse.outputs.skill_path }}" VERSION="${{ steps.parse.outputs.version }}" TAG="${{ github.ref_name }}" mkdir -p release-assets # --- Stage SBOM files preserving directory structure --- STAGING_DIR="$(mktemp -d)" INNER_DIR="$STAGING_DIR/$SKILL_NAME" mkdir -p "$INNER_DIR" TEMPFILE="$(mktemp)" jq -r '.sbom.files[].path' "$SKILL_PATH/skill.json" > "$TEMPFILE" while IFS= read -r file; do [ -z "$file" ] && continue FULL_PATH="$SKILL_PATH/$file" if [ -f "$FULL_PATH" ]; then mkdir -p "$INNER_DIR/$(dirname "$file")" cp "$FULL_PATH" "$INNER_DIR/$file" else echo "::error file=$SKILL_PATH/skill.json::SBOM references missing file: $file" exit 1 fi done < "$TEMPFILE" cp "$SKILL_PATH/skill.json" "$INNER_DIR/skill.json" # --- Create zip preserving directory structure --- ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip" (cd "$STAGING_DIR" && zip -qr "$OLDPWD/release-assets/$ZIP_NAME" .) # --- Generate checksums.json via jq --- FILES_JSON="{}" while IFS= read -r file; do [ -z "$file" ] && continue FULL_PATH="$SKILL_PATH/$file" if [ -f "$FULL_PATH" ]; then SHA256=$(sha256sum "$FULL_PATH" | awk '{print $1}') SIZE=$(stat -c%s "$FULL_PATH" 2>/dev/null || stat -f%z "$FULL_PATH") FILES_JSON="$(echo "$FILES_JSON" | jq \ --arg key "$file" \ --arg sha "$SHA256" \ --argjson sz "$SIZE" \ '. + {($key): {sha256: $sha, size: $sz, path: $key}}')" fi done < "$TEMPFILE" rm -f "$TEMPFILE" SKILL_JSON_SHA=$(sha256sum "$SKILL_PATH/skill.json" | awk '{print $1}') SKILL_JSON_SIZE=$(stat -c%s "$SKILL_PATH/skill.json" 2>/dev/null || stat -f%z "$SKILL_PATH/skill.json") FILES_JSON="$(echo "$FILES_JSON" | jq \ --arg sha "$SKILL_JSON_SHA" \ --argjson sz "$SKILL_JSON_SIZE" \ '. + {"skill.json": {sha256: $sha, size: $sz}}')" ZIP_SHA=$(sha256sum "release-assets/$ZIP_NAME" | awk '{print $1}') ZIP_SIZE=$(stat -c%s "release-assets/$ZIP_NAME" 2>/dev/null || stat -f%z "release-assets/$ZIP_NAME") jq -n \ --arg skill "$SKILL_NAME" \ --arg version "$VERSION" \ --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg repo "${{ github.repository }}" \ --arg tag "$TAG" \ --arg zip_file "$ZIP_NAME" \ --arg zip_sha "$ZIP_SHA" \ --argjson zip_size "$ZIP_SIZE" \ --arg zip_url "https://github.com/${{ github.repository }}/releases/download/$TAG/$ZIP_NAME" \ --argjson files "$FILES_JSON" \ '{ skill: $skill, version: $version, generated_at: $generated, repository: $repo, tag: $tag, archive: { filename: $zip_file, sha256: $zip_sha, size: $zip_size, url: $zip_url }, files: $files }' > "release-assets/checksums.json" # --- Copy skill.json and root-level docs alongside the zip --- cp "$SKILL_PATH/skill.json" release-assets/skill.json if [ -f "$SKILL_PATH/SKILL.md" ]; then cp "$SKILL_PATH/SKILL.md" release-assets/ fi if [ -f "$SKILL_PATH/README.md" ]; then cp "$SKILL_PATH/README.md" release-assets/ fi rm -rf "$STAGING_DIR" echo "=== checksums.json ===" jq . "release-assets/checksums.json" echo "" echo "=== Release assets ===" ls -la release-assets/ - name: Publish to ClawHub if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != '' run: | set -euo pipefail SITE=${CLAWHUB_SITE:-https://clawhub.ai} REGISTRY=${CLAWHUB_REGISTRY:-$SITE} SKILL_PATH="${{ steps.parse.outputs.skill_path }}" SKILL_NAME="${{ steps.parse.outputs.skill_name }}" VERSION="${{ steps.parse.outputs.version }}" NAME=$(jq -r '.name' "$SKILL_PATH/skill.json") CHANGELOG="Release ${VERSION} via CI" export CLAWHUB_CONFIG_PATH="$HOME/.clawhub-ci/config.json" CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \ clawhub publish "$SKILL_PATH" \ --slug "$SKILL_NAME" \ --name "$NAME" \ --version "$VERSION" \ --changelog "$CHANGELOG" \ --tags "latest" \ --no-input - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}" tag_name: ${{ github.ref_name }} files: release-assets/* body: | ## ${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }} ### Quick Install **Via clawhub (recommended):** ```bash npx clawhub@latest install ${{ steps.parse.outputs.skill_name }} ``` **Manual download with verification:** ```bash # 1. Download the release archive and checksums curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json # 2. Verify archive checksum echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c # 3. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory) unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip ``` ### Verification All files include SHA256 checksums in `checksums.json`: ```bash curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json | jq . ``` ### Files See `checksums.json` for the complete file manifest with SHA256 hashes. The zip archive preserves the full directory structure of the skill. --- *Released by ClawSec skill distribution pipeline* draft: false prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Delete superseded releases run: | SKILL_NAME="${{ steps.parse.outputs.skill_name }}" CURRENT_VERSION="${{ steps.parse.outputs.version }}" # Extract major version from current release CURRENT_MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) echo "Current release: $SKILL_NAME v$CURRENT_VERSION (major: $CURRENT_MAJOR)" # List all releases for this skill gh release list --limit 100 | grep "^${SKILL_NAME} " | while read -r line; do # Extract tag from release list (3rd tab-delimited column) TAG=$(echo "$line" | awk -F'\t' '{print $3}') VERSION="${TAG#${SKILL_NAME}-v}" # Skip current version if [ "$VERSION" = "$CURRENT_VERSION" ]; then continue fi # Extract major version RELEASE_MAJOR=$(echo "$VERSION" | cut -d. -f1) # Only delete if same major version (preserve old majors for backwards compat) if [ "$RELEASE_MAJOR" = "$CURRENT_MAJOR" ]; then echo "Deleting $TAG (superseded by v$CURRENT_VERSION)" gh release delete "$TAG" --yes || echo "Warning: Could not delete $TAG" else echo "Keeping $TAG as latest for major version $RELEASE_MAJOR" fi done echo "Superseded release cleanup complete" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}