mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user