fix(release): exclude tests from skill payloads (#230)

* fix(release): exclude tests from skill payloads

* fix(release): normalize test path filtering

* fix(release): prefer GitHub artifacts for non-OpenClaw installs

* fix(release): keep legacy ClawHub publishing

* fix(release): address skill packaging review feedback

* chore(skills): bump release versions

* feat(skills): surface recommended platforms

* docs(skills): add signed release verification

* fix(skills): normalize PR version bumps

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
This commit is contained in:
David Abutbul
2026-05-14 14:38:58 +03:00
committed by GitHub
parent 0e503c3d5a
commit 1e48a955cc
54 changed files with 1645 additions and 269 deletions
+12 -3
View File
@@ -184,15 +184,23 @@ jobs:
# 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(.[$name]?);
object_or_empty($skill[$name]?);
def platform_meta:
(.platform as $platform
| if ($platform | type) == "string" then object_or_empty(.[$platform]?)
($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,
@@ -200,6 +208,7 @@ jobs:
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
}
+182 -50
View File
@@ -375,6 +375,26 @@ jobs:
failures=0
mkdir -p dist/dry-run
normalize_release_path() {
local path="$1"
path="${path//\\//}"
while [[ "$path" == ./* ]]; do
path="${path#./}"
done
while [[ "$path" == *//* ]]; do
path="${path//\/\//\/}"
done
if [[ -z "$path" || "$path" == /* || "$path" == [A-Za-z]:* || "$path" == ".." || "$path" == ../* || "$path" == */.. || "$path" == */../* ]]; then
return 1
fi
printf '%s\n' "$path"
}
is_test_release_path() {
local lower="${1,,}"
[[ "$lower" == test/* || "$lower" == tests/* || "$lower" == */test/* || "$lower" == */tests/* ]]
}
while IFS= read -r skill_dir; do
json_path="${skill_dir}/skill.json"
md_path="${skill_dir}/SKILL.md"
@@ -477,8 +497,17 @@ jobs:
temp_sbom_file="$(mktemp)"
jq -r '.sbom.files[].path' "${json_path}" > "${temp_sbom_file}"
while IFS= read -r file; do
[ -z "${file}" ] && continue
while IFS= read -r raw_file; do
[ -z "${raw_file}" ] && continue
if ! file="$(normalize_release_path "${raw_file}")"; then
echo "::error file=${json_path}::SBOM references unsafe file path: ${raw_file}"
failures=$((failures + 1))
continue
fi
if is_test_release_path "${file}"; then
echo " [Dry-run] Skipping test-only release file: ${file}"
continue
fi
full_path="${skill_dir}/${file}"
if [ -f "${full_path}" ]; then
mkdir -p "${inner_dir}/$(dirname "${file}")"
@@ -504,6 +533,11 @@ jobs:
# --- Create zip preserving directory structure ---
zip_name="${skill_name}-v${version}.zip"
(cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .)
if unzip -Z1 "${out_assets}/${zip_name}" | grep -Eiq '(^|/)(test|tests)/'; then
echo "::error::Dry-run release archive contains test-only files: ${zip_name}"
unzip -Z1 "${out_assets}/${zip_name}" | grep -Ei '(^|/)(test|tests)/' || true
failures=$((failures + 1))
fi
# --- Clean up test artifacts from source directory ---
if [ -d "${skill_dir}/advisories" ]; then
@@ -515,8 +549,14 @@ jobs:
# --- Generate checksums.json via jq ---
files_json="{}"
while IFS= read -r file; do
[ -z "${file}" ] && continue
while IFS= read -r raw_file; do
[ -z "${raw_file}" ] && continue
if ! file="$(normalize_release_path "${raw_file}")"; then
continue
fi
if is_test_release_path "${file}"; then
continue
fi
full_path="${skill_dir}/${file}"
if [ -f "${full_path}" ]; then
sha256="$(sha256sum "${full_path}" | awk '{print $1}')"
@@ -615,6 +655,8 @@ jobs:
version: ${{ steps.parse.outputs.version }}
skill_path: ${{ steps.parse.outputs.skill_path }}
publishable: ${{ steps.publishable.outputs.publishable }}
openclaw_skill: ${{ steps.publishable.outputs.openclaw_skill }}
publish_clawhub: ${{ steps.publishable.outputs.publish_clawhub }}
steps:
- name: Parse tag
id: parse
@@ -686,22 +728,35 @@ jobs:
echo "SKILL.md version validated: $MD_VERSION"
fi
else
echo "No SKILL.md found, skipping frontmatter validation"
echo "::error::Missing required SKILL.md: $SKILL_PATH/SKILL.md"
exit 1
fi
- name: Detect publishability
- name: Detect publishability and install defaults
id: publishable
run: |
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
INTERNAL=$(jq -r '.openclaw.internal // false' "$SKILL_PATH/skill.json")
INTERNAL=$(jq -r 'if (.openclaw | type) == "object" then (.openclaw.internal // false) else false end' "$SKILL_PATH/skill.json")
OPENCLAW_SKILL=false
if jq -e '(.openclaw | type == "object") and ((.openclaw | length) > 0)' "$SKILL_PATH/skill.json" >/dev/null; then
OPENCLAW_SKILL=true
fi
PUBLISHABLE=true
if [ "$INTERNAL" = "true" ]; then
PUBLISHABLE=false
echo "Skill marked internal=true; will skip ClawHub publish."
echo "Skill marked internal=true; will skip ClawHub publishing."
fi
PUBLISH_CLAWHUB=false
if [ "$PUBLISHABLE" = "true" ]; then
PUBLISH_CLAWHUB=true
fi
echo "internal=${INTERNAL}" >> $GITHUB_OUTPUT
echo "openclaw_skill=${OPENCLAW_SKILL}" >> $GITHUB_OUTPUT
echo "publish_clawhub=${PUBLISH_CLAWHUB}" >> $GITHUB_OUTPUT
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
- name: Setup Node
@@ -788,6 +843,26 @@ jobs:
mkdir -p release-assets
normalize_release_path() {
local path="$1"
path="${path//\\//}"
while [[ "$path" == ./* ]]; do
path="${path#./}"
done
while [[ "$path" == *//* ]]; do
path="${path//\/\//\/}"
done
if [[ -z "$path" || "$path" == /* || "$path" == [A-Za-z]:* || "$path" == ".." || "$path" == ../* || "$path" == */.. || "$path" == */../* ]]; then
return 1
fi
printf '%s\n' "$path"
}
is_test_release_path() {
local lower="${1,,}"
[[ "$lower" == test/* || "$lower" == tests/* || "$lower" == */test/* || "$lower" == */tests/* ]]
}
# --- Stage SBOM files preserving directory structure ---
STAGING_DIR="$(mktemp -d)"
INNER_DIR="$STAGING_DIR/$SKILL_NAME"
@@ -795,8 +870,16 @@ jobs:
TEMPFILE="$(mktemp)"
jq -r '.sbom.files[].path' "$SKILL_PATH/skill.json" > "$TEMPFILE"
while IFS= read -r file; do
[ -z "$file" ] && continue
while IFS= read -r raw_file; do
[ -z "$raw_file" ] && continue
if ! file="$(normalize_release_path "$raw_file")"; then
echo "::error file=$SKILL_PATH/skill.json::SBOM references unsafe file path: $raw_file"
exit 1
fi
if is_test_release_path "$file"; then
echo "Skipping test-only release file: $file"
continue
fi
FULL_PATH="$SKILL_PATH/$file"
if [ -f "$FULL_PATH" ]; then
mkdir -p "$INNER_DIR/$(dirname "$file")"
@@ -812,11 +895,22 @@ jobs:
# --- Create zip preserving directory structure ---
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
(cd "$STAGING_DIR" && zip -qr "$OLDPWD/release-assets/$ZIP_NAME" .)
if unzip -Z1 "release-assets/$ZIP_NAME" | grep -Eiq '(^|/)(test|tests)/'; then
echo "::error::Release archive contains test-only files: $ZIP_NAME"
unzip -Z1 "release-assets/$ZIP_NAME" | grep -Ei '(^|/)(test|tests)/' || true
exit 1
fi
# --- Generate checksums.json via jq ---
FILES_JSON="{}"
while IFS= read -r file; do
[ -z "$file" ] && continue
while IFS= read -r raw_file; do
[ -z "$raw_file" ] && continue
if ! file="$(normalize_release_path "$raw_file")"; then
continue
fi
if is_test_release_path "$file"; then
continue
fi
FULL_PATH="$SKILL_PATH/$file"
if [ -f "$FULL_PATH" ]; then
SHA256=$(sha256sum "$FULL_PATH" | awk '{print $1}')
@@ -946,6 +1040,71 @@ jobs:
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Build quick install instructions
id: install
run: |
set -euo pipefail
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
VERSION="${{ steps.parse.outputs.version }}"
REPO="${{ github.repository }}"
TAG="${{ github.ref_name }}"
{
echo "quick_install<<INSTALL_EOF"
if [ "${{ steps.publishable.outputs.publish_clawhub }}" = "true" ] && [ "${{ steps.publishable.outputs.openclaw_skill }}" = "true" ]; then
cat <<EOF
### Quick Install
**Via ClawHub (recommended):**
\`\`\`bash
npx clawhub@latest install ${SKILL_NAME}
\`\`\`
**If you already have \`clawsec-suite\` installed:**
Ask your agent to pull \`${SKILL_NAME}\` from the ClawSec catalog and it will handle setup and verification automatically.
EOF
else
cat <<EOF
### Quick Install
**GitHub release artifact (recommended):**
Ask your agent to read the published skill instructions from this GitHub release and follow them:
https://github.com/${REPO}/releases/download/${TAG}/SKILL.md
Or download them locally:
\`\`\`bash
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/SKILL.md
\`\`\`
EOF
fi
cat <<EOF
**Manual download with verification:**
\`\`\`bash
# 1. Download the release archive, checksums, and signing material
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/${SKILL_NAME}-v${VERSION}.zip
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/checksums.json
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/checksums.sig
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/signing-public.pem
# 2. Verify the checksums manifest signature (Ed25519)
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
# 3. Verify archive checksum from the signed manifest
echo "\$(jq -r '.archive.sha256' checksums.json) ${SKILL_NAME}-v${VERSION}.zip" | sha256sum -c
# 4. Extract (creates ${SKILL_NAME}/ directory)
unzip ${SKILL_NAME}-v${VERSION}.zip
\`\`\`
EOF
echo "INSTALL_EOF"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
@@ -957,34 +1116,7 @@ jobs:
${{ steps.changelog.outputs.changelog }}
### Quick Install
**Via clawhub (recommended):**
```bash
npx clawhub@latest install ${{ steps.parse.outputs.skill_name }}
```
**If you already have `clawsec-suite` installed:**
Ask your agent to pull `${{ steps.parse.outputs.skill_name }}` from the ClawSec catalog and it will handle setup and verification automatically.
**Manual download with verification:**
```bash
# 1. Download the release archive, checksums, and signing material
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
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
# 2. Verify the checksums manifest signature (Ed25519)
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
# 3. Verify archive checksum from the signed manifest
echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c
# 4. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
```
${{ steps.install.outputs.quick_install }}
### Verification
@@ -1061,28 +1193,28 @@ jobs:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
steps:
- name: Check if publishable
if: needs.release-tag.outputs.publishable != 'true'
if: needs.release-tag.outputs.publish_clawhub != 'true'
run: |
echo "Skill marked as internal, skipping ClawHub publish"
echo "Skill is not eligible for ClawHub publishing; skipping"
exit 0
- name: Checkout
if: needs.release-tag.outputs.publishable == 'true'
if: needs.release-tag.outputs.publish_clawhub == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
if: needs.release-tag.outputs.publishable == 'true'
if: needs.release-tag.outputs.publish_clawhub == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Install clawhub CLI
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: npm install -g clawhub@${CLAWHUB_CLI_VERSION}
- name: Patch clawhub publish payload workaround
# Temporary: clawhub@0.7.0 publish payload is missing acceptLicenseTerms.
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: |
node <<'NODE'
const { execSync } = require("node:child_process");
@@ -1125,7 +1257,7 @@ jobs:
NODE
- name: Login to ClawHub
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: |
set -euo pipefail
SITE=${CLAWHUB_SITE:-https://clawhub.ai}
@@ -1136,7 +1268,7 @@ jobs:
clawhub login --token "$CLAWHUB_TOKEN" --site "$SITE" --no-input
- name: Guard duplicate ClawHub version
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: |
set -euo pipefail
SITE=${CLAWHUB_SITE:-https://clawhub.ai}
@@ -1166,7 +1298,7 @@ jobs:
fi
- name: Publish to ClawHub
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: |
set -euo pipefail
SITE=${CLAWHUB_SITE:-https://clawhub.ai}
@@ -1240,7 +1372,7 @@ jobs:
id: publishable
run: |
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
INTERNAL=$(jq -r '.openclaw.internal // false' "$SKILL_PATH/skill.json")
INTERNAL=$(jq -r 'if (.openclaw | type) == "object" then (.openclaw.internal // false) else false end' "$SKILL_PATH/skill.json")
if [ "$INTERNAL" = "true" ]; then
echo "::error::Skill is marked internal and cannot be published to ClawHub"