#!/bin/bash # Usage: ./scripts/release-skill.sh [--force-tag] # Example: ./scripts/release-skill.sh clawsec-feed 1.1.0 # # This script ensures version consistency by: # 1. Updating skill.json with the new version # 2. Updating any hardcoded version URLs in skill.json and SKILL.md # 3. Committing the changes # 4. Creating the git tag (only on main/master branch) # # Branch-aware workflow: # Feature branch: Updates versions, commits, pushes → CI validates build # Main branch: Updates versions, commits, creates tag → push triggers release # # Use --force-tag to create a tag even when not on main/master. set -euo pipefail # Parse arguments FORCE_TAG=false POSITIONAL_ARGS=() for arg in "$@"; do case $arg in --force-tag) FORCE_TAG=true ;; *) POSITIONAL_ARGS+=("$arg") ;; esac done if [ "${#POSITIONAL_ARGS[@]}" -ne 2 ]; then echo "Usage: $0 [--force-tag]" echo "Example: $0 clawsec-feed 1.1.0" exit 1 fi SKILL_NAME="${POSITIONAL_ARGS[0]}" VERSION="${POSITIONAL_ARGS[1]}" SKILL_PATH="skills/$SKILL_NAME" # Initialize variables RELEASE_NOTES="" # Ensure we're on a branch (not detached HEAD) so release flow works from feature branches CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD || true)" if [ -z "$CURRENT_BRANCH" ]; then echo "Error: Detached HEAD detected. Checkout a branch before running release." >&2 exit 1 fi # Determine if we're on a release branch (main/master) IS_RELEASE_BRANCH=false if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then IS_RELEASE_BRANCH=true fi # Security: Validate skill name to prevent path injection # Only allow lowercase alphanumeric characters and hyphens if ! [[ "$SKILL_NAME" =~ ^[a-z0-9-]+$ ]]; then echo "Error: Invalid skill name. Only lowercase alphanumeric characters and hyphens are allowed." echo "Example: clawsec-feed, prompt-agent, clawtributor" exit 1 fi if [ ! -f "$SKILL_PATH/skill.json" ]; then echo "Error: $SKILL_PATH/skill.json not found" exit 1 fi # Validate semver format (supports prerelease like 1.0.0-beta1) if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then echo "Error: Invalid version format. Use semver (e.g., 1.0.0, 1.1.0-beta1)" exit 1 fi TAG="${SKILL_NAME}-v${VERSION}" # Check for uncommitted changes in skill directory if ! git diff --quiet "$SKILL_PATH/" 2>/dev/null; then echo "Error: $SKILL_PATH/ has uncommitted changes. Please commit or stash them first." exit 1 fi echo "Releasing $SKILL_NAME version $VERSION" echo "Branch: $CURRENT_BRANCH" if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then echo "Mode: Full release (will create tag)" else echo "Mode: Prep only (tag deferred until merge to main)" fi echo "=======================================" # Create a temporary directory for atomic operations TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT # Track files that need to be staged FILES_TO_STAGE=() # Update version in skill.json echo "Updating $SKILL_PATH/skill.json version to $VERSION..." if ! jq --arg v "$VERSION" '.version = $v' "$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then echo "Error: Failed to update version in skill.json" exit 1 fi mv "$TEMP_DIR/skill.json" "$SKILL_PATH/skill.json" FILES_TO_STAGE+=("$SKILL_PATH/skill.json") # Update any hardcoded version URLs in skill.json (openclaw.feed_url pattern) if jq -e '.openclaw.feed_url' "$SKILL_PATH/skill.json" >/dev/null 2>&1; then echo "Updating openclaw.feed_url to use tag $TAG..." if ! jq --arg tag "$TAG" '.openclaw.feed_url = (.openclaw.feed_url | gsub("/[^/]+-v[0-9.]+(-[a-zA-Z0-9]+)?/"; "/\($tag)/"))' "$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then echo "Error: Failed to update feed_url in skill.json" exit 1 fi mv "$TEMP_DIR/skill.json" "$SKILL_PATH/skill.json" fi # Update version in SKILL.md frontmatter and ALL hardcoded version URLs (if file exists) if [ -f "$SKILL_PATH/SKILL.md" ]; then echo "Updating $SKILL_PATH/SKILL.md frontmatter version to $VERSION..." # Verify version line exists before sed if ! grep -qE "^version: " "$SKILL_PATH/SKILL.md"; then echo "Error: SKILL.md missing 'version:' line in frontmatter" >&2 echo " Expected format: 'version: X.Y.Z' at start of line" >&2 exit 1 fi # Apply sed and verify substitution occurred sed "s/^version: .*/version: $VERSION/" "$SKILL_PATH/SKILL.md" > "$TEMP_DIR/SKILL.md" if ! grep -qF "version: $VERSION" "$TEMP_DIR/SKILL.md"; then echo "Error: Failed to update version in SKILL.md frontmatter" >&2 echo " Target version: $VERSION" >&2 exit 1 fi echo " ✓ Version updated to $VERSION" echo "Updating hardcoded version URLs in SKILL.md to use tag $TAG..." # Replace all hardcoded version URLs: download/SKILLNAME-vX.Y.Z(-prerelease)?/ -> download/TAG/ # This handles patterns like: download/clawsec-feed-v1.0.0/ or download/prompt-agent-v1.0.0-beta1/ PATTERN="/download/${SKILL_NAME}-v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?/" # Check if pattern exists (warn if not, don't fail - some skills may not self-reference) if grep -qE "$PATTERN" "$TEMP_DIR/SKILL.md"; then sed -E "s|$PATTERN|/download/${TAG}/|g" "$TEMP_DIR/SKILL.md" > "$TEMP_DIR/SKILL.md.tmp" # Verify substitution occurred if ! grep -qF "/download/${TAG}/" "$TEMP_DIR/SKILL.md.tmp"; then echo "Warning: URL pattern found but substitution may have failed" >&2 else URL_COUNT=$(grep -cF "/download/${TAG}/" "$TEMP_DIR/SKILL.md.tmp") echo " ✓ Updated $URL_COUNT hardcoded URL(s)" fi mv "$TEMP_DIR/SKILL.md.tmp" "$TEMP_DIR/SKILL.md" else echo " ℹ No hardcoded version URLs found (OK if skill doesn't self-reference)" fi mv "$TEMP_DIR/SKILL.md" "$SKILL_PATH/SKILL.md" FILES_TO_STAGE+=("$SKILL_PATH/SKILL.md") fi # Update hardcoded version URLs in other markdown files (heartbeat.md, reporting.md, etc.) for md_file in "$SKILL_PATH"/*.md; do if [ -f "$md_file" ] && [ "$md_file" != "$SKILL_PATH/SKILL.md" ]; then filename=$(basename "$md_file") echo "Updating hardcoded version URLs in $filename..." PATTERN="/download/${SKILL_NAME}-v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?/" # Check if pattern exists if grep -qE "$PATTERN" "$md_file"; then sed -E "s|$PATTERN|/download/${TAG}/|g" "$md_file" > "$TEMP_DIR/$filename" # Verify substitution occurred if ! grep -qF "/download/${TAG}/" "$TEMP_DIR/$filename"; then echo " Warning: URL pattern found but substitution may have failed in $filename" >&2 else URL_COUNT=$(grep -cF "/download/${TAG}/" "$TEMP_DIR/$filename") echo " ✓ Updated $URL_COUNT URL(s) in $filename" fi mv "$TEMP_DIR/$filename" "$md_file" FILES_TO_STAGE+=("$md_file") else echo " ℹ No hardcoded version URLs found in $filename (skipping)" fi fi done # Show what changed echo "" echo "Changes to $SKILL_PATH/:" git diff "$SKILL_PATH/" || true echo "" # Stage all changed files atomically echo "Staging changes..." for file in "${FILES_TO_STAGE[@]}"; do git add "$file" done # Verify staged changes before committing MADE_COMMIT=false if git diff --cached --quiet; then echo "Note: Version already at $VERSION — no changes to commit" COMMIT_SHA=$(git rev-parse HEAD) else # Commit the version bump echo "Committing changes..." if ! git commit -m "chore($SKILL_NAME): bump version to $VERSION"; then echo "Error: Failed to commit changes" exit 1 fi COMMIT_SHA=$(git rev-parse HEAD) MADE_COMMIT=true fi # Save commit SHA for recovery echo "Committed: $COMMIT_SHA" # Create tag only on release branches (or if forced) if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then # Check if tag already exists (only matters when we're creating one) if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Error: Tag $TAG already exists" if [[ "$MADE_COMMIT" == "true" ]]; then echo "Rolling back version-bump commit..." git reset --hard HEAD~1 fi exit 1 fi echo "Creating tag: $TAG" if ! git tag -a "$TAG" -m "$SKILL_NAME version $VERSION"; then echo "Error: Failed to create tag $TAG" >&2 echo "" >&2 echo "The commit has been created but NOT tagged:" >&2 echo " Commit: $COMMIT_SHA" >&2 echo "" >&2 echo "Recovery options:" >&2 echo " 1. Fix the issue and tag manually:" >&2 echo " git tag -a '$TAG' -m '$SKILL_NAME version $VERSION' $COMMIT_SHA" >&2 echo "" >&2 echo " 2. Investigate why tagging failed:" >&2 echo " - Check if tag exists: git tag -l '$TAG'" >&2 echo " - Check permissions: ls -ld .git/refs/tags" >&2 echo "" >&2 echo " 3. To rollback the commit (if desired):" >&2 echo " git reset --hard HEAD~1" >&2 echo "" >&2 echo "The commit has NOT been pushed. Fix the issue before pushing." >&2 exit 1 fi # Extract changelog entry for this version and create GitHub release RELEASE_NOTES="" GH_RELEASE_CREATED=false if [ -f "$SKILL_PATH/CHANGELOG.md" ]; then echo "Extracting changelog entry for version $VERSION..." # Extract the changelog section for this version # Pattern: ## [VERSION] - DATE ... until next ## [ or end of file RELEASE_NOTES=$(awk -v version="$VERSION" ' BEGIN { in_section = 0; found = 0 } $0 ~ ("^## \\[" version "\\]") { in_section = 1; found = 1; next } in_section && /^## \[/ && found { exit } in_section { print } ' "$SKILL_PATH/CHANGELOG.md" | sed '/^$/d' | sed '1{/^$/d;}') if [ -n "$RELEASE_NOTES" ]; then echo "Found changelog entry with $(echo "$RELEASE_NOTES" | wc -l) lines" # Create GitHub release with changelog notes echo "Creating GitHub release with changelog notes..." if command -v gh >/dev/null 2>&1; then if ! echo "$RELEASE_NOTES" | gh release create "$TAG" \ --title "$SKILL_NAME v$VERSION" \ --notes-file -; then echo "Warning: Failed to create GitHub release, but tag was created successfully" >&2 echo "You can manually create the release at: https://github.com/$(git remote get-url origin | sed 's/.*github.com[:/]\([^.]*\).*/\1/')/releases/new" >&2 else echo "✓ GitHub release created with changelog notes" GH_RELEASE_CREATED=true fi else echo "Warning: GitHub CLI (gh) not found. Skipping automatic release creation." >&2 echo "Install GitHub CLI and run manually:" >&2 echo " gh release create '$TAG' --title '$SKILL_NAME v$VERSION' --notes-file <(echo \"$RELEASE_NOTES\")" >&2 fi else echo "Warning: No changelog entry found for version $VERSION" >&2 fi else echo "No CHANGELOG.md found in $SKILL_PATH - skipping release notes" fi echo "" echo "Done! To release, push the tag:" if [[ "$MADE_COMMIT" == "true" ]]; then echo " git push origin $CURRENT_BRANCH" fi echo " git push origin $TAG" echo "" echo "Or to undo:" if [[ "$MADE_COMMIT" == "true" ]]; then echo " git reset --hard HEAD~1 && git tag -d $TAG" else echo " git tag -d $TAG" fi if [[ "$GH_RELEASE_CREATED" == "true" ]]; then echo "" echo "Note: GitHub release was created automatically with changelog notes." fi else # Feature branch: skip tagging, instruct user on next steps echo "" echo "Done! Version updated and committed (tag deferred)." echo "" echo "Next steps:" echo " 1. Push your branch for CI validation:" echo " git push origin $CURRENT_BRANCH" echo "" echo " 2. After CI passes and PR is merged to main, create the tag and release:" echo " git checkout main && git pull" echo " git tag -a '$TAG' $COMMIT_SHA -m '$SKILL_NAME version $VERSION'" echo " git push origin $TAG" if [ -f "$SKILL_PATH/CHANGELOG.md" ]; then echo " # Create GitHub release with changelog (requires GitHub CLI):" echo " gh release create '$TAG' --title '$SKILL_NAME v$VERSION' --generate-notes" fi echo "" echo "Or to undo the version bump:" echo " git reset --hard HEAD~1" fi